From 48a984f5e94932be470d615e950b2ddbb21760c9 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 20 Jul 2026 16:47:19 +0300 Subject: [PATCH 01/12] feat(personalized_feed): added fixtures for new feed, added new feed parsing and layout, added webview player for post videos --- .../feed/models/feed_blog_post_model.dart | 4 + .../feed/models/post_feed_mapper.dart | 41 ++ .../models/post_feed_pagination_model.dart | 93 +++ .../bloc/feed_cubit/feed_cubit.dart | 61 +- .../feed/presentation/pages/feed_screen.dart | 11 +- .../widgets/blog_post_widget.dart | 608 +++++++++++------- .../feed/presentation/widgets/blog_posts.dart | 21 +- .../widgets/feed_video_player_widget.dart | 272 ++++++++ .../feed/repositories/feed_repository.dart | 11 +- lib/features/feed/services/feed_service.dart | 7 + pubspec.yaml | 2 + .../feed/models/post_feed_mapper_test.dart | 56 ++ .../post_feed_pagination_model_test.dart | 57 ++ .../bloc/feed_cubit/feed_cubit_test.dart | 231 +------ .../presentation/bloc/feed_cubit_test.dart | 408 +----------- .../widgets/basic_widget_test.dart | 10 +- .../widgets/blog_post_widget_test.dart | 2 +- .../repositories/feed_repository_test.dart | 85 ++- .../feed/services/feed_service_test.dart | 27 + .../feed/post_feed_empty_response.json | 15 + .../feed/post_feed_next_page_response.json | 45 ++ test/fixtures/feed/post_feed_response.json | 115 ++++ test/fixtures/feed_fixtures.dart | 51 ++ 23 files changed, 1330 insertions(+), 903 deletions(-) create mode 100644 lib/features/feed/models/post_feed_mapper.dart create mode 100644 lib/features/feed/models/post_feed_pagination_model.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player_widget.dart create mode 100644 test/features/feed/models/post_feed_mapper_test.dart create mode 100644 test/features/feed/models/post_feed_pagination_model_test.dart create mode 100644 test/fixtures/feed/post_feed_empty_response.json create mode 100644 test/fixtures/feed/post_feed_next_page_response.json create mode 100644 test/fixtures/feed/post_feed_response.json create mode 100644 test/fixtures/feed_fixtures.dart diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 89eb5838..157a2da0 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'feed_blog_post_model.freezed.dart'; @@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel { @JsonKey(name: "total_views") required int totalViews, @JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, + @Default([]) List images, + @JsonKey(name: "video_embed_url") String? videoEmbedUrl, + @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; factory FeedBlogPostModel.fromJson(Map json) => _$FeedBlogPostModelFromJson(json); diff --git a/lib/features/feed/models/post_feed_mapper.dart b/lib/features/feed/models/post_feed_mapper.dart new file mode 100644 index 00000000..f88e01ed --- /dev/null +++ b/lib/features/feed/models/post_feed_mapper.dart @@ -0,0 +1,41 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; + +class PostFeedMapper { + static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) { + return FeedBlogPostModel( + id: item.postId, + state: item.postState, + image: resolveMediaUrl(item.postImageUrl), + description: item.postTextMd ?? item.postShortDescription ?? '', + author: Author( + authorSlug: item.postAuthorDetails?.slug ?? '', + authorName: item.postAuthorName, + authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl), + authorIsMe: currentUserId != null && item.postAuthorId == currentUserId, + isEntitySubscribedByMe: item.isEntitySubscribedByMe, + ), + videoUrl: '', + createdAt: item.postCreatedAt, + updatedAt: item.updatedAt, + title: item.postTitle, + totalLikes: item.postLikesCount, + totalViews: item.postViewsCount, + totalComments: item.postCommentsCount, + wasLikedByMe: item.postIsLikedByUser, + images: item.images, + videoEmbedUrl: item.videoEmbedUrl, + videoUrlThumb: item.videoUrlThumb, + ); + } + + static String resolveMediaUrl(String? url) { + if (url == null || url.isEmpty) return ''; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + if (url.startsWith('/')) return '${sl().baseUrl}$url'; + return '${sl().baseUrl}/storage/$url'; + } +} diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart new file mode 100644 index 00000000..c558e64e --- /dev/null +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -0,0 +1,93 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'post_feed_pagination_model.freezed.dart'; +part 'post_feed_pagination_model.g.dart'; + +@freezed +abstract class PostFeedPaginationModel with _$PostFeedPaginationModel { + const factory PostFeedPaginationModel({ + @JsonKey(name: 'data') required List data, + required CursorLinks links, + required CursorMeta meta, + }) = _PostFeedPaginationModel; + + factory PostFeedPaginationModel.fromJson(Map json) => _$PostFeedPaginationModelFromJson(json); +} + +@freezed +abstract class PostFeedItemModel with _$PostFeedItemModel { + const factory PostFeedItemModel({ + required int id, + @JsonKey(name: 'post_id') required int postId, + @JsonKey(name: 'post_author_id') required int postAuthorId, + @JsonKey(name: 'post_author_name') required String postAuthorName, + @JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl, + @JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl, + @JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt, + @JsonKey(name: 'post_title') required String postTitle, + @JsonKey(name: 'post_short_description') String? postShortDescription, + @JsonKey(name: 'post_text_md') String? postTextMd, + @JsonKey(name: 'post_image_url') String? postImageUrl, + @Default([]) List images, + @JsonKey(name: 'video_embed_url') String? videoEmbedUrl, + @JsonKey(name: 'post_views_count') required int postViewsCount, + @JsonKey(name: 'post_likes_count') required int postLikesCount, + @JsonKey(name: 'post_comments_count') required int postCommentsCount, + @JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser, + @JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe, + @JsonKey(name: 'post_url') String? postUrl, + @JsonKey(name: 'post_state') required String postState, + @JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails, + @JsonKey(name: "video_url_thumb") String? videoUrlThumb, + @JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt, + @JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt, + }) = _PostFeedItemModel; + + factory PostFeedItemModel.fromJson(Map json) => _$PostFeedItemModelFromJson(json); +} + +@freezed +abstract class FeedGalleryImage with _$FeedGalleryImage { + const factory FeedGalleryImage({ + required String url, + required String orientation, + }) = _FeedGalleryImage; + + factory FeedGalleryImage.fromJson(Map json) => _$FeedGalleryImageFromJson(json); +} + +@freezed +abstract class PostAuthorDetails with _$PostAuthorDetails { + const factory PostAuthorDetails({ + required String slug, + @JsonKey(name: 'is_consultant') required bool isConsultant, + }) = _PostAuthorDetails; + + factory PostAuthorDetails.fromJson(Map json) => _$PostAuthorDetailsFromJson(json); +} + +@freezed +abstract class CursorLinks with _$CursorLinks { + const factory CursorLinks({ + required String? first, + required String? last, + required String? prev, + required String? next, + }) = _CursorLinks; + + factory CursorLinks.fromJson(Map json) => _$CursorLinksFromJson(json); +} + +@freezed +abstract class CursorMeta with _$CursorMeta { + const factory CursorMeta({ + required String path, + @JsonKey(name: 'per_page') required int perPage, + @JsonKey(name: 'next_cursor') String? nextCursor, + @JsonKey(name: 'prev_cursor') String? prevCursor, + }) = _CursorMeta; + + factory CursorMeta.fromJson(Map json) => _$CursorMetaFromJson(json); +} + +DateTime parseDateTime(String date) => DateTime.parse(date).toLocal(); diff --git a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart index 9254bf07..a88d4623 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class FeedCubit extends Cubit { final FeedRepository feedRepository; - int currentPage = 1; - late int lastPage; + String? _nextCursor; + bool _hasMore = true; bool isLoading = false; String? _query; @@ -38,13 +38,16 @@ class FeedCubit extends Cubit { } } - Future loadFeedBlogPostList({String? query}) async { + Future loadFeedBlogPostList({String? query, int? currentUserId}) async { try { _query = query; - final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query); - lastPage = feedBlogPosts.meta.lastPage; + _nextCursor = null; + _hasMore = true; + final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId); + _nextCursor = feed.nextCursor; + _hasMore = _nextCursor != null; if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false)); + emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); } } catch (e, st) { await reportFrom(e, st, this); @@ -54,29 +57,35 @@ class FeedCubit extends Cubit { } } - Future loadNextPage() async { - if (currentPage < lastPage && !isLoading && !isClosed) { - isLoading = true; + Future loadNextPage({int? currentUserId}) async { + if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) { + return; + } - if (!isClosed) { - emit((state as FeedStateLoaded).copyWith(isLoading: isLoading)); - } + isLoading = true; + emit((state as FeedStateLoaded).copyWith(isLoading: true)); - try { - if (currentPage < lastPage && !isClosed) { - currentPage++; - final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query); - isLoading = false; - if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading)); - } - } - } catch (e, st) { - await reportFrom(e, st, this); - if (!isClosed) { - emit(FeedState.error(errorMessage: e.toString())); - } + try { + final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId); + _nextCursor = feed.nextCursor; + _hasMore = _nextCursor != null; + if (!isClosed && state is FeedStateLoaded) { + emit( + FeedState.loaded( + feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts], + isLoading: false, + ), + ); } + } catch (e, st) { + await reportFrom(e, st, this); + if (!isClosed && state is FeedStateLoaded) { + emit((state as FeedStateLoaded).copyWith(isLoading: false)); + } else if (!isClosed) { + emit(FeedState.error(errorMessage: e.toString())); + } + } finally { + isLoading = false; } } diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 5eb4f454..58664992 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -83,7 +83,7 @@ class _FeedScreenContentState extends State { final state = feedCubit.state; // Загружаем данные, если они еще не загружены if (state is! FeedStateLoaded) { - feedCubit.loadFeedBlogPostList(); + feedCubit.loadFeedBlogPostList(currentUserId: context.read().state.profile?.id); } }); super.initState(); @@ -201,7 +201,10 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(query: query); + context.read().loadFeedBlogPostList( + query: query, + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(query: query); @@ -224,7 +227,9 @@ class _FeedScreenContentState extends State { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { switch (_selectedChipIndex) { case 0: - context.read().loadFeedBlogPostList(); + context.read().loadFeedBlogPostList( + currentUserId: context.read().state.profile?.id, + ); break; case 1 when !hidePlans: context.read().loadPlans(); diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 4192aaff..7ad825a5 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; @@ -9,8 +10,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -19,9 +22,9 @@ import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; class BlogPostWidget extends StatefulWidget { - final int index; + final FeedBlogPostModel post; - const BlogPostWidget({super.key, required this.index}); + const BlogPostWidget({super.key, required this.post}); @override State createState() => _BlogPostWidgetState(); @@ -30,243 +33,378 @@ class BlogPostWidget extends StatefulWidget { class _BlogPostWidgetState extends State { late bool isLiked; late int totalLikes; - bool isMoreOptionsVisible = false; + int currentGalleryIndex = 0; + + FeedBlogPostModel get post => widget.post; + + @override + void initState() { + super.initState(); + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + } + + @override + void didUpdateWidget(covariant BlogPostWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.post.id != post.id) { + currentGalleryIndex = 0; + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + return; + } + if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + } + } + + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + + Future _toggleLike() async { + final nextLiked = !isLiked; + setState(() { + isLiked = nextLiked; + nextLiked ? totalLikes++ : totalLikes--; + }); + context.read().updateFeedBlogPostList(post, nextLiked); + if (nextLiked) { + await context.read().likePost(post.id); + } else { + await context.read().unlikedPost(post.id); + } + } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (BuildContext context, FeedState state) { - if (state is FeedStateLoaded) { - isLiked = state.feedBlogPostList[widget.index].wasLikedByMe; - totalLikes = state.feedBlogPostList[widget.index].totalLikes; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id), - child: Material( - borderRadius: BorderRadius.circular(12), - color: Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () { - GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug); - }, - child: CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https') - ? state.feedBlogPostList[widget.index].author.authorProfileIcon - : sl().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), - ), - const SizedBox(width: 8), - // Имя и дата внутри Expanded - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - state.feedBlogPostList[widget.index].author.authorName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), - ), - const SizedBox(height: 4), - Text( - '${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), - ), - ], - ), - ), - // Кнопка подписки (если нужно) - if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) && - !state.feedBlogPostList[widget.index].author.authorIsMe) - Padding( - padding: const EdgeInsets.only(right: 8), - child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug), - ), - // Кнопка меню - CustomPopup( - isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe, - complainedContentType: ComplainedContent.posts, - path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}', - id: state.feedBlogPostList[widget.index].id, - feedBlogPostModel: state.feedBlogPostList[widget.index], - userSlug: state.feedBlogPostList[widget.index].author.authorSlug, - ), - ], - ), - ), - if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.feedBlogPostList[widget.index].title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), - ), - ), - ], - if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8), - if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty) - const SizedBox(height: 8), - if (state.feedBlogPostList[widget.index].description.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Builder( - builder: (context) { - String description = state.feedBlogPostList[widget.index].description; - final isLong = description.length > 200; - - if (isLong) { - // Умная обрезка: обрезаем markdown текст, не ломая синтаксис - int cutPoint = 200; - - // Проверяем, есть ли незакрытые markdown теги - String preview = description.substring(0, cutPoint); - - // Считаем открывающие и закрывающие теги - int boldOpen = (preview.split('**').length - 1) ~/ 2; - int boldClose = preview.split('**').length - 1 - boldOpen * 2; - bool hasUnclosedBold = boldOpen > boldClose; - - // Если есть незакрытый bold, ищем закрытие - if (hasUnclosedBold) { - int searchEnd = (cutPoint + 100).clamp(0, description.length); - int boldClosePos = description.indexOf('**', cutPoint); - if (boldClosePos != -1 && boldClosePos < searchEnd) { - cutPoint = boldClosePos + 2; - } else { - // Если не нашли, обрезаем до последнего пробела - cutPoint = description.lastIndexOf(' ', 200); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - } else { - // Обрезаем до последнего пробела для красоты - cutPoint = description.lastIndexOf(' ', cutPoint); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - - description = description.substring(0, cutPoint); - } + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList(); + final embedUrl = post.videoEmbedUrl?.trim(); - return MarkdownHtmlWidget( - markdownText: description, - ); - }, - ), - ), - if (state.feedBlogPostList[widget.index].description.length > 200) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), - ), - ), - if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty) - const SizedBox(height: 8), - GestureDetector( - onTap: () { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: ['${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - onTap: () async { - setState(() { - isLiked = !isLiked; - isLiked ? totalLikes++ : totalLikes--; - }); - // Update feed list immediately by ID (no reload) - context.read().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked); - // Fire network call without introducing UI delay - if (isLiked) { - await context.read().likePost(state.feedBlogPostList[widget.index].id); - } else { - await context.read().unlikedPost(state.feedBlogPostList[widget.index].id); - } - }, - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - 'assets/icons/comment.svg', - height: 20, - width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - text: state.feedBlogPostList[widget.index].totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id), - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - height: 15, - width: 12, - 'assets/icons/repost.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - onTap: () { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); - }, - ), - const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), - const SizedBox(width: 4), - Text( - state.feedBlogPostList[widget.index].totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), - ), - ], - ), - ), - const SizedBox(height: 8), - ], - ), + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + child: Material( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + _BlogPostHeader(post: post), + _BlogPostTextContent(post: post), + _BlogPostMediaContent( + galleryUrls: galleryUrls, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, ), - ); - } - return const SizedBox.shrink(); - }, + const SizedBox(height: 12), + _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), + const SizedBox(height: 8), + ], + ), + ), ); } } + +class _BlogPostHeader extends StatelessWidget { + const _BlogPostHeader({required this.post}); + + final FeedBlogPostModel post; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), + child: CircleAvatar( + radius: 20, + backgroundColor: Colors.black12, + foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null, + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.author.authorName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), + ), + ], + ), + ), + if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SubscribeButtonWidget(slug: post.author.authorSlug), + ), + CustomPopup( + isAuthor: post.author.authorIsMe, + complainedContentType: ComplainedContent.posts, + path: 'blog_post/${post.id}', + id: post.id, + feedBlogPostModel: post, + userSlug: post.author.authorSlug, + ), + ], + ), + ); + } +} + +class _BlogPostTextContent extends StatelessWidget { + const _BlogPostTextContent({required this.post}); + + final FeedBlogPostModel post; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.title.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + post.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), + ), + ), + ], + if (post.title.isEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.description.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)), + ), + if (post.description.length > 200) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Показать ещё', + style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + ], + ); + } +} + +class _BlogPostMediaContent extends StatelessWidget { + const _BlogPostMediaContent({ + required this.galleryUrls, + required this.imageUrl, + required this.videoEmbedUrl, + required this.currentGalleryIndex, + required this.onOpenGallery, + required this.onGalleryChanged, + required this.videoUrlThumb, + }); + + final List galleryUrls; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + Widget build(BuildContext context) { + final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length; + return Column( + children: [ + if (galleryUrls.length > 1) ...[ + Stack( + children: [ + CarouselSlider.builder( + itemCount: galleryUrls.length + 1, + itemBuilder: (context, itemIndex, _) { + if (itemIndex == galleryUrls.length) { + if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb); + return const SizedBox.shrink(); + } + final itemUrl = galleryUrls[itemIndex]; + return GestureDetector( + onTap: () => onOpenGallery(galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + }, + options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)), + ), + Positioned( + top: 12, + right: 12, + child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount), + ), + ], + ), + ] else if (galleryUrls.length == 1) ...[ + GestureDetector( + onTap: () => onOpenGallery(galleryUrls, 0), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + height: 260, + imageUrl: galleryUrls.first, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ] else if (imageUrl.isNotEmpty) ...[ + GestureDetector( + onTap: () => onOpenGallery([imageUrl], 0), + child: Hero( + tag: imageUrl, + child: CachedNetworkImage( + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + imageUrl: imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ), + ], + ], + ); + } +} + +class _GalleryPhotoCounter extends StatelessWidget { + const _GalleryPhotoCounter({required this.currentIndex, required this.total}); + + final int currentIndex; + final int total; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const SizedBox(width: 8), + Text( + '${currentIndex + 1} / $total', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + ), + ], + ), + ); + } +} + +class _BlogPostActionsRow extends StatelessWidget { + const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); + + final FeedBlogPostModel post; + final bool isLiked; + final int totalLikes; + final Future Function() onToggleLike; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + FeedIconButton( + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + text: totalLikes.toString(), + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + onTap: onToggleLike, + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + text: post.totalComments.toString(), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams( + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), + ); + }, + ), + const Spacer(), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + const SizedBox(width: 4), + Text( + post.totalViews.toString(), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + ), + ], + ), + ); + } +} + +String _truncateDescription(String description) { + final isLong = description.length > 200; + if (!isLong) return description; + + var cutPoint = 200; + final preview = description.substring(0, cutPoint); + final boldOpen = (preview.split('**').length - 1) ~/ 2; + final boldClose = preview.split('**').length - 1 - boldOpen * 2; + final hasUnclosedBold = boldOpen > boldClose; + + if (hasUnclosedBold) { + final searchEnd = (cutPoint + 100).clamp(0, description.length); + final boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + return description.substring(0, cutPoint); +} diff --git a/lib/features/feed/presentation/widgets/blog_posts.dart b/lib/features/feed/presentation/widgets/blog_posts.dart index b9da2ecc..afae31c7 100644 --- a/lib/features/feed/presentation/widgets/blog_posts.dart +++ b/lib/features/feed/presentation/widgets/blog_posts.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart'; import 'package:flutter/material.dart'; @@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart'; class BlogPosts extends StatelessWidget { const BlogPosts({super.key, required this.handleVisibility}); - final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility; - + final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility; + @override Widget build(BuildContext context) { return RefreshIndicator( - onRefresh: () => context.read().loadFeedBlogPostList(), + onRefresh: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), child: BlocBuilder( builder: (BuildContext context, FeedState state) { if (state is FeedStateLoaded) { return NotificationListener( onNotification: (notification) { - loadNewContent(notification, context.read().loadNextPage); + loadNewContent(notification, () => context.read().loadNextPage(currentUserId: context.read().state.profile?.id)); return false; }, child: ListView.separated( @@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget { if (index == state.feedBlogPostList.length) { return const Loader(); } + final post = state.feedBlogPostList[index]; return VisibilityDetector( + key: ValueKey(post.id), onVisibilityChanged: (info) { - final id = state.feedBlogPostList[index].id; - handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts); + handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts); }, - key: ValueKey(index), - child: BlogPostWidget(index: index), + child: BlogPostWidget(post: post), ); }, separatorBuilder: (context, index) => const SizedBox(height: 8), @@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget { ), ); } else if (state is FeedStateError) { - return DataErrorWidget(onReload: () => context.read().loadFeedBlogPostList()); + return DataErrorWidget( + onReload: () => context.read().loadFeedBlogPostList(currentUserId: context.read().state.profile?.id), + ); } else { return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); } diff --git a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart new file mode 100644 index 00000000..701988a9 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +/// Embed-видео в ленте. +/// +/// 1) Пока пользователь не нажал play — только превью (без WebView). +/// 2) После play — превью + лоадер, WebView грузится скрыто. +/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение. +class FeedVideoPlayer extends StatefulWidget { + const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb}); + + final String embedUrl; + + /// Пока бэкенд не отдаёт превью — null, показывается серая заглушка. + final String? videoUrlThumb; + + @override + State createState() => _FeedVideoPlayerState(); +} + +class _FeedVideoPlayerState extends State { + static const _readyChannel = 'EmbedReady'; + + WebViewController? _controller; + bool _isLoading = false; + bool _isReady = false; + bool _hasError = false; + + @override + void didUpdateWidget(covariant FeedVideoPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) { + _reset(); + } + } + + @override + void dispose() { + _controller = null; + super.dispose(); + } + + void _reset() { + _controller = null; + _isLoading = false; + _isReady = false; + _hasError = false; + } + + /// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки. + Uri _embedUriForPlayback(String url) { + final uri = Uri.parse(url); + final params = Map.from(uri.queryParameters); + params['autoplay'] = '1'; + params['mute'] = '1'; + params.putIfAbsent('playsinline', () => '1'); + return uri.replace(queryParameters: params); + } + + String _embedHtml(String embedUrl) { + final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl); + return ''' + + + + + + + + + + + + +'''; + } + + void _markReady() { + if (!mounted || _isReady || _hasError) return; + setState(() { + _isReady = true; + _isLoading = false; + }); + } + + Future _onPlayPressed() async { + if (_isLoading || _isReady || !mounted) return; + + setState(() { + _isLoading = true; + _hasError = false; + }); + + try { + final embedUri = _embedUriForPlayback(widget.embedUrl); + final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; + + late final PlatformWebViewControllerCreationParams params; + if (WebViewPlatform.instance is WebKitWebViewPlatform) { + // Жест play уже был — разрешаем media без дополнительного тапа. + params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const {}); + } else { + params = const PlatformWebViewControllerCreationParams(); + } + + final controller = WebViewController.fromPlatformCreationParams(params) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.black) + ..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady()) + ..setOnConsoleMessage((message) { + log('FeedVideoPlayer console message: ${message.message}'); + }) + ..setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (error) { + if (!mounted) return; + if (error.isForMainFrame != true) return; + if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; + if (_isReady) return; + + setState(() { + _hasError = true; + _isLoading = false; + _controller = null; + }); + }, + ), + ); + + final platform = controller.platform; + if (platform is AndroidWebViewController) { + // Жест уже был на кнопке play. + await platform.setMediaPlaybackRequiresUserGesture(false); + } + + await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl); + + if (!mounted) return; + setState(() => _controller = controller); + } catch (e, st) { + if (kDebugMode) { + debugPrint('FeedVideoPlayer load failed: $e\n$st'); + } + if (mounted) { + setState(() { + _hasError = true; + _isLoading = false; + _controller = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final player = SizedBox( + height: 220, + width: MediaQuery.sizeOf(context).width, + child: Stack( + fit: StackFit.expand, + children: [ + // WebView создаётся только после play и до ready остаётся скрытым под превью. + if (_controller != null) + Opacity( + opacity: _isReady ? 1 : 0, + child: IgnorePointer( + ignoring: !_isReady, + child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!), + ), + ), + if (!_isReady) + _VideoPreviewPlaceholder( + previewUrl: widget.videoUrlThumb, + isLoading: _isLoading, + hasError: _hasError, + onPlay: _onPlayPressed, + onRetry: _onPlayPressed, + ), + ], + ), + ); + + // Пока превью/лоадер — не даём InkWell карточки перехватывать тап. + if (_isReady) return player; + return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player); + } +} + +class _VideoPreviewPlaceholder extends StatelessWidget { + const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry}); + + final String? previewUrl; + final bool isLoading; + final bool hasError; + final VoidCallback onPlay; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (previewUrl != null && previewUrl!.isNotEmpty) + CachedNetworkImage( + imageUrl: previewUrl!, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), + ) + else + const ColoredBox(color: Color(0xFF2C2C2C)), + if (hasError) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40, color: Colors.white54), + const SizedBox(height: 8), + TextButton( + onPressed: onRetry, + child: const Text('Повторить', style: TextStyle(color: Colors.white70)), + ), + ], + ), + ) + else if (isLoading) + const Center(child: CircularProgressIndicator(color: Colors.white)) + else + Center( + child: GestureDetector( + onTap: onPlay, + child: Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle), + padding: const EdgeInsets.all(12), + child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/feed/repositories/feed_repository.dart b/lib/features/feed/repositories/feed_repository.dart index b5128be8..781e0f3c 100644 --- a/lib/features/feed/repositories/feed_repository.dart +++ b/lib/features/feed/repositories/feed_repository.dart @@ -1,9 +1,9 @@ import 'package:eatvio_app/features/feed/models/comment_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart'; @@ -13,9 +13,12 @@ class FeedRepository { FeedRepository({required this.feedService}); - Future getFeedBlogPosts({String? query, int page = 1}) async { - return (await feedService.getBlogPostList(page: page, query: query)); - + Future<({List posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async { + final response = await feedService.getFeed(search: search, cursor: cursor); + return ( + posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(), + nextCursor: response.meta.nextCursor, + ); } Future getBlogPost({required int id}) async { diff --git a/lib/features/feed/services/feed_service.dart b/lib/features/feed/services/feed_service.dart index c756f8c7..9d88dd8f 100644 --- a/lib/features/feed/services/feed_service.dart +++ b/lib/features/feed/services/feed_service.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:retrofit/retrofit.dart'; @@ -18,6 +19,12 @@ abstract class FeedService { @GET('/api/blogPosts/index') Future getBlogPostList({@Query('page') required int page, @Query('query_search') String? query}); + @GET('/api/feed') + Future getFeed({ + @Query('search') String? search, + @Query('cursor') String? cursor, + }); + @GET('/api/blogPosts/show/{postId}') Future getBlogPost({@Path('postId') required int postId}); diff --git a/pubspec.yaml b/pubspec.yaml index e6badd50..8c44cc5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,8 @@ dependencies: sentry_dio: ^9.10.0 livekit_client: ^2.6.1 sbp: 0.1.0 + webview_flutter_android: ^4.12.0 + webview_flutter_wkwebview: ^3.25.1 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/features/feed/models/post_feed_mapper_test.dart b/test/features/feed/models/post_feed_mapper_test.dart new file mode 100644 index 00000000..2873aa0c --- /dev/null +++ b/test/features/feed/models/post_feed_mapper_test.dart @@ -0,0 +1,56 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; + +void main() { + group('PostFeedMapper', () { + setUpAll(() async { + await sl.reset(); + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + }); + + test('maps fixture item post_id to FeedBlogPostModel.id', () { + final item = FeedFixtures.postFeedItem(); + final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10); + + expect(model.id, 1); + expect(model.author.authorIsMe, isTrue); + expect(model.author.authorSlug, 'anna-ivanova'); + expect(model.images, hasLength(2)); + expect(model.videoUrl, isEmpty); + expect(model.videoEmbedUrl, isNull); + expect(model.title, 'Завтрак с овсянкой'); + expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg'); + expect(model.author.isEntitySubscribedByMe, isFalse); + }); + + test('maps fixture embed video post and likes', () { + final item = FeedFixtures.postFeedItem(index: 1); + final model = PostFeedMapper.fromPostFeedItem(item); + + expect(model.id, 2); + expect(model.videoUrl, isEmpty); + expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(model.wasLikedByMe, isTrue); + expect(model.totalLikes, 42); + expect(model.author.authorIsMe, isFalse); + expect(model.author.isEntitySubscribedByMe, isTrue); + }); + + test('maps all fixture posts', () { + final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11); + + expect(posts, hasLength(3)); + expect(posts[1].author.authorIsMe, isTrue); + expect(posts[1].author.isEntitySubscribedByMe, isTrue); + expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(posts[2].author.isEntitySubscribedByMe, isNull); + }); + }); +} diff --git a/test/features/feed/models/post_feed_pagination_model_test.dart b/test/features/feed/models/post_feed_pagination_model_test.dart new file mode 100644 index 00000000..fb1fb47f --- /dev/null +++ b/test/features/feed/models/post_feed_pagination_model_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; + +void main() { + group('PostFeedPaginationModel fixtures', () { + test('parses first page with gallery, video and next_cursor', () { + final model = FeedFixtures.postFeedPaginationModel(); + + expect(model.data, hasLength(3)); + expect(model.meta.perPage, 20); + expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ'); + expect(model.meta.prevCursor, isNull); + expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ')); + + final withGallery = model.data[0]; + expect(withGallery.postId, 1); + expect(withGallery.images, hasLength(2)); + expect(withGallery.images.first.orientation, 'horizontal'); + expect(withGallery.videoEmbedUrl, isNull); + expect(withGallery.isEntitySubscribedByMe, isFalse); + expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova'); + expect(withGallery.postAuthorDetails?.isConsultant, isTrue); + + final withYoutubeEmbed = model.data[1]; + expect(withYoutubeEmbed.postId, 2); + expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(withYoutubeEmbed.postIsLikedByUser, isTrue); + expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue); + expect(withYoutubeEmbed.images, isEmpty); + + final withRutubeEmbed = model.data[2]; + expect(withRutubeEmbed.postId, 3); + expect(withRutubeEmbed.postImageUrl, isNull); + expect(withRutubeEmbed.images, hasLength(1)); + expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); }); + + test('parses next page without next_cursor', () { + final model = FeedFixtures.postFeedNextPagePaginationModel(); + + expect(model.data, hasLength(1)); + expect(model.data.first.postId, 4); + expect(model.meta.nextCursor, isNull); + expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ'); + expect(model.links.next, isNull); + }); + + test('parses empty feed', () { + final model = FeedFixtures.postFeedEmptyPaginationModel(); + + expect(model.data, isEmpty); + expect(model.meta.nextCursor, isNull); + expect(model.links.next, isNull); + }); + }); +} diff --git a/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart b/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart index 15cfaa79..cb17180e 100644 --- a/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart +++ b/test/features/feed/presentation/bloc/feed_cubit/feed_cubit_test.dart @@ -1,7 +1,5 @@ -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; @@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) void main() { - group('FeedCubit', () { - FeedCubit? feedCubit; - late MockFeedRepository mockFeedRepository; - late FeedBlogPostSettingsModel feedBlogPostsFixture; + group('FeedCubit cursor flow', () { + late FeedCubit cubit; + late MockFeedRepository repository; + + final post = FeedBlogPostModel( + id: 10, + state: 'active', + image: '', + description: 'description', + author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + title: 'title', + totalLikes: 1, + totalViews: 1, + totalComments: 1, + wasLikedByMe: false, + ); setUp(() { - mockFeedRepository = MockFeedRepository(); - - // Создаём тестовые данные - final testPosts = [ - FeedBlogPostModel( - id: 1, - state: 'published', - image: 'https://example.com/image1.jpg', - description: 'Описание поста 1', - author: const Author( - authorSlug: 'author-1', - authorName: 'Автор 1', - authorProfileIcon: 'https://example.com/photo1.jpg', - authorIsMe: false, - isEntitySubscribedByMe: null, - authorPlaceInRating: null, - ), - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - title: 'Тестовый пост 1', - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ), - FeedBlogPostModel( - id: 2, - state: 'published', - image: 'https://example.com/image2.jpg', - description: 'Описание поста 2', - author: const Author( - authorSlug: 'author-2', - authorName: 'Автор 2', - authorProfileIcon: 'https://example.com/photo2.jpg', - authorIsMe: false, - isEntitySubscribedByMe: null, - authorPlaceInRating: null, - ), - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - title: 'Тестовый пост 2', - totalLikes: 20, - totalViews: 200, - totalComments: 10, - wasLikedByMe: true, - ), - ]; - - feedBlogPostsFixture = FeedBlogPostSettingsModel( - feedBlogPostList: testPosts, - meta: const Meta( - currentPage: 1, - from: null, - lastPage: 3, - links: [], - path: null, - perPage: 10, - to: null, - total: 25, - totalMinPrice: null, - totalMaxPrice: null, - ), - ); + repository = MockFeedRepository(); + cubit = FeedCubit(feedRepository: repository); }); tearDown(() async { - if (feedCubit != null && !feedCubit!.isClosed) { - await Future.delayed(const Duration(milliseconds: 100)); - feedCubit!.close(); - feedCubit = null; - } + await cubit.close(); }); - test('начальное состояние - FeedStateLoading', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - expect(feedCubit!.state, const FeedState.loading()); + test('error state on load failure', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom')); + + await cubit.loadFeedBlogPostList(); + + expect(cubit.state, isA()); }); - test('loadFeedBlogPostList - успешная загрузка постов', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); + test('updateFeedBlogPostList updates likes in loaded state', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null)); + await cubit.loadFeedBlogPostList(); - when(mockFeedRepository.getFeedBlogPosts(query: null)) - .thenAnswer((_) async => feedBlogPostsFixture); + cubit.updateFeedBlogPostList(post, true); - await feedCubit!.loadFeedBlogPostList(); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - expect(loadedState.isLoading, false); - expect(feedCubit!.lastPage, 3); - }); - - test('loadFeedBlogPostList - загрузка с запросом', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'тест'; - - when(mockFeedRepository.getFeedBlogPosts(query: query)) - .thenAnswer((_) async => feedBlogPostsFixture); - - await feedCubit!.loadFeedBlogPostList(query: query); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1); - }); - - test('loadFeedBlogPostList - обработка ошибки', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query'))) - .thenThrow(Exception('Ошибка загрузки')); - - await feedCubit!.loadFeedBlogPostList(); - - await Future.delayed(const Duration(milliseconds: 100)); - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception')); - }); - - test('updateFeedBlogPostList - обновление лайка поста', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - // Сначала загружаем посты - when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query'))) - .thenAnswer((_) async => feedBlogPostsFixture); - - // Устанавливаем состояние loaded вручную для теста - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first; - feedCubit!.updateFeedBlogPostList(postToUpdate, true); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id); - expect(updatedPost.wasLikedByMe, true); - expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1); - }); - - test('updateFeedBlogPostList - снятие лайка', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true - final initialLikes = postToUpdate.totalLikes; - - feedCubit!.updateFeedBlogPostList(postToUpdate, false); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id); - expect(updatedPost.wasLikedByMe, false); - expect(updatedPost.totalLikes, initialLikes - 1); - }); - - test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - // Состояние loading - feedCubit!.emit(const FeedState.loading()); - - final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first; - feedCubit!.updateFeedBlogPostList(postToUpdate, true); - - // Состояние не должно измениться - expect(feedCubit!.state, const FeedState.loading()); - }); - - test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - - feedCubit!.emit(FeedState.loaded( - feedBlogPostList: feedBlogPostsFixture.feedBlogPostList, - isLoading: false, - )); - feedCubit!.lastPage = 3; - - // Создаём пост с несуществующим ID - final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999); - feedCubit!.updateFeedBlogPostList(nonExistentPost, true); - - // Список не должен измениться - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList.first.wasLikedByMe, true); + expect(loaded.feedBlogPostList.first.totalLikes, 2); }); }); } - diff --git a/test/features/feed/presentation/bloc/feed_cubit_test.dart b/test/features/feed/presentation/bloc/feed_cubit_test.dart index 7213c5e9..e8d1de87 100644 --- a/test/features/feed/presentation/bloc/feed_cubit_test.dart +++ b/test/features/feed/presentation/bloc/feed_cubit_test.dart @@ -1,404 +1,66 @@ -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; + import 'feed_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) void main() { group('FeedCubit', () { - FeedCubit? feedCubit; - late MockFeedRepository mockFeedRepository; + late MockFeedRepository repository; + late FeedCubit cubit; - final testBlogPost = FeedBlogPostModel( + final post = FeedBlogPostModel( id: 1, - title: 'Test Post', - description: 'Test description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: 'https://example.com/video.mp4', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, + state: 'active', + image: 'https://example.com/image.png', + description: 'text', + author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + title: 'Title', + totalLikes: 1, + totalViews: 2, + totalComments: 3, wasLikedByMe: false, ); - final testBlogPost2 = FeedBlogPostModel( - id: 2, - title: 'Test Post 2', - description: 'Test description 2', - image: 'https://example.com/image2.jpg', - state: 'published', - author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false), - videoUrl: 'https://example.com/video2.mp4', - createdAt: DateTime(2024, 1, 16), - updatedAt: DateTime(2024, 1, 16), - totalLikes: 20, - totalViews: 200, - totalComments: 10, - wasLikedByMe: true, - ); - setUp(() { - mockFeedRepository = MockFeedRepository(); + repository = MockFeedRepository(); + cubit = FeedCubit(feedRepository: repository); }); tearDown(() async { - if (feedCubit != null && !feedCubit!.isClosed) { - await Future.delayed(const Duration(milliseconds: 100)); - feedCubit!.close(); - feedCubit = null; - } + await cubit.close(); }); - test('начальное состояние - loading', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - expect(feedCubit!.state, const FeedState.loading()); + test('loadFeedBlogPostList emits loaded from getFeed', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next')); + + await cubit.loadFeedBlogPostList(); + + expect(cubit.state, isA()); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList, hasLength(1)); + verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1); }); - test('loadFeedBlogPostList - успешно загружает список постов', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); + test('loadNextPage appends posts', () async { + when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next')); + when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null)); - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings); + await cubit.loadFeedBlogPostList(); + await cubit.loadNextPage(); - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 1); - expect(loadedState.feedBlogPostList.first.id, 1); - expect(loadedState.isLoading, false); - expect(feedCubit!.lastPage, 3); - verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1); - }); - - test('loadFeedBlogPostList - загружает список постов с query', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'test query'; - final expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost, testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings); - - await feedCubit!.loadFeedBlogPostList(query: query); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1); - }); - - test('loadFeedBlogPostList - обрабатывает ошибку', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final exception = Exception('Network error'); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception: Network error')); - }); - - test('loadNextPage - загружает следующую страницу', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - final secondPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.length, 2); - expect(feedCubit!.currentPage, 2); - expect(loadedState.isLoading, false); - verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1); - }); - - test('loadNextPage - не загружает если уже на последней странице', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query'))); - }); - - test('loadNextPage - не загружает если уже загружается', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.isLoading = true; - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query'))); - }); - - test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final exception = Exception('Network error'); - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final errorState = feedCubit!.state as FeedStateError; - expect(errorState.errorMessage, contains('Exception: Network error')); - }); - - test('loadNextPage - сохраняет query при загрузке следующей страницы', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const query = 'test query'; - final firstPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - final secondPageSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings); - - await feedCubit!.loadFeedBlogPostList(query: query); - await Future.delayed(const Duration(milliseconds: 100)); - - when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings); - - await feedCubit!.loadNextPage(); - await Future.delayed(const Duration(milliseconds: 100)); - - verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1); - }); - - test('updateFeedBlogPostList - обновляет лайк поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.updateFeedBlogPostList(testBlogPost, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.first.wasLikedByMe, true); - expect(loadedState.feedBlogPostList.first.totalLikes, 11); - }); - - test('updateFeedBlogPostList - убирает лайк поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - feedCubit!.updateFeedBlogPostList(testBlogPost2, false); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, isA()); - final loadedState = feedCubit!.state as FeedStateLoaded; - expect(loadedState.feedBlogPostList.first.wasLikedByMe, false); - expect(loadedState.feedBlogPostList.first.totalLikes, 19); - }); - - test('updateFeedBlogPostList - не обновляет если пост не найден', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final nonExistentPost = testBlogPost.copyWith(id: 999); - final initialState = feedCubit!.state; - feedCubit!.updateFeedBlogPostList(nonExistentPost, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, initialState); - }); - - test('updateFeedBlogPostList - не обновляет если state не loaded', () { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - feedCubit!.updateFeedBlogPostList(testBlogPost, true); - expect(feedCubit!.state, const FeedState.loading()); - }); - - test('updateFeedBlogPostList - не обновляет если post null', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - final settings = FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost], - meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); - - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings); - - await feedCubit!.loadFeedBlogPostList(); - await Future.delayed(const Duration(milliseconds: 100)); - - final initialState = feedCubit!.state; - feedCubit!.updateFeedBlogPostList(null, true); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(feedCubit!.state, initialState); - }); - - test('likePost - лайкает пост', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.likePost(postId); - - verify(mockFeedRepository.likeBlogPost(id: postId)).called(1); - }); - - test('likePost - обрабатывает ошибку без выбрасывания', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - final exception = Exception('Network error'); - - when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception); - - await feedCubit!.likePost(postId); - - verify(mockFeedRepository.likeBlogPost(id: postId)).called(1); - }); - - test('unlikedPost - убирает лайк с поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.unlikedPost(postId); - - verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1); - }); - - test('viewPost - отправляет просмотр поста', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const postId = 1; - - when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewPost(postId); - - verify(mockFeedRepository.viewPost(postId)).called(1); - }); - - test('viewRecipe - отправляет просмотр рецепта', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const slug = 'recipe-1'; - - when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewRecipe(slug); - - verify(mockFeedRepository.viewRecipe(slug)).called(1); - }); - - test('viewDiet - отправляет просмотр диеты', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const dietId = 2; - - when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewDiet(dietId); - - verify(mockFeedRepository.viewDiet(dietId)).called(1); - }); - - test('viewPlan - отправляет просмотр плана', () async { - feedCubit = FeedCubit(feedRepository: mockFeedRepository); - const slug = 'plan-1'; - - when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value()); - - await feedCubit!.viewPlan(slug); - - verify(mockFeedRepository.viewPlan(slug)).called(1); + final loaded = cubit.state as FeedStateLoaded; + expect(loaded.feedBlogPostList, hasLength(2)); + verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1); }); }); } diff --git a/test/features/feed/presentation/widgets/basic_widget_test.dart b/test/features/feed/presentation/widgets/basic_widget_test.dart index 16e18b6e..b9d7a80b 100644 --- a/test/features/feed/presentation/widgets/basic_widget_test.dart +++ b/test/features/feed/presentation/widgets/basic_widget_test.dart @@ -1,12 +1,10 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/blog_posts.dart'; @@ -378,12 +376,8 @@ void main() { // Arrange feedCubit.emit(FeedState.loaded(feedBlogPostList: [testBlogPost], isLoading: false)); - when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer( - (_) async => FeedBlogPostSettingsModel( - feedBlogPostList: [testBlogPost, testBlogPost2], - meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ), - ); + when(mockFeedRepository.getFeed(search: null, cursor: null, currentUserId: null)) + .thenAnswer((_) async => (posts: [testBlogPost, testBlogPost2], nextCursor: null)); final widget = BlogPosts(handleVisibility: ({required visibleFraction, required id, required type}) {}); diff --git a/test/features/feed/presentation/widgets/blog_post_widget_test.dart b/test/features/feed/presentation/widgets/blog_post_widget_test.dart index 3571cf2b..ffc2b581 100644 --- a/test/features/feed/presentation/widgets/blog_post_widget_test.dart +++ b/test/features/feed/presentation/widgets/blog_post_widget_test.dart @@ -81,7 +81,7 @@ void main() { when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const BlogPostWidget(index: 0)))); + await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(BlogPostWidget(post: mockBlogPost)))); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/feed/repositories/feed_repository_test.dart b/test/features/feed/repositories/feed_repository_test.dart index d2a56a55..9165ba3a 100644 --- a/test/features/feed/repositories/feed_repository_test.dart +++ b/test/features/feed/repositories/feed_repository_test.dart @@ -1,8 +1,10 @@ import 'package:eatvio_app/core/models/pagination_model.dart'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/feed/models/comment_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; @@ -11,9 +13,11 @@ import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../fixtures/feed_fixtures.dart'; import '../../../fixtures/fixture_reader.dart'; import 'feed_repository_test.mocks.dart'; @@ -23,72 +27,57 @@ void main() { late FeedRepository feedRepository; late MockFeedService mockFeedService; - setUp(() { + setUp(() async { + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + await sl.reset(); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); mockFeedService = MockFeedService(); feedRepository = FeedRepository(feedService: mockFeedService); }); - test('getFeedBlogPosts - успешное получение постов', () async { - const expectedModel = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], path: null, perPage: 10, to: null, total: 0, totalMinPrice: null, totalMaxPrice: null), - ); + test('getFeed - успешное получение постов', () async { + final expectedModel = FeedFixtures.postFeedPaginationModel(); - when(mockFeedService.getBlogPostList(page: 1, query: null)).thenAnswer((_) async => expectedModel); + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(); + final result = await feedRepository.getFeed(); - expect(result, isA()); - verify(mockFeedService.getBlogPostList(page: 1, query: null)).called(1); + expect(result.posts, hasLength(3)); + expect(result.posts.first.id, 1); + expect(result.posts[1].videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ'); + expect(result.nextCursor, 'eyJpZCI6MTAzfQ'); + verify(mockFeedService.getFeed(search: null, cursor: null)).called(1); }); - test('getFeedBlogPosts - с запросом и страницей', () async { + test('getFeed - с запросом и курсором', () async { const query = 'тест'; - const page = 2; - const expectedModel = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta( - currentPage: page, - from: null, - lastPage: 2, - links: [], - path: null, - perPage: 10, - to: null, - total: 15, - totalMinPrice: null, - totalMaxPrice: null, - ), - ); + const cursor = 'eyJpZCI6MTAzfQ'; + final expectedModel = FeedFixtures.postFeedNextPagePaginationModel(); - when(mockFeedService.getBlogPostList(page: page, query: query)).thenAnswer((_) async => expectedModel); + when(mockFeedService.getFeed(search: query, cursor: cursor)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(query: query, page: page); + final result = await feedRepository.getFeed(search: query, cursor: cursor); - expect(result, isA()); - verify(mockFeedService.getBlogPostList(page: page, query: query)).called(1); + expect(result.posts, hasLength(1)); + expect(result.posts.first.id, 4); + expect(result.nextCursor, isNull); + verify(mockFeedService.getFeed(search: query, cursor: cursor)).called(1); }); - test('getFeedBlogPosts - возвращает список постов без query', () async { - const page = 1; - const expectedSettings = FeedBlogPostSettingsModel( - feedBlogPostList: [], - meta: Meta(currentPage: 1, lastPage: 1, total: 0, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null), - ); + test('getFeed - пустая лента', () async { + final expectedModel = FeedFixtures.postFeedEmptyPaginationModel(); - when(mockFeedService.getBlogPostList(page: page, query: null)).thenAnswer((_) async => expectedSettings); + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel); - final result = await feedRepository.getFeedBlogPosts(page: page); + final result = await feedRepository.getFeed(); - expect(result, isA()); - expect(result.feedBlogPostList.isEmpty, true); - verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1); + expect(result.posts, isEmpty); + expect(result.nextCursor, isNull); }); test('getBlogPost - успешное получение поста', () async { const postId = 1; - final json = FixtureReader.readAsMap('feed/feed_blog_post_response.json'); - final expectedPost = FeedBlogPostModel.fromJson(json); + final expectedPost = FeedFixtures.feedBlogPostModel(); when(mockFeedService.getBlogPost(postId: postId)).thenAnswer((_) async => expectedPost); @@ -529,10 +518,10 @@ void main() { verify(mockFeedService.viewContent(viewModel: viewModel)).called(1); }); - test('getFeedBlogPosts - пробрасывает исключение', () async { - when(mockFeedService.getBlogPostList(page: 1, query: null)).thenThrow(Exception('Ошибка сети')); + test('getFeed - пробрасывает исключение', () async { + when(mockFeedService.getFeed(search: null, cursor: null)).thenThrow(Exception('Ошибка сети')); - expect(() => feedRepository.getFeedBlogPosts(), throwsException); + expect(() => feedRepository.getFeed(), throwsException); }); }); } diff --git a/test/features/feed/services/feed_service_test.dart b/test/features/feed/services/feed_service_test.dart index e8832ec5..1c1ec3c1 100644 --- a/test/features/feed/services/feed_service_test.dart +++ b/test/features/feed/services/feed_service_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../fixtures/feed_fixtures.dart'; import 'feed_service_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -55,6 +56,32 @@ void main() { verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1); }); + test('getFeed returns cursor response', () async { + final expected = FeedFixtures.postFeedPaginationModel(); + + when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expected); + + final result = await mockFeedService.getFeed(search: null, cursor: null); + + expect(result.data, hasLength(3)); + expect(result.meta.perPage, 20); + expect(result.meta.nextCursor, 'eyJpZCI6MTAzfQ'); + verify(mockFeedService.getFeed(search: null, cursor: null)).called(1); + }); + + test('getFeed returns next page without cursor', () async { + const cursor = 'eyJpZCI6MTAzfQ'; + final expected = FeedFixtures.postFeedNextPagePaginationModel(); + + when(mockFeedService.getFeed(search: null, cursor: cursor)).thenAnswer((_) async => expected); + + final result = await mockFeedService.getFeed(search: null, cursor: cursor); + + expect(result.data, hasLength(1)); + expect(result.meta.nextCursor, isNull); + verify(mockFeedService.getFeed(search: null, cursor: cursor)).called(1); + }); + test('getBlogPost returns post', () async { const id = 10; final expected = FeedBlogPostModel( diff --git a/test/fixtures/feed/post_feed_empty_response.json b/test/fixtures/feed/post_feed_empty_response.json new file mode 100644 index 00000000..872f387b --- /dev/null +++ b/test/fixtures/feed/post_feed_empty_response.json @@ -0,0 +1,15 @@ +{ + "data": [], + "links": { + "first": null, + "last": null, + "prev": null, + "next": null + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/test/fixtures/feed/post_feed_next_page_response.json b/test/fixtures/feed/post_feed_next_page_response.json new file mode 100644 index 00000000..daf2b0d5 --- /dev/null +++ b/test/fixtures/feed/post_feed_next_page_response.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "id": 104, + "post_id": 4, + "post_author_id": 13, + "post_author_name": "Олег Кузнецов", + "post_author_image_url": "/storage/avatars/oleg.png", + "post_author_profile_url": "/ru/profiles/oleg-kuznetsov", + "post_created_at": "2024-06-04T11:00:00.000000Z", + "post_title": "Ужин за 20 минут", + "post_short_description": "Быстрый ужин после работы", + "post_text_md": "Курица с овощами на сковороде.", + "post_image_url": "/storage/posts/dinner.jpg", + "images": [], + "video_embed_url": null, + "post_views_count": 88, + "post_likes_count": 7, + "post_comments_count": 2, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": false, + "post_url": "/ru/blog_post/4", + "video_url_thumb": null, + "post_state": "active", + "post_author_detailz_json": { + "slug": "oleg-kuznetsov", + "is_consultant": false + }, + "created_at": "2024-06-04T11:05:00.000000Z", + "updated_at": "2024-06-04T11:05:00.000000Z" + } + ], + "links": { + "first": null, + "last": null, + "prev": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ", + "next": null + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": null, + "prev_cursor": "eyJpZCI6MTAzfQ" + } +} diff --git a/test/fixtures/feed/post_feed_response.json b/test/fixtures/feed/post_feed_response.json new file mode 100644 index 00000000..95676951 --- /dev/null +++ b/test/fixtures/feed/post_feed_response.json @@ -0,0 +1,115 @@ +{ + "data": [ + { + "id": 101, + "post_id": 1, + "post_author_id": 10, + "post_author_name": "Анна Иванова", + "post_author_image_url": "/storage/avatars/anna.png", + "post_author_profile_url": "/ru/profiles/anna-ivanova", + "post_created_at": "2024-06-01T10:00:00.000000Z", + "post_title": "Завтрак с овсянкой", + "post_short_description": "Простой и полезный завтрак", + "post_text_md": "Сегодня приготовила **овсянку** с ягодами.", + "post_image_url": "/storage/posts/oatmeal.jpg", + "images": [ + { + "url": "/storage/posts/oatmeal-1.jpg", + "orientation": "horizontal" + }, + { + "url": "/storage/posts/oatmeal-2.jpg", + "orientation": "vertical" + } + ], + "video_embed_url": "https://rutube.ru/play/embed/08c00eb3a54c056aeca34f58d8fd1db8/", + "post_views_count": 120, + "post_likes_count": 15, + "post_comments_count": 4, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": false, + "post_url": "/ru/blog_post/1", + "post_state": "active", + "video_url_thumb": "https://mike-dev-ach5iekohk4n.eatv.io/storage/3409/20260720-96266cae-5852-40c9-9692-0a4047e8c934.png", + "post_author_detailz_json": { + "slug": "anna-ivanova", + "is_consultant": true + }, + "created_at": "2024-06-01T10:05:00.000000Z", + "updated_at": "2024-06-01T10:05:00.000000Z" + }, + { + "id": 102, + "post_id": 2, + "post_author_id": 11, + "post_author_name": "Игорь Петров", + "post_author_image_url": "/storage/avatars/igor.png", + "post_author_profile_url": "/ru/profiles/igor-petrov", + "post_created_at": "2024-06-02T08:30:00.000000Z", + "post_title": "Тренировка дома", + "post_short_description": "Короткая тренировка без оборудования", + "post_text_md": "15 минут кардио и растяжка.", + "post_image_url": "/storage/posts/workout.jpg", + "images": [], + "video_embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "post_views_count": 340, + "post_likes_count": 42, + "post_comments_count": 9, + "post_is_liked_by_user": true, + "is_entity_subscribed_by_me": true, + "post_url": "/ru/blog_post/2", + "post_state": "active", + "post_author_detailz_json": { + "slug": "igor-petrov", + "is_consultant": false + }, + "created_at": "2024-06-02T08:35:00.000000Z", + "updated_at": "2024-06-02T09:00:00.000000Z" + }, + { + "id": 103, + "post_id": 3, + "post_author_id": 12, + "post_author_name": "Мария Смирнова", + "post_author_image_url": null, + "post_author_profile_url": "/ru/profiles/maria-smirnova", + "post_created_at": "2024-06-03T14:15:00.000000Z", + "post_title": "Рецепт смузи", + "post_short_description": null, + "post_text_md": null, + "post_image_url": null, + "images": [ + { + "url": "/storage/posts/smoothie.jpg", + "orientation": "horizontal" + } + ], + "video_embed_url": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "post_views_count": 50, + "post_likes_count": 3, + "post_comments_count": 1, + "post_is_liked_by_user": false, + "is_entity_subscribed_by_me": null, + "post_url": "/ru/blog_post/3", + "post_state": "active", + "post_author_detailz_json": { + "slug": "maria-smirnova", + "is_consultant": false + }, + "created_at": "2024-06-03T14:20:00.000000Z", + "updated_at": "2024-06-03T14:20:00.000000Z" + } + ], + "links": { + "first": null, + "last": null, + "prev": null, + "next": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ" + }, + "meta": { + "path": "/api/feed", + "per_page": 20, + "next_cursor": "eyJpZCI6MTAzfQ", + "prev_cursor": null + } +} diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart new file mode 100644 index 00000000..3084a69b --- /dev/null +++ b/test/fixtures/feed_fixtures.dart @@ -0,0 +1,51 @@ +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; + +import 'fixture_reader.dart'; + +class FeedFixtures { + FeedFixtures._(); + + static Map postFeedResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_response.json'); + } + + static Map postFeedNextPageResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_next_page_response.json'); + } + + static Map postFeedEmptyResponseJson() { + return FixtureReader.readAsMap('feed/post_feed_empty_response.json'); + } + + static Map feedBlogPostResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); + } + + static PostFeedPaginationModel postFeedPaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedResponseJson()); + } + + static PostFeedPaginationModel postFeedNextPagePaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedNextPageResponseJson()); + } + + static PostFeedPaginationModel postFeedEmptyPaginationModel() { + return PostFeedPaginationModel.fromJson(postFeedEmptyResponseJson()); + } + + static PostFeedItemModel postFeedItem({int index = 0}) { + return postFeedPaginationModel().data[index]; + } + + static List mappedFeedPosts({int? currentUserId}) { + return postFeedPaginationModel().data + .map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)) + .toList(); + } + + static FeedBlogPostModel feedBlogPostModel() { + return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); + } +} From 9c5c6df751d39919b242adadba569cf3a01d2a29 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Mon, 20 Jul 2026 18:15:25 +0300 Subject: [PATCH 02/12] refactor(video,webview): webview lib replaced on feed --- .kilocode/rules/memory-bank/context.md | 15 +- .../widgets/feed_video_player_widget.dart | 172 +++++++++++------- pubspec.yaml | 25 ++- 3 files changed, 130 insertions(+), 82 deletions(-) diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md index 0bde3ccf..ff45966e 100644 --- a/.kilocode/rules/memory-bank/context.md +++ b/.kilocode/rules/memory-bank/context.md @@ -1,7 +1,7 @@ # Current Context ## Project Status -Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production. +Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production. ## Recent Activity - Memory Bank initialization completed @@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1. - Architecture and technology stack catalogued ## Current Focus -Ready for development tasks. No active work in progress. +Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`) +мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс +fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует +Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview` +делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play → +скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с +для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen. +Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`. ## Known Issues -None documented at initialization. +- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`, + `post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие, + связаны с fixtures/маппингом, не с видеоплеером. ## Next Steps Awaiting user direction for: diff --git a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart index 701988a9..29e85300 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -5,15 +5,18 @@ import 'dart:developer'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:webview_flutter_android/webview_flutter_android.dart'; -import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; /// Embed-видео в ленте. /// /// 1) Пока пользователь не нажал play — только превью (без WebView). /// 2) После play — превью + лоадер, WebView грузится скрыто. /// 3) Когда embed готов — показываем WebView и запускаем воспроизведение. +/// +/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4 +/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/YouTube): +/// Android `WebChromeClient.onShowCustomView/onHideCustomView`. class FeedVideoPlayer extends StatefulWidget { const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb}); @@ -28,12 +31,17 @@ class FeedVideoPlayer extends StatefulWidget { class _FeedVideoPlayerState extends State { static const _readyChannel = 'EmbedReady'; + static const Duration _readyFallbackDelay = Duration(seconds: 4); - WebViewController? _controller; + /// Пока null — WebView не рендерится. Заполняется по жесту play. + InAppWebViewInitialData? _initialData; bool _isLoading = false; bool _isReady = false; bool _hasError = false; + /// Страховка готовности для провайдеров без Rutube-протокола `player:canPlay`. + Timer? _readyFallback; + @override void didUpdateWidget(covariant FeedVideoPlayer oldWidget) { super.didUpdateWidget(oldWidget); @@ -44,12 +52,14 @@ class _FeedVideoPlayerState extends State { @override void dispose() { - _controller = null; + _readyFallback?.cancel(); super.dispose(); } void _reset() { - _controller = null; + _readyFallback?.cancel(); + _readyFallback = null; + _initialData = null; _isLoading = false; _isReady = false; _hasError = false; @@ -82,23 +92,24 @@ class _FeedVideoPlayerState extends State { @@ -108,12 +119,25 @@ class _FeedVideoPlayerState extends State { void _markReady() { if (!mounted || _isReady || _hasError) return; + _readyFallback?.cancel(); + _readyFallback = null; setState(() { _isReady = true; _isLoading = false; }); } + void _markError() { + if (!mounted || _isReady) return; + _readyFallback?.cancel(); + _readyFallback = null; + setState(() { + _hasError = true; + _isLoading = false; + _initialData = null; + }); + } + Future _onPlayPressed() async { if (_isLoading || _isReady || !mounted) return; @@ -126,62 +150,68 @@ class _FeedVideoPlayerState extends State { final embedUri = _embedUriForPlayback(widget.embedUrl); final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; - late final PlatformWebViewControllerCreationParams params; - if (WebViewPlatform.instance is WebKitWebViewPlatform) { - // Жест play уже был — разрешаем media без дополнительного тапа. - params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const {}); - } else { - params = const PlatformWebViewControllerCreationParams(); - } - - final controller = WebViewController.fromPlatformCreationParams(params) - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(Colors.black) - ..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady()) - ..setOnConsoleMessage((message) { - log('FeedVideoPlayer console message: ${message.message}'); - }) - ..setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (error) { - if (!mounted) return; - if (error.isForMainFrame != true) return; - if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; - if (_isReady) return; - - setState(() { - _hasError = true; - _isLoading = false; - _controller = null; - }); - }, - ), - ); - - final platform = controller.platform; - if (platform is AndroidWebViewController) { - // Жест уже был на кнопке play. - await platform.setMediaPlaybackRequiresUserGesture(false); - } - - await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl); - if (!mounted) return; - setState(() => _controller = controller); + setState(() { + _initialData = InAppWebViewInitialData( + data: _embedHtml(embedUri.toString()), + mimeType: 'text/html', + encoding: 'utf8', + baseUrl: WebUri(baseUrl), + ); + }); } catch (e, st) { if (kDebugMode) { debugPrint('FeedVideoPlayer load failed: $e\n$st'); } - if (mounted) { - setState(() { - _hasError = true; - _isLoading = false; - _controller = null; - }); - } + _markError(); } } + InAppWebViewSettings get _settings => InAppWebViewSettings( + // Жест play уже был на кнопке — разрешаем старта без доп. тапа. + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen', + iframeAllowFullscreen: true, + transparentBackground: false, + ); + + void _onWebViewCreated(InAppWebViewController controller) { + controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady()); + } + + void _onLoadStop(InAppWebViewController _, WebUri? _) { + // Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube), + // покажем WebView по таймауту после полной загрузки. + if (_isReady || _hasError || !mounted) return; + _readyFallback?.cancel(); + _readyFallback = Timer(_readyFallbackDelay, () { + if (mounted && !_isReady && !_hasError) _markReady(); + }); + } + + void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) { + if (!mounted) return; + if (request.isForMainFrame != true) return; + // ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера. + if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; + if (_isReady) return; + if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}'); + _markError(); + } + + void _onEnterFullscreen(InAppWebViewController _) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + void _onExitFullscreen(InAppWebViewController _) { + // Возвращаем системный набор ориентаций (не блокируем глобально в portrait). + SystemChrome.setPreferredOrientations([]); + } + @override Widget build(BuildContext context) { final player = SizedBox( @@ -191,12 +221,22 @@ class _FeedVideoPlayerState extends State { fit: StackFit.expand, children: [ // WebView создаётся только после play и до ready остаётся скрытым под превью. - if (_controller != null) + if (_initialData != null) Opacity( opacity: _isReady ? 1 : 0, child: IgnorePointer( ignoring: !_isReady, - child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!), + child: InAppWebView( + key: ValueKey('feed-inappwebview-${widget.embedUrl}'), + initialData: _initialData, + initialSettings: _settings, + onWebViewCreated: _onWebViewCreated, + onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'), + onLoadStop: _onLoadStop, + onReceivedError: _onReceivedError, + onEnterFullscreen: _onEnterFullscreen, + onExitFullscreen: _onExitFullscreen, + ), ), ), if (!_isReady) diff --git a/pubspec.yaml b/pubspec.yaml index 8c44cc5a..aed9b3e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eatvio_app description: A new Flutter project. version: 1.3.24+106 -publish_to: 'none' +publish_to: "none" environment: sdk: ^3.10.4 @@ -82,12 +82,11 @@ dependencies: rxdart: ^0.27.7 gpassword: ^1.0.0 webview_flutter_platform_interface: ^2.14.0 + flutter_inappwebview: ^6.1.5 intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 sbp: 0.1.0 - webview_flutter_android: ^4.12.0 - webview_flutter_wkwebview: ^3.25.1 dev_dependencies: build_runner: ^2.10.4 @@ -167,14 +166,14 @@ flutter_intl: flutter_native_splash: android: true ios: true - color: '#FFFFFF' - image_android: 'assets/splash/splash.png' - image_dark_android: 'assets/splash/splash.png' - image_ios: 'assets/splash/splash.png' - image_dark_ios: 'assets/splash/splash.png' - branding_android: 'assets/splash/branding.png' - branding_dark_android: 'assets/splash/branding.png' + color: "#FFFFFF" + image_android: "assets/splash/splash.png" + image_dark_android: "assets/splash/splash.png" + image_ios: "assets/splash/splash.png" + image_dark_ios: "assets/splash/splash.png" + branding_android: "assets/splash/branding.png" + branding_dark_android: "assets/splash/branding.png" android_12: - image: 'assets/splash/splash.png' - image_dark: 'assets/splash/splash.png' - branding: 'assets/splash/branding.png' + image: "assets/splash/splash.png" + image_dark: "assets/splash/splash.png" + branding: "assets/splash/branding.png" From f1abea70c5264835e41670305dd3a6c123e458cc Mon Sep 17 00:00:00 2001 From: HasanKulbuzhev Date: Tue, 21 Jul 2026 14:22:45 +0300 Subject: [PATCH 03/12] fix fixtures --- test/fixtures/feed/post_feed_next_page_response.json | 2 +- test/fixtures/feed/post_feed_response.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/fixtures/feed/post_feed_next_page_response.json b/test/fixtures/feed/post_feed_next_page_response.json index daf2b0d5..56603e76 100644 --- a/test/fixtures/feed/post_feed_next_page_response.json +++ b/test/fixtures/feed/post_feed_next_page_response.json @@ -22,7 +22,7 @@ "post_url": "/ru/blog_post/4", "video_url_thumb": null, "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "oleg-kuznetsov", "is_consultant": false }, diff --git a/test/fixtures/feed/post_feed_response.json b/test/fixtures/feed/post_feed_response.json index 95676951..77a103d4 100644 --- a/test/fixtures/feed/post_feed_response.json +++ b/test/fixtures/feed/post_feed_response.json @@ -22,7 +22,7 @@ "orientation": "vertical" } ], - "video_embed_url": "https://rutube.ru/play/embed/08c00eb3a54c056aeca34f58d8fd1db8/", + "video_embed_url": null, "post_views_count": 120, "post_likes_count": 15, "post_comments_count": 4, @@ -30,8 +30,8 @@ "is_entity_subscribed_by_me": false, "post_url": "/ru/blog_post/1", "post_state": "active", - "video_url_thumb": "https://mike-dev-ach5iekohk4n.eatv.io/storage/3409/20260720-96266cae-5852-40c9-9692-0a4047e8c934.png", - "post_author_detailz_json": { + "video_url_thumb": null, + "post_author_detailz": { "slug": "anna-ivanova", "is_consultant": true }, @@ -59,7 +59,7 @@ "is_entity_subscribed_by_me": true, "post_url": "/ru/blog_post/2", "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "igor-petrov", "is_consultant": false }, @@ -92,7 +92,7 @@ "is_entity_subscribed_by_me": null, "post_url": "/ru/blog_post/3", "post_state": "active", - "post_author_detailz_json": { + "post_author_detailz": { "slug": "maria-smirnova", "is_consultant": false }, From 2dbd4a455f51d407ac4adeb215744c6e9aad100c Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 21 Jul 2026 15:15:28 +0300 Subject: [PATCH 04/12] feat(personalized_feed): added vk player state handling, separeted player logic --- .../widgets/blog_post_widget.dart | 113 +++++-- .../feed_video_player/feed_video_embed.dart | 70 ++++ .../feed_video_embed_scripts.dart | 70 ++++ .../feed_video_player_widget.dart | 213 ++++++++++++ .../feed_video_preview_placeholder.dart | 63 ++++ .../widgets/feed_video_player_widget.dart | 313 +----------------- 6 files changed, 499 insertions(+), 343 deletions(-) create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart create mode 100644 lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 7ad825a5..8751798b 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; @@ -13,7 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -218,7 +220,7 @@ class _BlogPostTextContent extends StatelessWidget { } } -class _BlogPostMediaContent extends StatelessWidget { +class _BlogPostMediaContent extends StatefulWidget { const _BlogPostMediaContent({ required this.galleryUrls, required this.imageUrl, @@ -237,61 +239,110 @@ class _BlogPostMediaContent extends StatelessWidget { final void Function(List urls, int index) onOpenGallery; final ValueChanged onGalleryChanged; + @override + State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { + static const _counterDisplayDuration = Duration(seconds: 2); + + bool _isCounterVisible = false; + Timer? _counterHideTimer; + + @override + void dispose() { + _counterHideTimer?.cancel(); + super.dispose(); + } + + bool _handleCarouselScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + _counterHideTimer?.cancel(); + if (!_isCounterVisible) { + setState(() => _isCounterVisible = true); + } + } else if (notification is ScrollEndNotification && _isCounterVisible) { + _counterHideTimer?.cancel(); + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted) { + setState(() => _isCounterVisible = false); + } + }); + } + return false; + } + @override Widget build(BuildContext context) { - final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length; + final mediaContentCount = widget.videoEmbedUrl != null ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; return Column( children: [ - if (galleryUrls.length > 1) ...[ + if (widget.galleryUrls.length > 1) ...[ Stack( children: [ - CarouselSlider.builder( - itemCount: galleryUrls.length + 1, - itemBuilder: (context, itemIndex, _) { - if (itemIndex == galleryUrls.length) { - if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb); - return const SizedBox.shrink(); - } - final itemUrl = galleryUrls[itemIndex]; - return GestureDetector( - onTap: () => onOpenGallery(galleryUrls, itemIndex), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - imageUrl: itemUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ); - }, - options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)), + NotificationListener( + onNotification: _handleCarouselScroll, + child: CarouselSlider.builder( + itemCount: widget.galleryUrls.length + 1, + itemBuilder: (context, itemIndex, _) { + if (itemIndex == widget.galleryUrls.length) { + if (widget.videoEmbedUrl != null) { + return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + } + return const SizedBox.shrink(); + } + final itemUrl = widget.galleryUrls[itemIndex]; + return GestureDetector( + onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + }, + options: CarouselOptions( + viewportFraction: 1, + height: 260, + enableInfiniteScroll: false, + onPageChanged: (index, _) => widget.onGalleryChanged(index), + ), + ), ), Positioned( top: 12, right: 12, - child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount), + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _isCounterVisible ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), + ), + ), ), ], ), - ] else if (galleryUrls.length == 1) ...[ + ] else if (widget.galleryUrls.length == 1) ...[ GestureDetector( - onTap: () => onOpenGallery(galleryUrls, 0), + onTap: () => widget.onOpenGallery(widget.galleryUrls, 0), child: CachedNetworkImage( fit: BoxFit.cover, width: MediaQuery.of(context).size.width, height: 260, - imageUrl: galleryUrls.first, + imageUrl: widget.galleryUrls.first, errorWidget: (context, str, object) => const SizedBox.shrink(), ), ), - ] else if (imageUrl.isNotEmpty) ...[ + ] else if (widget.imageUrl.isNotEmpty) ...[ GestureDetector( - onTap: () => onOpenGallery([imageUrl], 0), + onTap: () => widget.onOpenGallery([widget.imageUrl], 0), child: Hero( - tag: imageUrl, + tag: widget.imageUrl, child: CachedNetworkImage( fit: BoxFit.fitWidth, width: MediaQuery.of(context).size.width, - imageUrl: imageUrl, + imageUrl: widget.imageUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), ), diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart new file mode 100644 index 00000000..056d0e37 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart'; + +enum FeedVideoEmbedProvider { vk, other } + +/// Сборка URL и HTML для embed-плеера ленты. +abstract final class FeedVideoEmbed { + static FeedVideoEmbedProvider detectProvider(Uri uri) { + final host = uri.host.toLowerCase(); + final isVk = host == 'vk.com' || + host.endsWith('.vk.com') || + host == 'vk.ru' || + host.endsWith('.vk.ru') || + host == 'vkvideo.ru' || + host.endsWith('.vkvideo.ru'); + return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other; + } + + /// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки. + static Uri uriForPlayback(String url) { + final uri = Uri.parse(url); + final params = Map.from(uri.queryParameters); + params['autoplay'] = '1'; + params['mute'] = '1'; + params.putIfAbsent('playsinline', () => '1'); + + // VK Video JS API: без js_api=1 события/методы плеера недоступны. + if (detectProvider(uri) == FeedVideoEmbedProvider.vk) { + params['js_api'] = '1'; + } + + return uri.replace(queryParameters: params); + } + + static String buildHtml(String embedUrl) { + final uri = Uri.parse(embedUrl); + final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk; + final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl); + final headScripts = isVk ? '' : ''; + final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk); + + return ''' + + + + + + + $headScripts + + + + + + +'''; + } +} diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart new file mode 100644 index 00000000..5d661bde --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart @@ -0,0 +1,70 @@ +/// JS-фрагменты для embed-плеера ленты. +/// +/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`. +abstract final class FeedVideoEmbedScripts { + static const readyChannel = 'EmbedReady'; + + /// Уведомляет Flutter, что embed готов к показу. + static String get notifyReady => + ''' + function notifyReady() { + try { + window.flutter_inappwebview.callHandler('$readyChannel', 'ready'); + } catch (e) {} + } + '''; + + /// Уведомляет Flutter об ошибке воспроизведения. + static String get notifyError => + ''' + function notifyError() { + try { + window.flutter_inappwebview.callHandler('$readyChannel', 'error'); + } catch (e) {} + } + '''; + + /// Rutube: готовность через postMessage `player:canPlay`. + static String get rutubeCanPlayListener => ''' + window.addEventListener('message', function (event) { + try { + var type = JSON.parse(event.data).type; + if (type === 'player:canPlay') { + notifyReady(); + } + } catch (e) {} + }); + '''; + + /// VK Video JS API: воспроизведение началось на событии `started`. + /// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`. + static String get vkPlayerReadyHandler => ''' + try { + if (typeof VK !== 'undefined' && VK.VideoPlayer) { + var vkPlayer = VK.VideoPlayer(document.getElementById('player')); + vkPlayer.on('started', function () { + notifyReady(); + }); + vkPlayer.on('error', function () { + notifyError(); + }); + } + } catch (e) {} + '''; + + /// Внешний скрипт VK VideoPlayer API. + static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js'; + + /// Собирает inline ` - - -'''; - } - - void _markReady() { - if (!mounted || _isReady || _hasError) return; - _readyFallback?.cancel(); - _readyFallback = null; - setState(() { - _isReady = true; - _isLoading = false; - }); - } - - void _markError() { - if (!mounted || _isReady) return; - _readyFallback?.cancel(); - _readyFallback = null; - setState(() { - _hasError = true; - _isLoading = false; - _initialData = null; - }); - } - - Future _onPlayPressed() async { - if (_isLoading || _isReady || !mounted) return; - - setState(() { - _isLoading = true; - _hasError = false; - }); - - try { - final embedUri = _embedUriForPlayback(widget.embedUrl); - final baseUrl = '${embedUri.scheme}://${embedUri.host}/'; - - if (!mounted) return; - setState(() { - _initialData = InAppWebViewInitialData( - data: _embedHtml(embedUri.toString()), - mimeType: 'text/html', - encoding: 'utf8', - baseUrl: WebUri(baseUrl), - ); - }); - } catch (e, st) { - if (kDebugMode) { - debugPrint('FeedVideoPlayer load failed: $e\n$st'); - } - _markError(); - } - } - - InAppWebViewSettings get _settings => InAppWebViewSettings( - // Жест play уже был на кнопке — разрешаем старта без доп. тапа. - mediaPlaybackRequiresUserGesture: false, - allowsInlineMediaPlayback: true, - iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen', - iframeAllowFullscreen: true, - transparentBackground: false, - ); - - void _onWebViewCreated(InAppWebViewController controller) { - controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady()); - } - - void _onLoadStop(InAppWebViewController _, WebUri? _) { - // Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube), - // покажем WebView по таймауту после полной загрузки. - if (_isReady || _hasError || !mounted) return; - _readyFallback?.cancel(); - _readyFallback = Timer(_readyFallbackDelay, () { - if (mounted && !_isReady && !_hasError) _markReady(); - }); - } - - void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) { - if (!mounted) return; - if (request.isForMainFrame != true) return; - // ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера. - if (error.description.contains('ERR_BLOCKED_BY_ORB')) return; - if (_isReady) return; - if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}'); - _markError(); - } - - void _onEnterFullscreen(InAppWebViewController _) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } - - void _onExitFullscreen(InAppWebViewController _) { - // Возвращаем системный набор ориентаций (не блокируем глобально в portrait). - SystemChrome.setPreferredOrientations([]); - } - - @override - Widget build(BuildContext context) { - final player = SizedBox( - height: 220, - width: MediaQuery.sizeOf(context).width, - child: Stack( - fit: StackFit.expand, - children: [ - // WebView создаётся только после play и до ready остаётся скрытым под превью. - if (_initialData != null) - Opacity( - opacity: _isReady ? 1 : 0, - child: IgnorePointer( - ignoring: !_isReady, - child: InAppWebView( - key: ValueKey('feed-inappwebview-${widget.embedUrl}'), - initialData: _initialData, - initialSettings: _settings, - onWebViewCreated: _onWebViewCreated, - onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'), - onLoadStop: _onLoadStop, - onReceivedError: _onReceivedError, - onEnterFullscreen: _onEnterFullscreen, - onExitFullscreen: _onExitFullscreen, - ), - ), - ), - if (!_isReady) - _VideoPreviewPlaceholder( - previewUrl: widget.videoUrlThumb, - isLoading: _isLoading, - hasError: _hasError, - onPlay: _onPlayPressed, - onRetry: _onPlayPressed, - ), - ], - ), - ); - - // Пока превью/лоадер — не даём InkWell карточки перехватывать тап. - if (_isReady) return player; - return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player); - } -} - -class _VideoPreviewPlaceholder extends StatelessWidget { - const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry}); - - final String? previewUrl; - final bool isLoading; - final bool hasError; - final VoidCallback onPlay; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Stack( - fit: StackFit.expand, - children: [ - if (previewUrl != null && previewUrl!.isNotEmpty) - CachedNetworkImage( - imageUrl: previewUrl!, - fit: BoxFit.cover, - errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), - ) - else - const ColoredBox(color: Color(0xFF2C2C2C)), - if (hasError) - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 40, color: Colors.white54), - const SizedBox(height: 8), - TextButton( - onPressed: onRetry, - child: const Text('Повторить', style: TextStyle(color: Colors.white70)), - ), - ], - ), - ) - else if (isLoading) - const Center(child: CircularProgressIndicator(color: Colors.white)) - else - Center( - child: GestureDetector( - onTap: onPlay, - child: Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle), - padding: const EdgeInsets.all(12), - child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40), - ), - ), - ), - ], - ); - } -} +export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; From d6b57ef34df6f4347074cf9511389bcad1b0890f Mon Sep 17 00:00:00 2001 From: HasanKulbuzhev Date: Tue, 21 Jul 2026 16:50:06 +0300 Subject: [PATCH 05/12] =?UTF-8?q?=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=83=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BA=D0=B0=D1=80=D1=83=D1=81=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BA,=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B5=20=D1=81=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=BE=D0=B9=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE.=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=20=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B5=D0=BE.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/blog_post_widget.dart | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 8751798b..0aa3feb2 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -244,46 +244,67 @@ class _BlogPostMediaContent extends StatefulWidget { } class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { - static const _counterDisplayDuration = Duration(seconds: 2); + static const _counterDisplayDuration = Duration(seconds: 1); bool _isCounterVisible = false; Timer? _counterHideTimer; + bool get _isCurrentSlideVideo => + widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length; + + bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + @override void dispose() { _counterHideTimer?.cancel(); super.dispose(); } + @override + void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { + _scheduleCounterHideOnVideo(); + } + } + + void _scheduleCounterHideOnVideo() { + _counterHideTimer?.cancel(); + if (!_isCurrentSlideVideo) return; + + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted && _isCurrentSlideVideo) { + setState(() => _isCounterVisible = false); + } + }); + } + bool _handleCarouselScroll(ScrollNotification notification) { if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { _counterHideTimer?.cancel(); if (!_isCounterVisible) { setState(() => _isCounterVisible = true); } - } else if (notification is ScrollEndNotification && _isCounterVisible) { - _counterHideTimer?.cancel(); - _counterHideTimer = Timer(_counterDisplayDuration, () { - if (mounted) { - setState(() => _isCounterVisible = false); - } - }); + } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + _scheduleCounterHideOnVideo(); } return false; } @override Widget build(BuildContext context) { - final mediaContentCount = widget.videoEmbedUrl != null ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; + final hasVideo = widget.videoEmbedUrl != null; + final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; + final showCarousel = mediaContentCount > 1; return Column( children: [ - if (widget.galleryUrls.length > 1) ...[ + if (showCarousel) ...[ Stack( children: [ NotificationListener( onNotification: _handleCarouselScroll, child: CarouselSlider.builder( - itemCount: widget.galleryUrls.length + 1, + itemCount: mediaContentCount, itemBuilder: (context, itemIndex, _) { if (itemIndex == widget.galleryUrls.length) { if (widget.videoEmbedUrl != null) { @@ -315,7 +336,7 @@ class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { right: 12, child: IgnorePointer( child: AnimatedOpacity( - opacity: _isCounterVisible ? 1 : 0, + opacity: _shouldShowCounter ? 1 : 0, duration: const Duration(milliseconds: 150), child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), ), From 2cccc9e25312191df4f4ad2b8d0770ea7330df06 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 22 Jul 2026 14:41:26 +0300 Subject: [PATCH 06/12] feat(personalized_feed): added video and multiple images handling to detailed blog post screen --- .../presentation/pages/blog_post_screen.dart | 39 +++- .../feed/models/feed_blog_post_model.dart | 2 +- .../models/post_feed_pagination_model.dart | 4 +- .../widgets/blog_post_media_content.dart | 215 ++++++++++++++++++ .../widgets/blog_post_widget.dart | 188 +-------------- lib/features/feed/services/feed_service.dart | 2 +- test/core/app_router_test.dart | 26 +++ test/core/routing/content_routes_test.dart | 5 +- .../cubit/blog_post_cubit_test.dart | 20 +- .../pages/blog_post_screen_smoke_test.dart | 129 ++++++++--- .../models/feed_blog_post_model_test.dart | 48 ++++ .../repositories/feed_repository_test.dart | 3 + ..._blog_post_gallery_and_video_response.json | 39 ++++ .../feed/feed_blog_post_gallery_response.json | 45 ++++ .../feed/feed_blog_post_response.json | 26 ++- .../feed_blog_post_video_only_response.json | 26 +++ test/fixtures/feed_fixtures.dart | 24 ++ 17 files changed, 595 insertions(+), 246 deletions(-) create mode 100644 lib/features/feed/presentation/widgets/blog_post_media_content.dart create mode 100644 test/fixtures/feed/feed_blog_post_gallery_and_video_response.json create mode 100644 test/fixtures/feed/feed_blog_post_gallery_response.json create mode 100644 test/fixtures/feed/feed_blog_post_video_only_response.json diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index ce691fb9..d5960c68 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -1,4 +1,4 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; @@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart'; +import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -33,6 +36,7 @@ class _BlogPostScreenState extends State { final CommentsCubit commentsCubit = sl(); final textEditingController = TextEditingController(); int? replyId; + int currentGalleryIndex = 0; @override void initState() { @@ -40,6 +44,10 @@ class _BlogPostScreenState extends State { super.initState(); } + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -242,8 +250,33 @@ class _BlogPostScreenState extends State { child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), - if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) - CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'), + Builder( + builder: (context) { + final post = blogPostState.blogPostModel; + if (post == null) { + return const SizedBox.shrink(); + } + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final galleryUrls = post.images + .map((e) => PostFeedMapper.resolveMediaUrl(e.url)) + .where((e) => e.isNotEmpty) + .toList(); + final embedUrl = post.videoEmbedUrl?.trim(); + return BlogPostMediaContent( + galleryUrls: galleryUrls, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, + ); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 157a2da0..9ce22b7f 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel { @JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, @Default([]) List images, - @JsonKey(name: "video_embed_url") String? videoEmbedUrl, + @JsonKey(name: "video_url_embed") String? videoEmbedUrl, @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart index c558e64e..31ade2a8 100644 --- a/lib/features/feed/models/post_feed_pagination_model.dart +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel { abstract class FeedGalleryImage with _$FeedGalleryImage { const factory FeedGalleryImage({ required String url, - required String orientation, + @Default('horizontal') String orientation, + int? id, + int? position, }) = _FeedGalleryImage; factory FeedGalleryImage.fromJson(Map json) => _$FeedGalleryImageFromJson(json); diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart new file mode 100644 index 00000000..e2141185 --- /dev/null +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider_plus/carousel_slider_plus.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:flutter/material.dart'; + +/// Gallery + optional embed video for feed card and blog post detail. +class BlogPostMediaContent extends StatefulWidget { + const BlogPostMediaContent({ + super.key, + required this.galleryUrls, + required this.imageUrl, + required this.videoEmbedUrl, + required this.currentGalleryIndex, + required this.onOpenGallery, + required this.onGalleryChanged, + required this.videoUrlThumb, + }); + + final List galleryUrls; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + State createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State { + static const _counterDisplayDuration = Duration(seconds: 1); + static const _carouselHeight = 260.0; + + bool _isCounterVisible = false; + Timer? _counterHideTimer; + + bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + + int get _slideCount { + if (widget.galleryUrls.isNotEmpty) { + return widget.galleryUrls.length + (_hasVideo ? 1 : 0); + } + if (_hasVideo) { + return 1; + } + return 0; + } + + bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length; + + bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + + @override + void dispose() { + _counterHideTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant BlogPostMediaContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { + _scheduleCounterHideOnVideo(); + } + } + + void _scheduleCounterHideOnVideo() { + _counterHideTimer?.cancel(); + if (!_isCurrentSlideVideo) return; + + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted && _isCurrentSlideVideo) { + setState(() => _isCounterVisible = false); + } + }); + } + + bool _handleCarouselScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + _counterHideTimer?.cancel(); + if (!_isCounterVisible) { + setState(() => _isCounterVisible = true); + } + } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + _scheduleCounterHideOnVideo(); + } + return false; + } + + Widget _imageSlide(String itemUrl, int itemIndex) { + return GestureDetector( + onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + height: _carouselHeight, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + } + + Widget _videoSlide() { + return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + } + + Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) { + return Stack( + children: [ + NotificationListener( + onNotification: _handleCarouselScroll, + child: CarouselSlider.builder( + itemCount: itemCount, + itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + options: CarouselOptions( + viewportFraction: 1, + height: _carouselHeight, + enableInfiniteScroll: false, + onPageChanged: (index, _) => widget.onGalleryChanged(index), + ), + ), + ), + if (itemCount > 1) + Positioned( + top: 12, + right: 12, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _shouldShowCounter ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) { + final itemCount = _slideCount; + return _carousel( + itemCount: itemCount, + itemBuilder: (context, itemIndex) { + if (itemIndex < widget.galleryUrls.length) { + return _imageSlide(widget.galleryUrls[itemIndex], itemIndex); + } + if (_hasVideo) { + return _videoSlide(); + } + return const SizedBox.shrink(); + }, + ); + } + + if (widget.galleryUrls.length == 1) { + return _imageSlide(widget.galleryUrls.first, 0); + } + + if (_hasVideo) { + return SizedBox( + height: _carouselHeight, + width: MediaQuery.of(context).size.width, + child: _videoSlide(), + ); + } + + if (widget.imageUrl.isNotEmpty) { + return GestureDetector( + onTap: () => widget.onOpenGallery([widget.imageUrl], 0), + child: Hero( + tag: widget.imageUrl, + child: CachedNetworkImage( + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class _GalleryPhotoCounter extends StatelessWidget { + const _GalleryPhotoCounter({required this.currentIndex, required this.total}); + + final int currentIndex; + final int total; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const SizedBox(width: 8), + Text( + '${currentIndex + 1} / $total', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 0aa3feb2..5c349db8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,7 +1,3 @@ -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; @@ -15,7 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -98,7 +94,7 @@ class _BlogPostWidgetState extends State { const SizedBox(height: 12), _BlogPostHeader(post: post), _BlogPostTextContent(post: post), - _BlogPostMediaContent( + BlogPostMediaContent( galleryUrls: galleryUrls, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, @@ -220,186 +216,6 @@ class _BlogPostTextContent extends StatelessWidget { } } -class _BlogPostMediaContent extends StatefulWidget { - const _BlogPostMediaContent({ - required this.galleryUrls, - required this.imageUrl, - required this.videoEmbedUrl, - required this.currentGalleryIndex, - required this.onOpenGallery, - required this.onGalleryChanged, - required this.videoUrlThumb, - }); - - final List galleryUrls; - final String imageUrl; - final String? videoEmbedUrl; - final String? videoUrlThumb; - final int currentGalleryIndex; - final void Function(List urls, int index) onOpenGallery; - final ValueChanged onGalleryChanged; - - @override - State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState(); -} - -class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { - static const _counterDisplayDuration = Duration(seconds: 1); - - bool _isCounterVisible = false; - Timer? _counterHideTimer; - - bool get _isCurrentSlideVideo => - widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length; - - bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; - - @override - void dispose() { - _counterHideTimer?.cancel(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { - _scheduleCounterHideOnVideo(); - } - } - - void _scheduleCounterHideOnVideo() { - _counterHideTimer?.cancel(); - if (!_isCurrentSlideVideo) return; - - _counterHideTimer = Timer(_counterDisplayDuration, () { - if (mounted && _isCurrentSlideVideo) { - setState(() => _isCounterVisible = false); - } - }); - } - - bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { - _counterHideTimer?.cancel(); - if (!_isCounterVisible) { - setState(() => _isCounterVisible = true); - } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { - _scheduleCounterHideOnVideo(); - } - return false; - } - - @override - Widget build(BuildContext context) { - final hasVideo = widget.videoEmbedUrl != null; - final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; - final showCarousel = mediaContentCount > 1; - return Column( - children: [ - if (showCarousel) ...[ - Stack( - children: [ - NotificationListener( - onNotification: _handleCarouselScroll, - child: CarouselSlider.builder( - itemCount: mediaContentCount, - itemBuilder: (context, itemIndex, _) { - if (itemIndex == widget.galleryUrls.length) { - if (widget.videoEmbedUrl != null) { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); - } - return const SizedBox.shrink(); - } - final itemUrl = widget.galleryUrls[itemIndex]; - return GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - imageUrl: itemUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ); - }, - options: CarouselOptions( - viewportFraction: 1, - height: 260, - enableInfiniteScroll: false, - onPageChanged: (index, _) => widget.onGalleryChanged(index), - ), - ), - ), - Positioned( - top: 12, - right: 12, - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _shouldShowCounter ? 1 : 0, - duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), - ), - ), - ), - ], - ), - ] else if (widget.galleryUrls.length == 1) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, 0), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - height: 260, - imageUrl: widget.galleryUrls.first, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ] else if (widget.imageUrl.isNotEmpty) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ), - ], - ], - ); - } -} - -class _GalleryPhotoCounter extends StatelessWidget { - const _GalleryPhotoCounter({required this.currentIndex, required this.total}); - - final int currentIndex; - final int total; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), - const SizedBox(width: 8), - Text( - '${currentIndex + 1} / $total', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), - ), - ], - ), - ); - } -} - class _BlogPostActionsRow extends StatelessWidget { const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); diff --git a/lib/features/feed/services/feed_service.dart b/lib/features/feed/services/feed_service.dart index 9d88dd8f..22a453be 100644 --- a/lib/features/feed/services/feed_service.dart +++ b/lib/features/feed/services/feed_service.dart @@ -25,7 +25,7 @@ abstract class FeedService { @Query('cursor') String? cursor, }); - @GET('/api/blogPosts/show/{postId}') + @GET('/api/v2/blogPosts/show/{postId}') Future getBlogPost({@Path('postId') required int postId}); @GET('/api/recipes/index') diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart index 648c5130..bdeef9c1 100644 --- a/test/core/app_router_test.dart +++ b/test/core/app_router_test.dart @@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart'; void main() { group('AppRouter', () { + group('resolveDeepLinkTarget - blog_post', () { + test('ru/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/ru/blog_post/123'), + ); + + expect(target, '/blog-post-screen?id=123'); + }); + + test('en/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/en/blog_post/456'), + ); + + expect(target, '/blog-post-screen?id=456'); + }); + + test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/blog_post/789'), + ); + + expect(target, '/blog-post-screen?id=789'); + }); + }); + group('resolveDeepLinkTarget - finance/alfa/return', () { const orderId = 'alfa_c29_k15138_t1712345678_r1234'; diff --git a/test/core/routing/content_routes_test.dart b/test/core/routing/content_routes_test.dart index ff23e2a3..385485a9 100644 --- a/test/core/routing/content_routes_test.dart +++ b/test/core/routing/content_routes_test.dart @@ -51,8 +51,11 @@ void main() { final blogPostRoute = routes.firstWhere( (route) => route is GoRoute && route.path == '/blog-post-screen', orElse: () => throw Exception('Route not found'), - ); + ) as GoRoute; expect(blogPostRoute, isA()); + expect(blogPostRoute.path, '/blog-post-screen'); + expect(blogPostRoute.name, 'blogPostScreen'); + expect(blogPostRoute.builder, isNotNull); }); test('aboutPlanScreen builder обрабатывает slug из query параметров', () { diff --git a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart index 082c154c..4103007c 100644 --- a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart +++ b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart @@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../../fixtures/feed_fixtures.dart'; + class MockFeedRepository extends Mock implements FeedRepository {} void main() { @@ -29,21 +31,7 @@ void main() { test('getBlogPost - успешная загрузка поста', () async { const postId = 1; - final mockPost = FeedBlogPostModel( - id: postId, - title: 'Test Post', - state: 'active', - image: '', - description: 'Test Content', - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false), - ); + final mockPost = FeedFixtures.feedBlogPostModel(); when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost); @@ -51,6 +39,8 @@ void main() { expect(cubit.state.isLoading, false); expect(cubit.state.blogPostModel, mockPost); + expect(cubit.state.blogPostModel?.images, hasLength(2)); + expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed')); verify(() => mockRepository.getBlogPost(id: postId)).called(1); }); diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index f194ea0c..a021157a 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart'; -import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:network_image_mock/network_image_mock.dart'; +import '../../../../fixtures/feed_fixtures.dart'; import 'blog_post_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) @@ -167,21 +167,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -202,7 +188,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BlogPostScreen), findsOneWidget); - expect(find.text('Test Post Title'), findsOneWidget); + expect(find.text('Заголовок'), findsOneWidget); + expect(find.byType(BlogPostMediaContent), findsOneWidget); }); }); @@ -211,21 +198,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -254,5 +227,95 @@ void main() { verify(blogPostCubit.toggleLike()).called(1); }); }); + + testWidgets('renders gallery from fixture', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostWithGallery(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея'), findsOneWidget); + }); + }); + + testWidgets('renders video-only post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostVideoOnly(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); + }); + }); + + testWidgets('renders gallery and video post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея и видео'), findsWidgets); + }); + }); }); } diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 9d84480a..55fbe181 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -2,6 +2,8 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../fixtures/feed_fixtures.dart'; + void main() { group('FeedBlogPostModel', () { test('creates FeedBlogPostModel with all parameters', () { @@ -102,6 +104,52 @@ void main() { expect(model.totalLikes, 10); }); + test('parses v2 show JSON with gallery images and video_url_embed', () { + final model = FeedBlogPostModel.fromJson({ + 'id': 1, + 'state': 'active', + 'image': 'media/1/cover.jpg', + 'images': [ + {'id': 10, 'url': 'media/1/a.jpg', 'position': 1, 'orientation': 'horizontal'}, + {'id': 11, 'url': 'media/1/b.jpg', 'position': 2}, + ], + 'description': 'Описание', + 'author': { + 'author_slug': 'author-slug', + 'author_name': 'Автор', + 'author_profile_icon': 'icon.jpg', + 'author_is_me': false, + }, + 'video_url': 'https://rutube.ru/video/abc/', + 'video_url_embed': 'https://rutube.ru/play/embed/abc/', + 'video_url_thumb': 'media/1/thumb.jpg', + 'created_at': '2024-01-15T12:00:00Z', + 'updated_at': '2024-01-15T12:00:00Z', + 'title': 'Заголовок', + 'total_likes': 10, + 'total_views': 100, + 'total_comments': 5, + 'was_liked_by_me': false, + }); + + expect(model.images, hasLength(2)); + expect(model.images.first.id, 10); + expect(model.images.first.url, 'media/1/a.jpg'); + expect(model.images.first.orientation, 'horizontal'); + expect(model.images[1].orientation, 'horizontal'); + expect(model.images[1].position, 2); + expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/'); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + + test('parses fixture feedBlogPostModel with gallery and embed', () { + final model = FeedFixtures.feedBlogPostModel(); + + expect(model.images, hasLength(2)); + expect(model.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + test('creates FeedBlogPostModel with different states', () { const author = Author( authorSlug: 'author', diff --git a/test/features/feed/repositories/feed_repository_test.dart b/test/features/feed/repositories/feed_repository_test.dart index 9165ba3a..10e9f200 100644 --- a/test/features/feed/repositories/feed_repository_test.dart +++ b/test/features/feed/repositories/feed_repository_test.dart @@ -85,6 +85,9 @@ void main() { expect(result, isA()); expect(result.id, postId); + expect(result.images, hasLength(2)); + expect(result.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(result.videoUrlThumb, isNotEmpty); verify(mockFeedService.getBlogPost(postId: postId)).called(1); }); diff --git a/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json new file mode 100644 index 00000000..c54b04af --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json @@ -0,0 +1,39 @@ +{ + "id": 4, + "state": "active", + "image": "media/4/a.jpg", + "images": [ + { + "id": 40, + "url": "media/4/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 41, + "url": "media/4/b.jpg", + "position": 2, + "orientation": "horizontal" + } + ], + "description": "Галерея и видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": true, + "author_place_in_rating": null + }, + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/4/thumb.jpg", + "created_at": "2024-01-18T12:00:00Z", + "updated_at": "2024-01-18T12:00:00Z", + "title": "Галерея и видео", + "total_likes": 12, + "total_views": 200, + "total_comments": 4, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_gallery_response.json b/test/fixtures/feed/feed_blog_post_gallery_response.json new file mode 100644 index 00000000..fd23f3a4 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_response.json @@ -0,0 +1,45 @@ +{ + "id": 2, + "state": "active", + "image": "media/2/a.jpg", + "images": [ + { + "id": 20, + "url": "media/2/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 21, + "url": "media/2/b.jpg", + "position": 2, + "orientation": "horizontal" + }, + { + "id": 22, + "url": "media/2/c.jpg", + "position": 3, + "orientation": "vertical" + } + ], + "description": "Пост только с галереей", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "", + "video_url_embed": null, + "video_url_thumb": null, + "created_at": "2024-01-16T12:00:00Z", + "updated_at": "2024-01-16T12:00:00Z", + "title": "Галерея", + "total_likes": 3, + "total_views": 40, + "total_comments": 1, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_response.json b/test/fixtures/feed/feed_blog_post_response.json index 75ae180b..c5af87de 100644 --- a/test/fixtures/feed/feed_blog_post_response.json +++ b/test/fixtures/feed/feed_blog_post_response.json @@ -1,7 +1,21 @@ { "id": 1, - "state": "published", - "image": "https://example.com/image.jpg", + "state": "active", + "image": "media/1/cover.jpg", + "images": [ + { + "id": 10, + "url": "media/1/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 11, + "url": "media/1/b.jpg", + "position": 2, + "orientation": "vertical" + } + ], "description": "Описание поста", "author": { "author_slug": "author-slug", @@ -11,13 +25,15 @@ "is_entity_subscribed_by_me": null, "author_place_in_rating": null }, - "video_url": "", + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/1/thumb.jpg", "created_at": "2024-01-15T12:00:00Z", "updated_at": "2024-01-15T12:00:00Z", "title": "Заголовок", "total_likes": 10, "total_views": 100, "total_comments": 5, - "was_liked_by_me": false + "was_liked_by_me": false, + "is_favorite": false } - diff --git a/test/fixtures/feed/feed_blog_post_video_only_response.json b/test/fixtures/feed/feed_blog_post_video_only_response.json new file mode 100644 index 00000000..f3025547 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_video_only_response.json @@ -0,0 +1,26 @@ +{ + "id": 3, + "state": "active", + "image": "images/element-image-placeholder.svg", + "images": [], + "description": "Пост только с видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url_thumb": "media/3/thumb.jpg", + "created_at": "2024-01-17T12:00:00Z", + "updated_at": "2024-01-17T12:00:00Z", + "title": "Видео", + "total_likes": 8, + "total_views": 90, + "total_comments": 2, + "was_liked_by_me": true, + "is_favorite": false +} diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 3084a69b..0beb1624 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -23,6 +23,18 @@ class FeedFixtures { return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); } + static Map feedBlogPostGalleryResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json'); + } + + static Map feedBlogPostVideoOnlyResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json'); + } + + static Map feedBlogPostGalleryAndVideoResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json'); + } + static PostFeedPaginationModel postFeedPaginationModel() { return PostFeedPaginationModel.fromJson(postFeedResponseJson()); } @@ -48,4 +60,16 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostModel() { return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); } + + static FeedBlogPostModel feedBlogPostWithGallery() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson()); + } + + static FeedBlogPostModel feedBlogPostVideoOnly() { + return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson()); + } + + static FeedBlogPostModel feedBlogPostGalleryAndVideo() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); + } } From 037f153273bcf93c4085170bcd6335be29a4d3fb Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 22 Jul 2026 17:58:02 +0300 Subject: [PATCH 07/12] feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint --- .../models/consultant_profile_model.dart | 74 ++- .../models/consultation_request_model.dart | 52 +- .../models/sbp_bank_model.dart | 17 + .../register_consultant_cubit.dart | 162 +++++- .../register_consultant_state.dart | 3 + .../certificates_and_diplomas_screen.dart | 14 +- .../presentation/pages/last_step_screen.dart | 68 ++- .../pages/passport_data_screen.dart | 527 ++++++++++++++++++ .../pages/registration_as_screen.dart | 50 +- .../stepper_become_consultant_screen.dart | 441 ++++++++------- .../repositories/consultant_repository.dart | 13 +- .../services/consultant_service.dart | 14 +- .../models/consultant_profile_model_test.dart | 40 +- .../consultation_request_model_test.dart | 41 ++ .../bloc/register_consultant_cubit_test.dart | 84 ++- .../pages/last_step_screen_test.dart | 21 +- .../pages/passport_data_screen_test.dart | 59 ++ .../pages/registration_as_screen_test.dart | 32 +- ...r_become_consultant_screen_smoke_test.dart | 2 +- .../consultant_repository_test.dart | 13 + .../consultant_profile_request.json | 24 +- .../consultant/consultation_request.json | 26 +- test/fixtures/consultant/sbp_banks.json | 16 + 23 files changed, 1514 insertions(+), 279 deletions(-) create mode 100644 lib/features/consultant/become_consultant/models/sbp_bank_model.dart create mode 100644 lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart create mode 100644 test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart create mode 100644 test/fixtures/consultant/sbp_banks.json diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 535b58fd..dd9e01f5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -6,24 +6,80 @@ part 'consultant_profile_model.g.dart'; @freezed abstract class ConsultantProfileModel with _$ConsultantProfileModel { + const ConsultantProfileModel._(); + const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, - @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, + @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, + @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, + @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, + @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, + @JsonKey(name: 'inn', includeIfNull: false) String? inn, + @JsonKey(name: 'snils', includeIfNull: false) String? snils, + @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, + @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, + @JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, + @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, + @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + + bool get hasValidPassportData { + final last = lastName ?? ''; + final first = firstName ?? ''; + final innValue = inn ?? ''; + final snilsValue = snils ?? ''; + final series = passportSeries ?? ''; + final number = passportNumber ?? ''; + final issueDate = passportIssueDate ?? ''; + final issuer = passportIssuer ?? ''; + final issuerCode = passportIssuerCode ?? ''; + final birth = birthDate ?? ''; + final birthPlaceValue = birthPlace ?? ''; + final registration = registrationAddress ?? ''; + final residence = residenceAddress ?? ''; + final citizenshipValue = citizenship ?? ''; + + return last.trim().isNotEmpty && + first.trim().isNotEmpty && + RegExp(r'^\d{12}$').hasMatch(innValue) && + RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && + RegExp(r'^\d{4}$').hasMatch(series) && + RegExp(r'^\d{6}$').hasMatch(number) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && + issuer.trim().isNotEmpty && + RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && + birthPlaceValue.trim().isNotEmpty && + registration.trim().isNotEmpty && + residence.trim().isNotEmpty && + RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); + } } @freezed abstract class SocialLinksModel with _$SocialLinksModel { const factory SocialLinksModel({ - @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, - @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, - @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, - @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, + @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, + @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, + @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, }) = _SocialLinksModel; factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 13cc0e77..051f517f 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -12,11 +12,61 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); } +@freezed +abstract class LegalEntityModel with _$LegalEntityModel { + const LegalEntityModel._(); + + const factory LegalEntityModel({ + required int id, + required String type, + String? inn, + String? snils, + String? state, + @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, + @JsonKey(name: 'last_name') String? lastName, + @JsonKey(name: 'first_name') String? firstName, + @JsonKey(name: 'middle_name') String? middleName, + @JsonKey(name: 'passport_series') String? passportSeries, + @JsonKey(name: 'passport_number') String? passportNumber, + @JsonKey(name: 'passport_issue_date') String? passportIssueDate, + @JsonKey(name: 'passport_issuer') String? passportIssuer, + @JsonKey(name: 'passport_issuer_code') String? passportIssuerCode, + @JsonKey(name: 'birth_date') String? birthDate, + @JsonKey(name: 'birth_place') String? birthPlace, + @JsonKey(name: 'registration_address') String? registrationAddress, + @JsonKey(name: 'residence_address') String? residenceAddress, + String? citizenship, + @JsonKey(name: 'tax_resident_us') bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone') String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, + }) = _LegalEntityModel; + + factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + + /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. + static String? normalizeApiDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) { + return value.substring(0, 10); + } + return value; + } + + String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + + String? get normalizedBirthDate => normalizeApiDate(birthDate); +} + @freezed abstract class CertPhotosModel with _$CertPhotosModel { const factory CertPhotosModel({ @@ -29,7 +79,7 @@ abstract class CertPhotosModel with _$CertPhotosModel { enum ModerationStatus { approved, created, - canceled + canceled, } @freezed diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart new file mode 100644 index 00000000..c7bf7976 --- /dev/null +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sbp_bank_model.freezed.dart'; +part 'sbp_bank_model.g.dart'; + +@freezed +abstract class SbpBankModel with _$SbpBankModel { + const factory SbpBankModel({ + @JsonKey(name: 'bank_member_id') required String bankMemberId, + @JsonKey(name: 'bank_name') required String bankName, + @JsonKey(name: 'short_name') String? shortName, + @JsonKey(name: 'logo_url') String? logoUrl, + String? bic, + }) = _SbpBankModel; + + factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); +} diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 0410ae04..9cae8fa5 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,21 +13,60 @@ class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; bool _update = false; + static const String individualPersonLegalType = 'individualPerson'; + RegisterConsultantCubit({required this.consultantRepository}) : super( const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), loading: true, acceptOffer: false, + acceptFrdoAgreement: false, isUpdating: false, ), ); + Future> _loadSbpBanks() async { + try { + return await consultantRepository.getSbpBanks(); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + return const []; + } + } + + List searchSbpBanks(String query) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return state.sbpBanks; + } + return state.sbpBanks.where((bank) { + final name = bank.bankName.toLowerCase(); + final shortName = (bank.shortName ?? '').toLowerCase(); + final bic = (bank.bic ?? '').toLowerCase(); + return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); + }).toList(); + } + + SbpBankModel? findSbpBankById(String? bankMemberId) { + if (bankMemberId == null || bankMemberId.isEmpty) { + return null; + } + for (final bank in state.sbpBanks) { + if (bank.bankMemberId == bankMemberId) { + return bank; + } + } + return null; + } + Future getMyRequestStatus(String slug) async { try { _update = true; final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { final course = courseMap.values.first; @@ -37,6 +78,8 @@ class RegisterConsultantCubit extends Cubit { return (link != null && link.isNotEmpty) ? link : null; } + final legal = myRequestStatus.legalEntity; + emit( RegisterConsultantState( consultant: ConsultantProfileModel( @@ -49,11 +92,33 @@ class RegisterConsultantCubit extends Cubit { personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), ), hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + legalType: legal?.type ?? individualPersonLegalType, + lastName: _emptyToNull(legal?.lastName), + firstName: _emptyToNull(legal?.firstName), + middleName: _emptyToNull(legal?.middleName), + inn: _emptyToNull(legal?.inn), + snils: _emptyToNull(legal?.snils), + passportSeries: _emptyToNull(legal?.passportSeries), + passportNumber: _emptyToNull(legal?.passportNumber), + passportIssueDate: legal?.normalizedPassportIssueDate, + passportIssuer: _emptyToNull(legal?.passportIssuer), + passportIssuerCode: _emptyToNull(legal?.passportIssuerCode), + birthDate: legal?.normalizedBirthDate, + birthPlace: _emptyToNull(legal?.birthPlace), + registrationAddress: _emptyToNull(legal?.registrationAddress), + residenceAddress: _emptyToNull(legal?.residenceAddress), + citizenship: _emptyToNull(legal?.citizenship) ?? 'RU', + taxResidentUs: legal?.taxResidentUs ?? false, + foreignTaxResident: legal?.foreignTaxResident ?? false, + sbpPhone: _emptyToNull(legal?.sbpPhone), + sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId), ), acceptOffer: false, + acceptFrdoAgreement: false, loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + sbpBanks: sbpBanks, isUpdating: _update, error: null, ), @@ -65,10 +130,31 @@ class RegisterConsultantCubit extends Cubit { } } + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + return value.trim(); + } + Future disableLoading() async { try { final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); + final sbpBanks = await _loadSbpBanks(); + emit( + state.copyWith( + loading: false, + certPhotos: certPhotos, + sbpBanks: sbpBanks, + error: null, + consultant: state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + ), + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -86,13 +172,79 @@ class RegisterConsultantCubit extends Cubit { } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { - emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); + void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + emit( + state.copyWith( + consultant: consultant, + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + loading: false, + error: null, + ), + ); + } + + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { + emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + } + + bool get hasAcceptedRequiredAgreements { + if (_update) { + return state.acceptOffer; + } + return state.acceptOffer && state.acceptFrdoAgreement; + } + + ConsultantProfileModel buildSubmitPayload() { + final withLegalType = state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + certificates: state.consultant.certificates + ?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) + .toList(), + ); + + if (_update) { + return ConsultantProfileModel( + hasMedicalEducation: withLegalType.hasMedicalEducation, + socialLinks: withLegalType.socialLinks, + photos: withLegalType.photos, + certificates: withLegalType.certificates, + legalType: withLegalType.legalType, + lastName: withLegalType.lastName, + firstName: withLegalType.firstName, + middleName: withLegalType.middleName, + inn: withLegalType.inn, + snils: withLegalType.snils, + passportSeries: withLegalType.passportSeries, + passportNumber: withLegalType.passportNumber, + passportIssueDate: withLegalType.passportIssueDate, + passportIssuer: withLegalType.passportIssuer, + passportIssuerCode: withLegalType.passportIssuerCode, + birthDate: withLegalType.birthDate, + birthPlace: withLegalType.birthPlace, + registrationAddress: withLegalType.registrationAddress, + residenceAddress: withLegalType.residenceAddress, + citizenship: withLegalType.citizenship, + taxResidentUs: withLegalType.taxResidentUs, + foreignTaxResident: withLegalType.foreignTaxResident, + sbpPhone: withLegalType.sbpPhone, + sbpBankMemberId: withLegalType.sbpBankMemberId, + ); + } + + return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); } Future registerConsultant() async { emit(state.copyWith(error: null)); try { + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } if (state.newAddedPhotos.isNotEmpty) { await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); } @@ -101,7 +253,7 @@ class RegisterConsultantCubit extends Cubit { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); + await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index e9635eb6..a39a1313 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'register_consultant_state.freezed.dart'; @@ -12,10 +13,12 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { const factory RegisterConsultantState({ required ConsultantProfileModel consultant, required bool acceptOffer, + @Default(false) bool acceptFrdoAgreement, required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, @Default([]) List certPhotos, + @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, String? error, diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index b2b26c0e..afff462e 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @@ -231,10 +232,15 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( context.read().state.consultant.copyWith( - certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist( - 0, - diplomasList.length - 1, - ), + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), + ), + ) + .toList() + .sublist(0, diplomasList.length - 1), ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 734387f1..7fd501a6 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,10 +16,28 @@ class LastStepScreen extends StatefulWidget { } class _LastStepScreenState extends State { - bool checkboxState = false; + static const _frdoUrl = + 'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/'; + + late final TapGestureRecognizer _frdoTapRecognizer; + + @override + void initState() { + super.initState(); + _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + } + + @override + void dispose() { + _frdoTapRecognizer.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final cubit = context.watch(); + final state = cubit.state; + return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 80, top: 16), child: Column( @@ -31,29 +50,56 @@ class _LastStepScreenState extends State { ), const SizedBox(height: 16), CheckboxListTile( - value: checkboxState, + value: state.acceptOffer, title: Text( hidePlans ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' - : 'Я ознакомлен и принимаю договор публичной офёрты', - style: const TextStyle(fontSize: 18, height: 1), + : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности', + style: const TextStyle(fontSize: 18, height: 1.2), ), - onChanged: (_) { - setState(() { - checkboxState = !checkboxState; - }); - context.read().updateConsultantProfileModel(context.read().state.consultant, checkboxState); + onChanged: (value) { + cubit.updateAgreements(acceptOffer: value ?? false); }, activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, ), + if (!state.isUpdating) ...[ + CheckboxListTile( + value: state.acceptFrdoAgreement, + title: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + children: [ + const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + TextSpan( + text: 'ФРДО', + style: TextStyle( + color: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: _frdoTapRecognizer, + ), + ], + ), + ), + onChanged: (value) { + cubit.updateAgreements(acceptFrdoAgreement: value ?? false); + }, + activeColor: appTheme.blueA400, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + ], Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( - onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf')), + onPressed: () async => await launchUrl( + Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', + hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart new file mode 100644 index 00000000..f3eb774b --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -0,0 +1,527 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +class PassportDataScreen extends StatefulWidget { + const PassportDataScreen({super.key}); + + @override + State createState() => _PassportDataScreenState(); +} + +class _PassportDataScreenState extends State { + late final TextEditingController lastNameController; + late final TextEditingController firstNameController; + late final TextEditingController middleNameController; + late final TextEditingController innController; + late final TextEditingController snilsController; + late final TextEditingController seriesController; + late final TextEditingController numberController; + late final TextEditingController issueDateController; + late final TextEditingController issuerController; + late final TextEditingController issuerCodeController; + late final TextEditingController birthDateController; + late final TextEditingController birthPlaceController; + late final TextEditingController citizenshipController; + late final TextEditingController registrationAddressController; + late final TextEditingController residenceAddressController; + late final TextEditingController sbpPhoneController; + late final TextEditingController sbpBankController; + final sbpBankSuggestionsController = SuggestionsController(); + + bool taxResidentUs = false; + bool foreignTaxResident = false; + String? selectedSbpBankMemberId; + String? selectedSbpBankName; + + final snilsMask = MaskTextInputFormatter( + mask: '###-###-### ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskIssue = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskBirth = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final issuerCodeMask = MaskTextInputFormatter( + mask: '###-###', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final phoneMask = MaskTextInputFormatter( + mask: '+7 (###) ### ## ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + + @override + void initState() { + super.initState(); + final consultant = context.read().state.consultant; + lastNameController = TextEditingController(text: consultant.lastName ?? ''); + firstNameController = TextEditingController( + text: consultant.firstName ?? '', + ); + middleNameController = TextEditingController( + text: consultant.middleName ?? '', + ); + innController = TextEditingController(text: consultant.inn ?? ''); + snilsController = TextEditingController(text: consultant.snils ?? ''); + seriesController = TextEditingController( + text: consultant.passportSeries ?? '', + ); + numberController = TextEditingController( + text: consultant.passportNumber ?? '', + ); + issueDateController = TextEditingController( + text: _apiDateToUi(consultant.passportIssueDate), + ); + issuerController = TextEditingController( + text: consultant.passportIssuer ?? '', + ); + issuerCodeController = TextEditingController( + text: consultant.passportIssuerCode ?? '', + ); + birthDateController = TextEditingController( + text: _apiDateToUi(consultant.birthDate), + ); + birthPlaceController = TextEditingController( + text: consultant.birthPlace ?? '', + ); + citizenshipController = TextEditingController( + text: consultant.citizenship ?? 'RU', + ); + registrationAddressController = TextEditingController( + text: consultant.registrationAddress ?? '', + ); + residenceAddressController = TextEditingController( + text: consultant.residenceAddress ?? '', + ); + sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? ''); + selectedSbpBankMemberId = consultant.sbpBankMemberId; + final selectedBank = context + .read() + .findSbpBankById(consultant.sbpBankMemberId); + selectedSbpBankName = selectedBank?.bankName; + sbpBankController = TextEditingController(text: selectedSbpBankName ?? ''); + taxResidentUs = consultant.taxResidentUs ?? false; + foreignTaxResident = consultant.foreignTaxResident ?? false; + } + + void _hydrateSbpBankNameIfNeeded() { + if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) { + return; + } + if (sbpBankController.text.trim().isNotEmpty) { + return; + } + final selectedBank = context + .read() + .findSbpBankById(selectedSbpBankMemberId); + if (selectedBank == null) { + return; + } + selectedSbpBankName = selectedBank.bankName; + sbpBankController.text = selectedBank.bankName; + } + + @override + void dispose() { + lastNameController.dispose(); + firstNameController.dispose(); + middleNameController.dispose(); + innController.dispose(); + snilsController.dispose(); + seriesController.dispose(); + numberController.dispose(); + issueDateController.dispose(); + issuerController.dispose(); + issuerCodeController.dispose(); + birthDateController.dispose(); + birthPlaceController.dispose(); + citizenshipController.dispose(); + registrationAddressController.dispose(); + residenceAddressController.dispose(); + sbpPhoneController.dispose(); + sbpBankController.dispose(); + sbpBankSuggestionsController.dispose(); + super.dispose(); + } + + String _apiDateToUi(String? apiDate) { + if (apiDate == null || apiDate.isEmpty) { + return ''; + } + final parts = apiDate.split('-'); + if (parts.length != 3) { + return apiDate; + } + return '${parts[2]}.${parts[1]}.${parts[0]}'; + } + + String? _uiDateToApi(String uiDate) { + final digits = uiDate.replaceAll(RegExp(r'\D'), ''); + if (digits.length != 8) { + return null; + } + return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}'; + } + + String? _uiPhoneToApi(String uiPhone) { + final digits = uiPhone.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + return null; + } + if (digits.length == 11 && digits.startsWith('7')) { + return '+$digits'; + } + if (digits.length == 10) { + return '+7$digits'; + } + return uiPhone.trim().isEmpty ? null : uiPhone.trim(); + } + + void _syncToCubit() { + final cubit = context.read(); + final current = cubit.state.consultant; + cubit.updateConsultantProfileModel( + current.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + lastName: lastNameController.text.trim().isEmpty + ? null + : lastNameController.text.trim(), + firstName: firstNameController.text.trim().isEmpty + ? null + : firstNameController.text.trim(), + middleName: middleNameController.text.trim().isEmpty + ? null + : middleNameController.text.trim(), + inn: innController.text.trim().isEmpty + ? null + : innController.text.trim(), + snils: snilsController.text.trim().isEmpty + ? null + : snilsController.text.trim(), + passportSeries: seriesController.text.trim().isEmpty + ? null + : seriesController.text.trim(), + passportNumber: numberController.text.trim().isEmpty + ? null + : numberController.text.trim(), + passportIssueDate: _uiDateToApi(issueDateController.text), + passportIssuer: issuerController.text.trim().isEmpty + ? null + : issuerController.text.trim(), + passportIssuerCode: issuerCodeController.text.trim().isEmpty + ? null + : issuerCodeController.text.trim(), + birthDate: _uiDateToApi(birthDateController.text), + birthPlace: birthPlaceController.text.trim().isEmpty + ? null + : birthPlaceController.text.trim(), + citizenship: citizenshipController.text.trim().isEmpty + ? 'RU' + : citizenshipController.text.trim().toUpperCase(), + registrationAddress: registrationAddressController.text.trim().isEmpty + ? null + : registrationAddressController.text.trim(), + residenceAddress: residenceAddressController.text.trim().isEmpty + ? null + : residenceAddressController.text.trim(), + taxResidentUs: taxResidentUs, + foreignTaxResident: foreignTaxResident, + sbpPhone: _uiPhoneToApi(sbpPhoneController.text), + sbpBankMemberId: selectedSbpBankMemberId, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + } + + void _selectSbpBank(SbpBankModel bank) { + setState(() { + selectedSbpBankMemberId = bank.bankMemberId; + selectedSbpBankName = bank.bankName; + sbpBankController.text = bank.bankName; + }); + _syncToCubit(); + } + + void _clearSbpBankIfNeeded(String value) { + if (selectedSbpBankMemberId != null && value != selectedSbpBankName) { + setState(() { + selectedSbpBankMemberId = null; + selectedSbpBankName = null; + }); + _syncToCubit(); + } + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, + listener: (context, state) => _hydrateSbpBankNameIfNeeded(), + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Паспортные данные', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + _sectionTitle('Персональные данные (как в паспорте)'), + CustomTextFormField( + hintText: 'Фамилия', + controller: lastNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Имя', + controller: firstNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Идентификация'), + CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь налоговым резидентом США', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + _sectionTitle('Паспорт'), + Row( + children: [ + Expanded( + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => _syncToCubit(), + ), + ), + ], + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Реквизиты СБП'), + CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + onChanged: (value) { + _clearSbpBankIfNeeded(value); + }, + ), + suggestionsCallback: (query) async { + return context.read().searchSbpBanks( + query, + ); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: const TextStyle(fontSize: 16, color: Colors.black), + ), + subtitle: bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: child, + ), + emptyBuilder: (context) => const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: Colors.black54), + ), + ), + onSelected: _selectSbpBank, + ), + const SizedBox(height: 16), + const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyle( + fontSize: 14, + height: 1.2, + color: appTheme.blueA400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index b8fed20b..a754de27 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RegistrationAsScreen extends StatefulWidget { const RegistrationAsScreen({super.key}); @@ -11,7 +13,23 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cubit = context.read(); + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + }); + } @override Widget build(BuildContext context) { @@ -36,11 +54,7 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: (_) {}, ), const Expanded( child: Padding( @@ -58,16 +72,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -80,16 +90,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -102,16 +108,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index a27e5c67..69f88361 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; @@ -28,7 +29,7 @@ class StepperBecomeConsultantScreen extends StatefulWidget { } class _StepperBecomeConsultantScreenState extends State { - double progressValue = 1 / 6; + double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; @@ -44,6 +45,20 @@ class _StepperBecomeConsultantScreenState extends State _pages() { + return const [ + CertificatesAndDiplomasScreen(), + PhotoOrScansScreen(), + AdditionalSettingsScreen(), + SocialNetworksScreen(), + RegistrationAsScreen(), + PassportDataScreen(), + LastStepScreen(), + ]; + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -53,7 +68,7 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), - ], + ), + if (widget.isCanceled == true) + InkWell( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(state.requestModel!.canceledModeration!.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ + Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], - ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), + ], ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), ), ), ), - Container( - height: 48, - width: widget.isCanceled == true - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: isLoading - ? Colors.grey - : widget.isCanceled == true - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: isLoading - ? null - : () async { - if (step == 6) { - if (widget.isCanceled == true) { - return; - } - if (registrationConsultantCubit.state.acceptOffer) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit.registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), + ), + Container( + height: 48, + width: widget.isCanceled == true + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: isLoading + ? Colors.grey + : widget.isCanceled == true + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: isLoading + ? null + : () async { + if (step == totalSteps) { + if (widget.isCanceled == true) { + return; + } + if (!state.consultant.hasValidPassportData) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: const Text('Заполните паспортные данные'), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit.registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit.state.error != null) { + return; + } + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), ), ), ), - ], - ), - ), - ); - } catch (e) { - if (!context.mounted) return; - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: Text(e.toString()), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), ), ], ), - ); - } finally { - setState(() { - isLoading = false; - }); - } - } else { - showDialog( + ), + ); + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Примите договор публичной офёрты'), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: () => Navigator.of(context).pop(), ), ], ), ); + } finally { + setState(() { + isLoading = false; + }); } } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); } - }, - label: isLoading - ? const CircularProgressIndicator() - : widget.isCanceled == true - ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) - : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), - ), + } else { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); + } + }, + label: isLoading + ? const CircularProgressIndicator() + : widget.isCanceled == true + ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) + : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), ), - centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween(begin: 1 / 6, end: progressValue), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), - ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + ), + centerTitle: true, + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween(begin: 1 / totalSteps, end: progressValue), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(appTheme.blueA400), ), ), ), ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 6; - }); - }, - children: const [ - CertificatesAndDiplomasScreen(), - PhotoOrScansScreen(), - AdditionalSettingsScreen(), - SocialNetworksScreen(), - RegistrationAsScreen(), - LastStepScreen(), - ], - ), - ); + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); }, ), ); diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index c0e1c57a..b8d55b5a 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:http_parser/http_parser.dart'; @@ -17,22 +18,24 @@ class ConsultantRepository { } else { return await consultantService.consultantRegistration(consultantModel: consultant); } - } + /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. Future getConsultantRequestStatus(String slug) async { return await consultantService.getConsultantRequestStatus(slug: slug); - } + /// Своя заявка — education + legal/passport для регистрации и update. Future getMyRequestStatus(String slug) async { return await consultantService.getMyRequestStatus(slug: slug); + } + Future> getSbpBanks() async { + return await consultantService.getSbpBanks(); } Future> getConsultantCertPhotos() async { return await consultantService.getConsultantCertPhotos(); - } Future> storeConsultantCertPhotos({required List files}) async { @@ -49,21 +52,17 @@ class ConsultantRepository { } return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); - } Future hideConsultantCertPhoto(int id) async { await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future unHideConsultantCertPhoto(int id) async { await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future deletePhoto(int id) async { await consultantService.deletePhoto(id: id); - } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 9a0757ad..6482c667 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:retrofit/retrofit.dart'; part 'consultant_service.g.dart'; @@ -10,22 +11,29 @@ part 'consultant_service.g.dart'; abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; - @POST('/api/consultant_request/registration') + @POST('/api/v2/consultant_request/registration') Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); @POST('/api/consultant_request/photos/upload') @MultiPart() Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + /// Публичный статус консультанта для просмотра профиля. + /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') Future getConsultantRequestStatus({@Path() required String slug}); - @GET('/api/consultant_request/{slug}/status') + /// Свой статус заявки для редактирования/модерации. + /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. + @GET('/api/v2/consultant_request/{slug}/status') Future getMyRequestStatus({@Path() required String slug}); - @PUT('/api/consultant_request/update') + @PUT('/api/v2/consultant_request/update') Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + @GET('/api/finances/sbp_banks') + Future> getSbpBanks(); + @GET('/api/v2/consultant_request/cert_photos') Future> getConsultantCertPhotos(); diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index 3b008a3f..fb88c9c5 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -106,7 +106,17 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); - const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]); + const model = ConsultantProfileModel( + hasMedicalEducation: true, + socialLinks: socialLinks, + photos: ['photo1.jpg'], + certificates: [certificates], + legalType: 'individualPerson', + inn: '123456789012', + snils: '123-456-789 00', + agreementAccepted: true, + frdoAgreementAccepted: true, + ); final json = model.toJson(); @@ -114,6 +124,34 @@ void main() { expect(json['social_links'], isNotNull); expect(json['cert_photos'], ['photo1.jpg']); expect(json['completed_courses'], isNotNull); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['snils'], '123-456-789 00'); + expect(json['agreement_accepted'], isTrue); + expect(json['frdo_agreement_accepted'], isTrue); + }); + + test('hasValidPassportData validates required individualPerson fields', () { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + expect(valid.hasValidPassportData, isTrue); + expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); + expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); }); }); } diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index 7dd05d98..f11423e4 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -122,6 +122,47 @@ void main() { expect(model.canceledModeration!.status, ModerationStatus.canceled); }); + test('fromJson parses legal_entity_resource', () { + final json = { + 'rating_place': 5, + 'education_resource': {'request_state': 'approved'}, + 'cert_photos': [], + 'legal_entity_resource': { + 'id': 1, + 'type': 'individualPerson', + 'inn': '123456789012', + 'snils': '123-456-789 00', + 'state': 'created', + 'alfa_scoring_status': 'pending', + 'alfa_scoring_request_id': 'abc', + 'passport_series': '1234', + 'passport_number': '567890', + 'passport_issue_date': '2020-01-15T00:00:00.000000Z', + 'passport_issuer': 'ОВД', + 'passport_issuer_code': '772-053', + 'birth_date': '1990-05-20', + 'birth_place': 'Москва', + 'registration_address': 'Адрес 1', + 'residence_address': 'Адрес 2', + }, + }; + + final model = ConsultationRequestModel.fromJson(json); + + expect(model.legalEntity, isNotNull); + expect(model.legalEntity!.type, 'individualPerson'); + expect(model.legalEntity!.inn, '123456789012'); + expect(model.legalEntity!.passportSeries, '1234'); + expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); + expect(model.legalEntity!.alfaScoringStatus, 'pending'); + }); + + test('normalizeApiDate strips time from DateTime.toString format', () { + expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate(null), isNull); + }); + test('toJson serializes ConsultationRequestModel correctly', () { const educationModel = EducationModel(requestState: ModerationStatus.approved); const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index a721050b..9eeeb304 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,7 @@ void main() { late RegisterConsultantCubit cubit; late ConsultationRequestModel requestModel; late List certPhotosFromFixture; + late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; setUp(() { @@ -32,7 +34,11 @@ void main() { certPhotosFromFixture = FixtureReader.readAsList( 'consultant/consultant_cert_photos.json', ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); + sbpBanksFromFixture = FixtureReader.readAsList( + 'consultant/sbp_banks.json', + ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); + when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -61,11 +67,19 @@ void main() { expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); + expect(state.consultant.legalType, 'individualPerson'); + expect(state.consultant.inn, '123456789012'); + expect(state.consultant.passportSeries, '1234'); + expect(state.consultant.passportIssueDate, '2020-01-15'); + expect(state.consultant.birthPlace, 'г. Москва'); // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect(state.sbpBanks, hasLength(2)); + expect(state.sbpBanks.first.bankName, 'Сбербанк'); verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); }); test('disableLoading stops spinner and loads cert photos', () async { @@ -76,7 +90,28 @@ void main() { expect(cubit.state.loading, isFalse); // disableLoading now keeps all photos from repository. expect(cubit.state.certPhotos, hasLength(3)); + expect(cubit.state.sbpBanks, hasLength(2)); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); + }); + + test('searchSbpBanks filters banks by name', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final results = cubit.searchSbpBanks('сбер'); + + expect(results, hasLength(1)); + expect(results.first.bankMemberId, '100000000111'); + }); + + test('findSbpBankById returns matching bank', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final bank = cubit.findSbpBankById('100000000004'); + + expect(bank?.bankName, 'Т-Банк'); }); test('updateConsultantProfileModel replaces consultant data and offer flag', () { @@ -95,12 +130,24 @@ void main() { ), ).thenAnswer((_) async {}); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: false, + ), + ).captured.single as ConsultantProfileModel; + + expect(captured.legalType, 'individualPerson'); + expect(captured.agreementAccepted, isTrue); + expect(captured.frdoAgreementAccepted, isTrue); + expect(captured.inn, isNotNull); }); - test('registerConsultant sends update=true after getMyRequestStatus call', () async { + test('registerConsultant sends legal payload with update=true', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when( @@ -113,7 +160,38 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: true, + ), + ).captured.single as ConsultantProfileModel; + + final json = captured.toJson(); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['passport_series'], '1234'); + expect(json.containsKey('agreement_accepted'), isFalse); + expect(json.containsKey('frdo_agreement_accepted'), isFalse); + }); + + test('registerConsultant blocks submit without passport data', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.registerConsultant(); + + expect(cubit.state.error, 'Заполните паспортные данные'); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); }); test('removeCertPhoto marks cert as deleted locally', () async { diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index cff449e8..c95b21c1 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -36,7 +36,7 @@ void main() { ); } - testWidgets('renders last step screen', (tester) async { + testWidgets('renders last step screen with both agreements for new registration', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, @@ -49,13 +49,30 @@ void main() { when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); - when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null); await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pump(); expect(find.byType(LastStepScreen), findsOneWidget); expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }); + + testWidgets('hides FRDO checkbox in update mode', (tester) async { + final cubit = MockRegisterConsultantCubit(); + const consultant = ConsultantProfileModel(); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); + await tester.pump(); + + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.textContaining('ФРДО'), findsNothing); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart new file mode 100644 index 00000000..3344e719 --- /dev/null +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -0,0 +1,59 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} + +void main() { + group('PassportDataScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + home: Scaffold( + body: BlocProvider.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); + }); + }); +} diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index df170daa..d4818dd8 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -1,24 +1,54 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} void main() { group('RegistrationAsScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + Widget wrap(Widget child) { return MaterialApp( theme: ThemeHelper().themeData(), - home: Scaffold(body: child), + home: Scaffold( + body: BlocProvider.value(value: cubit, child: child), + ), ); } testWidgets('renders registration as screen', (tester) async { await tester.pumpWidget(wrap(const RegistrationAsScreen())); await tester.pump(); + await tester.pump(); expect(find.byType(RegistrationAsScreen), findsOneWidget); expect(find.text('Регистрация в качестве'), findsOneWidget); expect(find.text('Физического лица'), findsOneWidget); + expect(cubit.state.consultant.legalType, 'individualPerson'); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 34c2db42..0f742c33 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -86,7 +86,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('шаг 1 из 6'), findsOneWidget); + expect(find.text('шаг 1 из 7'), findsOneWidget); }); testWidgets('displays progress bar', (tester) async { diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index c695cc5b..468e638c 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -88,6 +89,18 @@ void main() { verify(() => mockService.getConsultantCertPhotos()).called(1); }); + test('getSbpBanks returns banks from service', () async { + final banks = FixtureReader.readAsList('consultant/sbp_banks.json') + .map((item) => SbpBankModel.fromJson(item as Map)) + .toList(); + when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks); + + final result = await repository.getSbpBanks(); + + expect(result, banks); + verify(() => mockService.getSbpBanks()).called(1); + }); + test('storeConsultantCertPhotos returns empty list when no files provided', () async { final result = await repository.storeConsultantCertPhotos(files: []); diff --git a/test/fixtures/consultant/consultant_profile_request.json b/test/fixtures/consultant/consultant_profile_request.json index fc05c731..270d4a20 100644 --- a/test/fixtures/consultant/consultant_profile_request.json +++ b/test/fixtures/consultant/consultant_profile_request.json @@ -20,6 +20,26 @@ "title": "Психология питания", "date": "2023-06-05" } - ] + ], + "legal_type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567", + "agreement_accepted": true, + "frdo_agreement_accepted": true } - diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index f4065e15..bf7ae187 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -23,6 +23,31 @@ "personal_site_link": "https://consultant.eatvio.app", "request_state": "approved" }, + "legal_entity_resource": { + "id": 1, + "type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "state": "created", + "alfa_scoring_status": "pending", + "alfa_scoring_request_id": "abc-123", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567" + }, "cert_photos": [ { "url": "https://cdn.eatvio.app/certificates/visible.jpg" @@ -40,4 +65,3 @@ ] } } - diff --git a/test/fixtures/consultant/sbp_banks.json b/test/fixtures/consultant/sbp_banks.json new file mode 100644 index 00000000..b725de52 --- /dev/null +++ b/test/fixtures/consultant/sbp_banks.json @@ -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" + } +] From a6fe6afb80430e1d55932a6528b17a215f62a5dd Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:17:01 +0300 Subject: [PATCH 08/12] feat(personalized_feed): fixed photos rendering and fixed profile posts rendering --- .../presentation/pages/blog_post_screen.dart | 6 +- .../widgets/blog_post_media_content.dart | 81 +++- .../widgets/blog_post_widget.dart | 3 +- .../feed_video_player_widget.dart | 5 +- .../feed_video_preview_placeholder.dart | 5 +- .../widgets/profile_blog_post_widget.dart | 392 +++++++++--------- pubspec.yaml | 22 +- .../models/feed_blog_post_model_test.dart | 13 + .../profile_cubit/profile_cubit_test.dart | 15 +- .../profile_blog_post_widget_smoke_test.dart | 251 +++++------ .../services/profile_service_test.dart | 18 +- test/fixtures/feed_fixtures.dart | 6 + .../blog_posts_by_author_response.json | 80 ++++ 13 files changed, 491 insertions(+), 406 deletions(-) create mode 100644 test/fixtures/profile/blog_posts_by_author_response.json diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index d5960c68..9067738c 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -257,13 +257,9 @@ class _BlogPostScreenState extends State { return const SizedBox.shrink(); } final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); - final galleryUrls = post.images - .map((e) => PostFeedMapper.resolveMediaUrl(e.url)) - .where((e) => e.isNotEmpty) - .toList(); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( - galleryUrls: galleryUrls, + galleryImages: post.images, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index e2141185..a0d8ddc8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider_plus/carousel_slider_plus.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; import 'package:flutter/material.dart'; @@ -9,7 +11,7 @@ import 'package:flutter/material.dart'; class BlogPostMediaContent extends StatefulWidget { const BlogPostMediaContent({ super.key, - required this.galleryUrls, + required this.galleryImages, required this.imageUrl, required this.videoEmbedUrl, required this.currentGalleryIndex, @@ -18,7 +20,7 @@ class BlogPostMediaContent extends StatefulWidget { required this.videoUrlThumb, }); - final List galleryUrls; + final List galleryImages; final String imageUrl; final String? videoEmbedUrl; final String? videoUrlThumb; @@ -32,16 +34,36 @@ class BlogPostMediaContent extends StatefulWidget { class _BlogPostMediaContentState extends State { static const _counterDisplayDuration = Duration(seconds: 1); - static const _carouselHeight = 260.0; + + /// Landscape / video slide aspect (width / height). + static const _horizontalAspectRatio = 16 / 9; + + /// Portrait slide aspect (width / height). + static const _verticalAspectRatio = 3 / 4; bool _isCounterVisible = false; Timer? _counterHideTimer; bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + List get _gallery { + return [ + for (final image in widget.galleryImages) + if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty) + FeedGalleryImage( + url: url, + orientation: image.orientation, + id: image.id, + position: image.position, + ), + ]; + } + + List get _galleryUrls => _gallery.map((e) => e.url).toList(); + int get _slideCount { - if (widget.galleryUrls.isNotEmpty) { - return widget.galleryUrls.length + (_hasVideo ? 1 : 0); + if (_gallery.isNotEmpty) { + return _gallery.length + (_hasVideo ? 1 : 0); } if (_hasVideo) { return 1; @@ -49,10 +71,24 @@ class _BlogPostMediaContentState extends State { return 0; } - bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length; + bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + /// Height is fixed for the whole post from the first gallery image (web-like). + String get _postOrientation { + if (_gallery.isNotEmpty) { + return _gallery.first.orientation; + } + return 'horizontal'; + } + + double _heightForOrientation(String orientation, double width) { + final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; + final maxHeight = MediaQuery.sizeOf(context).height * 0.7; + return (width / aspectRatio).clamp(180.0, maxHeight); + } + @override void dispose() { _counterHideTimer?.cancel(); @@ -90,13 +126,13 @@ class _BlogPostMediaContentState extends State { return false; } - Widget _imageSlide(String itemUrl, int itemIndex) { + Widget _imageSlide(String itemUrl, int itemIndex, double height) { return GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex), child: CachedNetworkImage( fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - height: _carouselHeight, + width: MediaQuery.sizeOf(context).width, + height: height, imageUrl: itemUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), @@ -107,7 +143,7 @@ class _BlogPostMediaContentState extends State { return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); } - Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) { + Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { return Stack( children: [ NotificationListener( @@ -117,7 +153,7 @@ class _BlogPostMediaContentState extends State { itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), options: CarouselOptions( viewportFraction: 1, - height: _carouselHeight, + height: height, enableInfiniteScroll: false, onPageChanged: (index, _) => widget.onGalleryChanged(index), ), @@ -141,13 +177,18 @@ class _BlogPostMediaContentState extends State { @override Widget build(BuildContext context) { - if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) { + final gallery = _gallery; + final width = MediaQuery.sizeOf(context).width; + final mediaHeight = _heightForOrientation(_postOrientation, width); + + if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) { final itemCount = _slideCount; return _carousel( itemCount: itemCount, + height: mediaHeight, itemBuilder: (context, itemIndex) { - if (itemIndex < widget.galleryUrls.length) { - return _imageSlide(widget.galleryUrls[itemIndex], itemIndex); + if (itemIndex < gallery.length) { + return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight); } if (_hasVideo) { return _videoSlide(); @@ -157,14 +198,14 @@ class _BlogPostMediaContentState extends State { ); } - if (widget.galleryUrls.length == 1) { - return _imageSlide(widget.galleryUrls.first, 0); + if (gallery.length == 1) { + return _imageSlide(gallery.first.url, 0, mediaHeight); } if (_hasVideo) { return SizedBox( - height: _carouselHeight, - width: MediaQuery.of(context).size.width, + height: mediaHeight, + width: width, child: _videoSlide(), ); } @@ -176,7 +217,7 @@ class _BlogPostMediaContentState extends State { tag: widget.imageUrl, child: CachedNetworkImage( fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, + width: width, imageUrl: widget.imageUrl, errorWidget: (context, str, object) => const SizedBox.shrink(), ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 5c349db8..3977d462 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -78,7 +78,6 @@ class _BlogPostWidgetState extends State { @override Widget build(BuildContext context) { final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); - final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList(); final embedUrl = post.videoEmbedUrl?.trim(); return InkWell( @@ -95,7 +94,7 @@ class _BlogPostWidgetState extends State { _BlogPostHeader(post: post), _BlogPostTextContent(post: post), BlogPostMediaContent( - galleryUrls: galleryUrls, + galleryImages: post.images, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoUrlThumb: post.videoUrlThumb, diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart index 6778e94b..2f850923 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart @@ -169,9 +169,8 @@ class _FeedVideoPlayerState extends State { @override Widget build(BuildContext context) { - final player = SizedBox( - height: 220, - width: MediaQuery.sizeOf(context).width, + final player = ColoredBox( + color: Colors.black, child: Stack( fit: StackFit.expand, children: [ diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart index 86036dc6..1721e45b 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart @@ -22,10 +22,13 @@ class FeedVideoPreviewPlaceholder extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ + const ColoredBox(color: Colors.black), if (previewUrl != null && previewUrl!.isNotEmpty) CachedNetworkImage( imageUrl: previewUrl!, - fit: BoxFit.cover, + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)), ) else diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index e0e5cd01..9ae218cc 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -1,19 +1,21 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; @@ -31,221 +33,213 @@ class ProfileBlogPostWidget extends StatefulWidget { class _ProfileBlogPostWidgetState extends State { late bool isLiked; late int totalLikes; + int currentGalleryIndex = 0; + + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } @override Widget build(BuildContext context) { return BlocBuilder( bloc: widget.profileCubit, builder: (BuildContext context, ProfileState state) { - if (state is ProfileStateLoaded) { - isLiked = state.blogPosts[widget.index].wasLikedByMe; - totalLikes = state.blogPosts[widget.index].totalLikes; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + if (state is! ProfileStateLoaded) { + return const SizedBox.shrink(); + } + + final FeedBlogPostModel post = state.blogPosts[widget.index]; + isLiked = post.wasLikedByMe; + totalLikes = post.totalLikes; + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final embedUrl = post.videoEmbedUrl?.trim(); + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + child: Ink( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Colors.black12, + foregroundImage: NetworkImage( + post.author.authorProfileIcon.contains('https') + ? post.author.authorProfileIcon + : sl().baseUrl + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.author.authorName, + style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', + style: const TextStyle(height: 1, color: Colors.black45), + ), + ], + ), + const Spacer(), + CustomPopup( + isAuthor: post.author.authorIsMe, + complainedContentType: ComplainedContent.posts, + path: 'blog_post/${post.id}', + id: post.id, + feedBlogPostModel: post, + userSlug: post.author.authorSlug, + ), + ], + ), + ), + if (post.title.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - state.blogPosts[widget.index].author.authorProfileIcon.contains('https') - ? state.blogPosts[widget.index].author.authorProfileIcon - : sl().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - state.blogPosts[widget.index].author.authorName, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - '${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45), - ), - ], - ), - const Spacer(), - CustomPopup( - isAuthor: state.blogPosts[widget.index].author.authorIsMe, - complainedContentType: ComplainedContent.posts, - path: 'blog_post/${(state.blogPosts[widget.index].id)}', - id: state.blogPosts[widget.index].id, - feedBlogPostModel: state.blogPosts[widget.index], - userSlug: state.blogPosts[widget.index].author.authorSlug, - ), - ], + child: Text( + post.title, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), ), ), - if (state.blogPosts[widget.index].title.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.blogPosts[widget.index].title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), - ), + ], + if (post.title.isEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.description.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: MarkdownHtmlWidget( + markdownText: _truncateDescription(post.description), + fontSize: 16, + textColor: Colors.black87, ), - ], - if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8), - if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8), - if (state.blogPosts[widget.index].description.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Builder( - builder: (context) { - String description = state.blogPosts[widget.index].description; - final isLong = description.length > 200; - - if (isLong) { - // Умная обрезка: обрезаем markdown текст, не ломая синтаксис - int cutPoint = 200; - - // Проверяем, есть ли незакрытые markdown теги - String preview = description.substring(0, cutPoint); - - // Считаем открывающие и закрывающие теги - int boldOpen = (preview.split('**').length - 1) ~/ 2; - int boldClose = preview.split('**').length - 1 - boldOpen * 2; - bool hasUnclosedBold = boldOpen > boldClose; - - // Если есть незакрытый bold, ищем закрытие - if (hasUnclosedBold) { - int searchEnd = (cutPoint + 100).clamp(0, description.length); - int boldClosePos = description.indexOf('**', cutPoint); - if (boldClosePos != -1 && boldClosePos < searchEnd) { - cutPoint = boldClosePos + 2; - } else { - // Если не нашли, обрезаем до последнего пробела - cutPoint = description.lastIndexOf(' ', 200); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - } else { - // Обрезаем до последнего пробела для красоты - cutPoint = description.lastIndexOf(' ', cutPoint); - if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; - } - - description = description.substring(0, cutPoint); - } - - return MarkdownHtmlWidget( - markdownText: description, - fontSize: 16, - textColor: Colors.black87, + ), + if (post.description.length > 200) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Показать ещё', + style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + BlogPostMediaContent( + galleryImages: post.images, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + FeedIconButton( + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + text: totalLikes.toString(), + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + onTap: () async { + setState(() { + isLiked = !isLiked; + isLiked ? totalLikes++ : totalLikes--; + }); + isLiked ? await context.read().likePost(post.id) : await context.read().unlikedPost(post.id); + widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); + }, + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset( + 'assets/icons/comment.svg', + height: 20, + width: 20, + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), + text: post.totalComments.toString(), + onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + ), + const SizedBox(width: 8), + FeedIconButton( + svgIcon: SvgPicture.asset( + height: 20, + width: 20, + 'assets/icons/share.svg', + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams( + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), ); }, ), - ), - if (state.blogPosts[widget.index].description.length > 200) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + const Spacer(), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + const SizedBox(width: 4), + Text( + post.totalViews.toString(), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), - ), - if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8), - GestureDetector( - onTap: () { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: ['${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}', - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), + ], ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - onTap: () async { - setState(() { - isLiked = !isLiked; - isLiked ? totalLikes++ : totalLikes--; - }); - isLiked - ? await context.read().likePost(state.blogPosts[widget.index].id) - : await context.read().unlikedPost(state.blogPosts[widget.index].id); - widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true); - }, - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - 'assets/icons/comment.svg', - height: 20, - width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - text: state.blogPosts[widget.index].totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - height: 20, - width: 20, - 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - onTap: () { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); - }, - ), - const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), - const SizedBox(width: 4), - Text( - state.blogPosts[widget.index].totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), - ), - ], - ), - ), - const SizedBox(height: 8), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ); - } - return const SizedBox.shrink(); + ), + ); }, ); } } + +String _truncateDescription(String description) { + final isLong = description.length > 200; + if (!isLong) return description; + + var cutPoint = 200; + final preview = description.substring(0, cutPoint); + final boldOpen = (preview.split('**').length - 1) ~/ 2; + final boldClose = preview.split('**').length - 1 - boldOpen * 2; + final hasUnclosedBold = boldOpen > boldClose; + + if (hasUnclosedBold) { + final searchEnd = (cutPoint + 100).clamp(0, description.length); + final boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + return description.substring(0, cutPoint); +} diff --git a/pubspec.yaml b/pubspec.yaml index 01ec1618..ae4effdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.35+124 +version: 1.3.36+125 publish_to: 'none' environment: @@ -171,14 +171,14 @@ flutter_intl: flutter_native_splash: android: true ios: true - color: "#FFFFFF" - image_android: "assets/splash/splash.png" - image_dark_android: "assets/splash/splash.png" - image_ios: "assets/splash/splash.png" - image_dark_ios: "assets/splash/splash.png" - branding_android: "assets/splash/branding.png" - branding_dark_android: "assets/splash/branding.png" + color: '#FFFFFF' + image_android: 'assets/splash/splash.png' + image_dark_android: 'assets/splash/splash.png' + image_ios: 'assets/splash/splash.png' + image_dark_ios: 'assets/splash/splash.png' + branding_android: 'assets/splash/branding.png' + branding_dark_android: 'assets/splash/branding.png' android_12: - image: "assets/splash/splash.png" - image_dark: "assets/splash/splash.png" - branding: "assets/splash/branding.png" + image: 'assets/splash/splash.png' + image_dark: 'assets/splash/splash.png' + branding: 'assets/splash/branding.png' diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 55fbe181..140266ba 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -150,6 +150,19 @@ void main() { expect(model.videoUrlThumb, 'media/1/thumb.jpg'); }); + test('parses profile search_by_author fixture with gallery and video', () { + final posts = FeedFixtures.blogPostsByAuthor(); + + expect(posts, hasLength(2)); + expect(posts.first.images, hasLength(2)); + expect(posts.first.images.first.orientation, 'horizontal'); + expect(posts.first.images[1].orientation, 'vertical'); + expect(posts.first.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(posts.first.videoUrlThumb, 'media/1/thumb.jpg'); + expect(posts[1].images, hasLength(2)); + expect(posts[1].videoEmbedUrl, isNull); + }); + test('creates FeedBlogPostModel with different states', () { const author = Author( authorSlug: 'author', diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9b109014..9e23ea16 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../../fixtures/feed_fixtures.dart'; import 'profile_cubit_test.mocks.dart'; @GenerateNiceMocks([ @@ -267,20 +268,10 @@ void main() { authorPlaceInRating: null, ); - final blogPost = FeedBlogPostModel( - id: 1, - state: 'published', - title: 'Test Post', - description: 'Test Content', - image: 'https://example.com/image.jpg', + final blogPost = FeedFixtures.feedBlogPostGalleryAndVideo().copyWith( author: author, - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 10, - totalComments: 5, - totalViews: 100, wasLikedByMe: false, + totalLikes: 10, ); const profileDetails = ProfileDetailsModel( diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 8f6fe8ed..44ab062a 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -5,20 +5,20 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; -import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:eatvio_app/generated/l10n.dart'; +import '../../../../fixtures/feed_fixtures.dart'; import 'profile_blog_post_widget_smoke_test.mocks.dart'; class _FakeHttpClient implements HttpClient { @@ -35,7 +35,6 @@ class _FakeHttpClient implements HttpClient { @override Future getUrl(Uri url) async => _FakeHttpClientRequest(); - @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -51,73 +50,10 @@ class _FakeHttpClientRequest implements HttpClientRequest { class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { // 1x1 transparent PNG. static final List _png = [ - 0x89, - 0x50, - 0x4E, - 0x47, - 0x0D, - 0x0A, - 0x1A, - 0x0A, - 0x00, - 0x00, - 0x00, - 0x0D, - 0x49, - 0x48, - 0x44, - 0x52, - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, - 0x08, - 0x06, - 0x00, - 0x00, - 0x00, - 0x1F, - 0x15, - 0xC4, - 0x89, - 0x00, - 0x00, - 0x00, - 0x0A, - 0x49, - 0x44, - 0x41, - 0x54, - 0x78, - 0x9C, - 0x63, - 0x00, - 0x01, - 0x00, - 0x00, - 0x05, - 0x00, - 0x01, - 0x0D, - 0x0A, - 0x2D, - 0xB4, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4E, - 0x44, - 0xAE, - 0x42, - 0x60, - 0x82, + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, ]; @override @@ -150,81 +86,53 @@ void main() { sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); }); - testWidgets('renders blog post card', (tester) async { - final post = FeedBlogPostModel( - id: 1, - state: 'published', - image: 'test.png', - description: 'Hello', - author: const Author( - authorSlug: 'author', - authorName: 'Author', - authorProfileIcon: 'https://example.com/icon.png', - authorIsMe: false, - isEntitySubscribedByMe: false, - ), - videoUrl: '', - createdAt: DateTime(2025, 1, 1), - updatedAt: DateTime(2025, 1, 1), - title: 'Title', - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - ); + const profile = ProfileModel( + id: 1, + name: 'User', + email: 'u@e.com', + phoneConfirmationState: 'confirmed', + slug: 'user', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); - const profile = ProfileModel( - id: 1, - name: 'User', - email: 'u@e.com', - phoneConfirmationState: 'confirmed', - slug: 'user', - isPublicProfile: true, - isBlockedByMe: false, - isBlockedByAuthor: false, - isBlockedByUser: false, - isEntitySubscribedByMe: false, - profilePhotoUrl: '', - roles: [], - badges: [], - subscriptions: [], - subscribers: [], - ); - - const details = ProfileDetailsModel( - firstName: 'Test', - lastName: 'User', - birthdate: '1990-01-01', - gender: 'male', - country: 'Russia', - city: 'Moscow', - lang: 'ru', - weight: 70.0, - height: 175, - chestCircumference: 100, - waistCircumference: 80, - hipCircumference: 90, - bloodPressureUp: 120, - bloodPressureDown: 80, - hasHypo: false, - hasHyper: false, - aboutAlergy: '', - aboutTakingPils: '', - aboutSportHobbies: '', - favoriteFoods: '', - unfavoriteFoods: '', - additionalInfo: '', - isMoodFillable: true, - isBedtimeFillable: true, - activityCoefficient: 1.5, - ); - - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); - final cubit = MockProfileCubit(); - when(cubit.state).thenReturn(loadedState); - when(cubit.stream).thenAnswer((_) => const Stream.empty()); - when(cubit.close()).thenAnswer((_) async => Future.value()); + const details = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.5, + ); + Future pumpPost(WidgetTester tester, ProfileCubit cubit) async { await HttpOverrides.runZoned(() async { await tester.pumpWidget( MaterialApp( @@ -236,15 +144,60 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('ru')], - home: Scaffold(body: ProfileBlogPostWidget(index: 0, profileCubit: cubit)), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(size: Size(800, 1600)), + child: SingleChildScrollView( + child: ProfileBlogPostWidget(index: 0, profileCubit: cubit), + ), + ), + ), ), ); await tester.pumpAndSettle(const Duration(milliseconds: 200)); - }, createHttpClient: (context) => _FakeHttpClient()); + }, createHttpClient: (_) => _FakeHttpClient()); + } - expect(find.text('Регистрация'), findsNothing); - expect(find.text('Title'), findsOneWidget); - expect(find.text('Author'), findsOneWidget); + testWidgets('renders gallery and video media content', (tester) async { + final post = FeedFixtures.feedBlogPostGalleryAndVideo(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.textContaining('Галерея и видео'), findsWidgets); + }); + + testWidgets('renders gallery-only media content', (tester) async { + final post = FeedFixtures.feedBlogPostWithGallery(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея'), findsOneWidget); + }); + + testWidgets('renders video-only media content', (tester) async { + final post = FeedFixtures.feedBlogPostVideoOnly(); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); }); }); } diff --git a/test/features/profile/services/profile_service_test.dart b/test/features/profile/services/profile_service_test.dart index 5c773bc3..9d02811a 100644 --- a/test/features/profile/services/profile_service_test.dart +++ b/test/features/profile/services/profile_service_test.dart @@ -3,9 +3,11 @@ import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/weight_model.dart'; import 'package:eatvio_app/features/profile/services/profile_service.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/feed_fixtures.dart'; import 'profile_service_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -72,9 +74,17 @@ void main() { expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA()); }); - test('getBlogPostByAuthorSlug', () async { - when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []); - expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA>()); + test('getBlogPostByAuthorSlug returns posts with gallery and video fields', () async { + final posts = FeedFixtures.blogPostsByAuthor(); + when(mockProfileService.getBlogPostByAuthorSlug(slug: 'slug')).thenAnswer((_) async => posts); + + final result = await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'); + + expect(result, isA>()); + expect(result, hasLength(2)); + expect(result.first.images, isNotEmpty); + expect(result.first.videoEmbedUrl, isNotNull); + expect(result.first.videoUrlThumb, isNotNull); }); test('getWeight', () async { diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 0beb1624..e2fa950a 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -72,4 +72,10 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostGalleryAndVideo() { return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); } + + static List blogPostsByAuthor() { + return FixtureReader.readAsList('profile/blog_posts_by_author_response.json') + .map((item) => FeedBlogPostModel.fromJson(item as Map)) + .toList(); + } } diff --git a/test/fixtures/profile/blog_posts_by_author_response.json b/test/fixtures/profile/blog_posts_by_author_response.json new file mode 100644 index 00000000..a97d2e8d --- /dev/null +++ b/test/fixtures/profile/blog_posts_by_author_response.json @@ -0,0 +1,80 @@ +[ + { + "id": 1, + "state": "active", + "image": "media/1/cover.jpg", + "images": [ + { + "id": 10, + "url": "media/1/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 11, + "url": "media/1/b.jpg", + "position": 2, + "orientation": "vertical" + } + ], + "description": "Описание поста профиля", + "author": { + "author_slug": "test-user", + "author_name": "Test User", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/1/thumb.jpg", + "created_at": "2024-01-15T12:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "title": "Пост профиля", + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "was_liked_by_me": false, + "is_favorite": false + }, + { + "id": 2, + "state": "active", + "image": "media/2/a.jpg", + "images": [ + { + "id": 20, + "url": "media/2/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 21, + "url": "media/2/b.jpg", + "position": 2, + "orientation": "horizontal" + } + ], + "description": "Галерея без видео", + "author": { + "author_slug": "test-user", + "author_name": "Test User", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "", + "video_url_embed": null, + "video_url_thumb": null, + "created_at": "2024-01-16T12:00:00Z", + "updated_at": "2024-01-16T12:00:00Z", + "title": "Галерея", + "total_likes": 3, + "total_views": 40, + "total_comments": 1, + "was_liked_by_me": false, + "is_favorite": false + } +] From 2d6b042d45aea06b6f0ad548a0fb87ce33095560 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 28 Jul 2026 12:00:03 +0300 Subject: [PATCH 09/12] fix: removed unused import --- .../presentation/bloc/profile_cubit/profile_cubit_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9e23ea16..c61bd465 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -1,7 +1,6 @@ import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; From 8c5fc8d82795ce64dd616af6c083aee49d80261f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 29 Jul 2026 14:08:13 +0300 Subject: [PATCH 10/12] feat(consultant_scoring): fixed registration update flow --- .../register_consultant_cubit.dart | 6 ++-- .../presentation/pages/last_step_screen.dart | 29 +++++++++++++++++++ .../pages/passport_data_screen.dart | 2 +- .../stepper_become_consultant_screen.dart | 27 +++++++++-------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 9cae8fa5..d82ff2bb 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -107,7 +107,7 @@ class RegisterConsultantCubit extends Cubit { birthPlace: _emptyToNull(legal?.birthPlace), registrationAddress: _emptyToNull(legal?.registrationAddress), residenceAddress: _emptyToNull(legal?.residenceAddress), - citizenship: _emptyToNull(legal?.citizenship) ?? 'RU', + citizenship: _emptyToNull(legal?.citizenship), taxResidentUs: legal?.taxResidentUs ?? false, foreignTaxResident: legal?.foreignTaxResident ?? false, sbpPhone: _emptyToNull(legal?.sbpPhone), @@ -149,7 +149,7 @@ class RegisterConsultantCubit extends Cubit { error: null, consultant: state.consultant.copyWith( legalType: individualPersonLegalType, - citizenship: state.consultant.citizenship ?? 'RU', + citizenship: state.consultant.citizenship, taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, ), @@ -198,7 +198,7 @@ class RegisterConsultantCubit extends Cubit { ConsultantProfileModel buildSubmitPayload() { final withLegalType = state.consultant.copyWith( legalType: individualPersonLegalType, - citizenship: state.consultant.citizenship ?? 'RU', + citizenship: state.consultant.citizenship, taxResidentUs: state.consultant.taxResidentUs ?? false, foreignTaxResident: state.consultant.foreignTaxResident ?? false, certificates: state.consultant.certificates diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 7fd501a6..afdcbf83 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -48,6 +48,35 @@ class _LastStepScreenState extends State { padding: EdgeInsets.symmetric(horizontal: 16), child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), ), + if (state.requestModel?.canceledModeration != null) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0x33FFCC00), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.requestModel!.canceledModeration!.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), + ), + if (state.requestModel!.canceledModeration!.additional != null) ...[ + const SizedBox(height: 8), + for (final item in state.requestModel!.canceledModeration!.additional!) ...[ + Text('• $item', style: const TextStyle(fontSize: 14, height: 1.3)), + ], + ], + ], + ), + ), + ), + ], const SizedBox(height: 16), CheckboxListTile( value: state.acceptOffer, diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index f3eb774b..814f2139 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -101,7 +101,7 @@ class _PassportDataScreenState extends State { text: consultant.birthPlace ?? '', ); citizenshipController = TextEditingController( - text: consultant.citizenship ?? 'RU', + text: consultant.citizenship, ); registrationAddressController = TextEditingController( text: consultant.registrationAddress ?? '', diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 69f88361..73359e25 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -123,20 +123,23 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), + title: Text(canceledModeration.title), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), - ], + if (canceledModeration.additional != null) + for (final item in canceledModeration.additional!) ...[ + Text('• $item'), + ], ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], @@ -158,7 +161,7 @@ class _StepperBecomeConsultantScreenState extends State Date: Thu, 30 Jul 2026 17:41:30 +0300 Subject: [PATCH 11/12] feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow --- lib/core/routing/other_routes.dart | 17 +- .../enums/alfa_scoring_status.dart | 51 ++ .../models/consultant_cert_photo_model.dart | 3 +- .../models/consultant_profile_model.dart | 54 +- .../models/consultation_request_model.dart | 37 +- .../models/sbp_bank_model.dart | 3 +- .../register_consultant_cubit.dart | 369 ++++++++++-- .../register_consultant_state.dart | 11 +- .../pages/additional_settings_screen.dart | 27 +- .../pages/become_consultant_screen.dart | 26 +- .../certificates_and_diplomas_screen.dart | 67 ++- .../presentation/pages/last_step_screen.dart | 82 ++- .../pages/passport_data_screen.dart | 540 ++++++++++-------- .../pages/photo_or_scans_screen.dart | 88 ++- .../pages/registration_as_screen.dart | 62 +- .../pages/social_networks_screen.dart | 111 ++-- .../stepper_become_consultant_screen.dart | 310 +++++++--- .../widgets/passport_field_with_error.dart | 28 + .../widgets/passport_section_title.dart | 34 ++ .../widgets/scoring_status_widget.dart | 218 +++++++ .../repositories/consultant_repository.dart | 39 +- .../services/consultant_service.dart | 32 +- .../utils/laravel_validation_errors.dart | 167 ++++++ .../presentation/pages/settings_screen.dart | 5 +- .../entities/diploma_entity_test.dart | 1 - .../consultant_cert_photo_model_test.dart | 1 - .../models/consultant_profile_model_test.dart | 10 +- .../consultation_request_model_test.dart | 41 +- .../register_consultant_state_test.dart | 21 +- .../bloc/register_consultant_cubit_test.dart | 493 +++++++++++++--- .../additional_settings_screen_test.dart | 19 +- ...icates_and_diplomas_screen_smoke_test.dart | 49 +- .../pages/last_step_screen_test.dart | 74 ++- .../pages/passport_data_screen_test.dart | 9 +- .../photo_or_scans_screen_smoke_test.dart | 50 +- .../pages/registration_as_screen_test.dart | 9 +- .../social_networks_screen_smoke_test.dart | 54 +- ...r_become_consultant_screen_smoke_test.dart | 100 +++- .../consultant_repository_test.dart | 181 ++++-- .../services/consultant_service_test.dart | 62 +- .../utils/laravel_validation_errors_test.dart | 94 +++ .../consultant/consultation_request.json | 2 +- 42 files changed, 2894 insertions(+), 757 deletions(-) create mode 100644 lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart create mode 100644 lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart create mode 100644 lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart create mode 100644 test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 91b6fab3..9c1ed729 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -258,7 +258,15 @@ class OtherRoutes { ), // Consultant routes - GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), + GoRoute( + path: RouteConstants.becomeConsultantScreen, + name: 'becomeConsultantScreen', + builder: (context, state) { + final data = state.extra as Map?; + final slug = data?['slug'] as String?; + return BecomeConsultantScreen(slug: slug); + }, + ), GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', @@ -266,7 +274,12 @@ class OtherRoutes { final data = state.extra as Map; final slug = data['slug'] as String?; final isCanceled = data['isCanceled'] as bool?; - return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled); + final isNewRegistration = data['isNewRegistration'] as bool? ?? false; + return StepperBecomeConsultantScreen( + slug: slug, + isCanceled: isCanceled, + isNewRegistration: isNewRegistration, + ); }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), diff --git a/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart new file mode 100644 index 00000000..60d5b86c --- /dev/null +++ b/lib/features/consultant/become_consultant/enums/alfa_scoring_status.dart @@ -0,0 +1,51 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +enum AlfaScoringStatus { + @JsonValue('none') + none, + @JsonValue('beneficiary_creating') + beneficiaryCreating, + @JsonValue('beneficiary_failed') + beneficiaryFailed, + @JsonValue('scoring_pending') + scoringPending, + @JsonValue('scoring_complete') + scoringComplete, + @JsonValue('scoring_failed') + scoringFailed; + + static AlfaScoringStatus fromString(String? value) { + switch (value) { + case 'beneficiary_creating': + return AlfaScoringStatus.beneficiaryCreating; + case 'beneficiary_failed': + return AlfaScoringStatus.beneficiaryFailed; + case 'scoring_pending': + return AlfaScoringStatus.scoringPending; + case 'scoring_complete': + return AlfaScoringStatus.scoringComplete; + case 'scoring_failed': + return AlfaScoringStatus.scoringFailed; + case 'none': + case null: + case '': + return AlfaScoringStatus.none; + default: + return AlfaScoringStatus.none; + } + } + + bool get isProcessing => + this == AlfaScoringStatus.beneficiaryCreating || + this == AlfaScoringStatus.scoringPending; + + bool get isFailed => + this == AlfaScoringStatus.beneficiaryFailed || + this == AlfaScoringStatus.scoringFailed; + + bool get canRetry => isFailed; + + bool get isComplete => this == AlfaScoringStatus.scoringComplete; + + bool get isTerminal => isComplete || isFailed; +} diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index aaec9b68..79735be5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { const ConsultantCertPhotoModel._(); - factory ConsultantCertPhotoModel.fromJson(Map json) => _$ConsultantCertPhotoModelFromJson(json); + factory ConsultantCertPhotoModel.fromJson(Map json) => + _$ConsultantCertPhotoModelFromJson(json); static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index dd9e01f5..5d454ff2 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -9,35 +9,50 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel { const ConsultantProfileModel._(); const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'has_medical_education', includeIfNull: false) + bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) + SocialLinksModel? socialLinks, @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'completed_courses', includeIfNull: false) + List? certificates, @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, @JsonKey(name: 'inn', includeIfNull: false) String? inn, @JsonKey(name: 'snils', includeIfNull: false) String? snils, - @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, - @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, - @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, - @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, - @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: '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: '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: '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, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) + String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) + bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) + bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; - factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + factory ConsultantProfileModel.fromJson(Map json) => + _$ConsultantProfileModelFromJson(json); bool get hasValidPassportData { final last = lastName ?? ''; @@ -79,10 +94,12 @@ abstract class SocialLinksModel with _$SocialLinksModel { @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) + String? personalSiteLink, }) = _SocialLinksModel; - factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); + factory SocialLinksModel.fromJson(Map json) => + _$SocialLinksModelFromJson(json); } @freezed @@ -92,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel { required String date, }) = _CertificatesModel; - factory CertificatesModel.fromJson(Map json) => _$CertificatesModelFromJson(json); + factory CertificatesModel.fromJson(Map json) => + _$CertificatesModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 051f517f..c15649fd 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -11,11 +12,13 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, - @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'canceled_moderation') + CanceledModerationModel? canceledModeration, @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; - factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); + factory ConsultationRequestModel.fromJson(Map json) => + _$ConsultationRequestModelFromJson(json); } @freezed @@ -28,7 +31,13 @@ abstract class LegalEntityModel with _$LegalEntityModel { String? inn, String? snils, String? state, - @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @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, @@ -49,7 +58,8 @@ abstract class LegalEntityModel with _$LegalEntityModel { @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, }) = _LegalEntityModel; - factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + factory LegalEntityModel.fromJson(Map json) => + _$LegalEntityModelFromJson(json); /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. static String? normalizeApiDate(String? value) { @@ -62,25 +72,21 @@ abstract class LegalEntityModel with _$LegalEntityModel { return value; } - String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + String? get normalizedPassportIssueDate => + normalizeApiDate(passportIssueDate); String? get normalizedBirthDate => normalizeApiDate(birthDate); } @freezed abstract class CertPhotosModel with _$CertPhotosModel { - const factory CertPhotosModel({ - required String url, - }) = _CertPhotosModel; + const factory CertPhotosModel({required String url}) = _CertPhotosModel; - factory CertPhotosModel.fromJson(Map json) => _$CertPhotosModelFromJson(json); + factory CertPhotosModel.fromJson(Map json) => + _$CertPhotosModelFromJson(json); } -enum ModerationStatus { - approved, - created, - canceled, -} +enum ModerationStatus { approved, created, canceled } @freezed abstract class CanceledModerationModel with _$CanceledModerationModel { @@ -90,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel { required List? additional, }) = _CanceledModerationModel; - factory CanceledModerationModel.fromJson(Map json) => _$CanceledModerationModelFromJson(json); + factory CanceledModerationModel.fromJson(Map json) => + _$CanceledModerationModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart index c7bf7976..1ffb0c5f 100644 --- a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -13,5 +13,6 @@ abstract class SbpBankModel with _$SbpBankModel { String? bic, }) = _SbpBankModel; - factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); + factory SbpBankModel.fromJson(Map json) => + _$SbpBankModelFromJson(json); } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index d82ff2bb..9f835e5c 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,30 +1,52 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:eatvio_app/core/error_reporting/report_from.dart'; class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; + final Duration scoringPollInterval; bool _update = false; + String? _userSlug; + Timer? _scoringPollTimer; static const String individualPersonLegalType = 'individualPerson'; - RegisterConsultantCubit({required this.consultantRepository}) - : super( - const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), - loading: true, - acceptOffer: false, - acceptFrdoAgreement: false, - isUpdating: false, - ), - ); + RegisterConsultantCubit({ + required this.consultantRepository, + this.scoringPollInterval = const Duration(seconds: 3), + }) : super( + const RegisterConsultantState( + consultant: ConsultantProfileModel( + socialLinks: SocialLinksModel(), + hasMedicalEducation: true, + legalType: individualPersonLegalType, + ), + loading: true, + acceptOffer: false, + acceptFrdoAgreement: false, + isUpdating: false, + ), + ); + + String? get userSlug => _userSlug; + + void setUserSlug(String? slug) { + if (slug != null && slug.isNotEmpty) { + _userSlug = slug; + } + } Future> _loadSbpBanks() async { try { @@ -45,7 +67,9 @@ class RegisterConsultantCubit extends Cubit { 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); + return name.contains(normalized) || + shortName.contains(normalized) || + bic.contains(normalized); }).toList(); } @@ -64,21 +88,29 @@ class RegisterConsultantCubit extends Cubit { Future getMyRequestStatus(String slug) async { try { _update = true; - final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); + _userSlug = slug; + final myRequestStatus = await consultantRepository.getMyRequestStatus( + slug, + ); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); - List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { - final course = courseMap.values.first; + List? certificates = myRequestStatus + .educationModel + .completedCourses + ?.map((courseMap) { + final course = courseMap.values.first; - return CertificatesModel(title: course.title, date: course.date); - }).toList(); + return CertificatesModel(title: course.title, date: course.date); + }) + .toList(); String? sanitizeLink(String? link) { return (link != null && link.isNotEmpty) ? link : null; } final legal = myRequestStatus.legalEntity; + final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none; emit( RegisterConsultantState( @@ -89,9 +121,12 @@ class RegisterConsultantCubit extends Cubit { tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), - personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + personalSiteLink: sanitizeLink( + myRequestStatus.educationModel.personalSiteLink, + ), ), - hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + hasMedicalEducation: + myRequestStatus.educationModel.hasMedicalEducation == 1, legalType: legal?.type ?? individualPersonLegalType, lastName: _emptyToNull(legal?.lastName), firstName: _emptyToNull(legal?.firstName), @@ -120,13 +155,30 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, isUpdating: _update, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + fieldErrors: const {}, error: null, ), ); + + if (scoringStatus.isProcessing) { + startScoringPoll(); + } } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -137,8 +189,9 @@ class RegisterConsultantCubit extends Cubit { return value.trim(); } - Future disableLoading() async { + Future disableLoading({String? slug}) async { try { + setUserSlug(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); final sbpBanks = await _loadSbpBanks(); emit( @@ -147,6 +200,8 @@ class RegisterConsultantCubit extends Cubit { certPhotos: certPhotos, sbpBanks: sbpBanks, error: null, + fieldErrors: const {}, + awaitingScoringOutcome: false, consultant: state.consultant.copyWith( legalType: individualPersonLegalType, citizenship: state.consultant.citizenship, @@ -158,7 +213,15 @@ class RegisterConsultantCubit extends Cubit { } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(loading: false, error: e.toString())); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + loading: false, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + awaitingScoringOutcome: false, + ), + ); } } @@ -168,11 +231,15 @@ class RegisterConsultantCubit extends Cubit { if (slug != null) { await getMyRequestStatus(slug); } else { - await disableLoading(); + await disableLoading(slug: _userSlug); } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + void updateConsultantProfileModel( + ConsultantProfileModel consultant, [ + bool? acceptOffer, + bool? acceptFrdoAgreement, + ]) { emit( state.copyWith( consultant: consultant, @@ -180,12 +247,19 @@ class RegisterConsultantCubit extends Cubit { acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, loading: false, error: null, + fieldErrors: const {}, ), ); } void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { - emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + emit( + state.copyWith( + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + error: null, + ), + ); } bool get hasAcceptedRequiredAgreements { @@ -202,7 +276,13 @@ class RegisterConsultantCubit extends Cubit { 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)) + ?.map( + (course) => CertificatesModel( + title: course.title, + date: + LegalEntityModel.normalizeApiDate(course.date) ?? course.date, + ), + ) .toList(), ); @@ -235,40 +315,241 @@ class RegisterConsultantCubit extends Cubit { ); } - return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); + return withLegalType.copyWith( + agreementAccepted: state.acceptOffer, + frdoAgreementAccepted: state.acceptFrdoAgreement, + ); } Future registerConsultant() async { - emit(state.copyWith(error: null)); + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } + + // Show scoring widget immediately while request / job are in progress. + emit( + state.copyWith( + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + awaitingScoringOutcome: true, + scoringRetryInProgress: false, + ), + ); + try { - if (!state.consultant.hasValidPassportData) { - emit(state.copyWith(error: 'Заполните паспортные данные')); - return; - } if (state.newAddedPhotos.isNotEmpty) { - await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + await consultantRepository.storeConsultantCertPhotos( + files: state.newAddedPhotos, + ); } if (state.deletedPhotoIds.isNotEmpty) { for (final int id in state.deletedPhotoIds) { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: _update, + ); + _update = true; + emit( + state.copyWith( + isUpdating: true, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + scoringRetryInProgress: false, + awaitingScoringOutcome: true, + newAddedPhotos: const [], + deletedPhotoIds: const [], + fieldErrors: const {}, + error: null, + ), + ); + startScoringPoll(); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); - emit(state.copyWith(error: e.toString())); + emit( + state.copyWith( + error: e.toString(), + scoringRetryInProgress: false, + awaitingScoringOutcome: false, + isScoringPolling: false, + alfaScoringStatus: AlfaScoringStatus.none, + ), + ); } } + Future retryScoring() async { + if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) { + return; + } + emit( + state.copyWith( + scoringRetryInProgress: true, + error: null, + fieldErrors: const {}, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + awaitingScoringOutcome: true, + ), + ); + _update = true; + try { + if (!state.consultant.hasValidPassportData) { + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: 'Заполните паспортные данные', + ), + ); + return; + } + await consultantRepository.registerConsultant( + consultant: buildSubmitPayload(), + update: true, + ); + startScoringPoll(); + emit( + state.copyWith( + scoringRetryInProgress: false, + isUpdating: true, + awaitingScoringOutcome: true, + ), + ); + } on DioException catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + final parsed = parseLaravelValidationErrors(e); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: parsed.summary, + fieldErrors: parsed.fieldErrors, + ), + ); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + emit( + state.copyWith( + scoringRetryInProgress: false, + isScoringPolling: false, + awaitingScoringOutcome: false, + alfaScoringStatus: AlfaScoringStatus.scoringFailed, + error: e.toString(), + ), + ); + } + } + + void startScoringPoll() { + final slug = _userSlug; + if (slug == null || slug.isEmpty) { + emit( + state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false), + ); + return; + } + _stopScoringPoll(); + emit(state.copyWith(isScoringPolling: true)); + _scoringPollTimer = Timer.periodic( + scoringPollInterval, + (_) => _pollScoringStatus(slug), + ); + unawaited(_pollScoringStatus(slug)); + } + + Future _pollScoringStatus(String slug) async { + if (isClosed) { + return; + } + try { + final status = await consultantRepository.getMyRequestStatus(slug); + if (isClosed) { + return; + } + final scoringStatus = + status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none; + // API stays on `none` until ProcessConsultantScoringJob starts + // (ScoringStarted → beneficiary_creating). Keep optimistic processing + // UI and continue polling until a real terminal/processing status arrives. + if (scoringStatus == AlfaScoringStatus.none) { + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating, + isScoringPolling: true, + ), + ); + return; + } + final isTerminal = scoringStatus.isTerminal; + emit( + state.copyWith( + requestModel: status, + alfaScoringStatus: scoringStatus, + isScoringPolling: scoringStatus.isProcessing, + // Clear session flag on terminal so success sheet can fire once + // (listenWhen: previous.awaiting && !current.awaiting && complete). + awaitingScoringOutcome: isTerminal + ? false + : state.awaitingScoringOutcome, + ), + ); + if (!scoringStatus.isProcessing) { + _stopScoringPoll(); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + // Keep polling on transient errors while scoring is in progress. + } + } + + void _stopScoringPoll() { + _scoringPollTimer?.cancel(); + _scoringPollTimer = null; + } + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } try { - final compressedFiles = await ImageCompressUtility.compressFilesList(files); + final compressedFiles = await ImageCompressUtility.compressFilesList( + files, + ); - emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null)); + emit( + state.copyWith( + newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], + error: null, + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -282,7 +563,19 @@ class RegisterConsultantCubit extends Cubit { return; } if (path != null) { - emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); + emit( + state.copyWith( + newAddedPhotos: state.newAddedPhotos + .where((file) => file.uri.path != path) + .toList(), + ), + ); } } + + @override + Future close() { + _stopScoringPoll(); + return super.close(); + } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index a39a1313..42daeb1e 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -17,10 +18,18 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, - @Default([]) List certPhotos, + @Default([]) + List certPhotos, @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, + @Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus, + @Default(false) bool isScoringPolling, + @Default(false) bool scoringRetryInProgress, + + /// True only after successful register/retry in this session until scoring is terminal. + @Default(false) bool awaitingScoringOutcome, + @Default({}) Map fieldErrors, String? error, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart index ce13f905..90b673a5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart @@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget { const AdditionalSettingsScreen({super.key}); @override - State createState() => _AdditionalSettingsScreenState(); + State createState() => + _AdditionalSettingsScreenState(); } class _AdditionalSettingsScreenState extends State { @@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State { @override void initState() { - if (context.read().state.consultant.hasMedicalEducation != null) { + if (context + .read() + .state + .consultant + .hasMedicalEducation != + null) { haveMedicalDegree = context .read() .state @@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State { value: publishDiplomasAndCertificatesInProfile, onTap: () { setState(() { - publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; + publishDiplomasAndCertificatesInProfile = + !publishDiplomasAndCertificatesInProfile; }); }, ), @@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State { setState(() { haveMedicalDegree = !haveMedicalDegree; }); - context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - hasMedicalEducation: haveMedicalDegree, - ), - ); + context + .read() + .updateConsultantProfileModel( + context + .read() + .state + .consultant + .copyWith(hasMedicalEducation: haveMedicalDegree), + ); }, ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart index 40d4a352..2955ee09 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class BecomeConsultantScreen extends StatefulWidget { - const BecomeConsultantScreen({super.key}); + final String? slug; + + const BecomeConsultantScreen({super.key, this.slug}); @override State createState() => _BecomeConsultantScreenState(); @@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State { title: const Text('Стать консультантом'), automaticallyImplyLeading: false, centerTitle: false, - leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), + leading: IconButton( + onPressed: () => GoRouter.of(context).pop(), + icon: const Icon(Icons.close), + ), ), body: const Padding( padding: EdgeInsets.all(16), @@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State { InkWell _startRegistrationButton() { return InkWell( - onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), + onTap: () => GoRouter.of(context).pushReplacementNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': widget.slug, 'isNewRegistration': true}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), child: const Center( child: Text( 'Начать регистрацию', - style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), + style: TextStyle( + color: Colors.white, + height: 1, + fontWeight: FontWeight.w600, + fontSize: 18, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index afff462e..33772eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -13,10 +13,12 @@ class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @override - State createState() => _CertificatesAndDiplomasScreenState(); + State createState() => + _CertificatesAndDiplomasScreenState(); } -class _CertificatesAndDiplomasScreenState extends State { +class _CertificatesAndDiplomasScreenState + extends State { final scrollController = ScrollController(); List diplomasList = [ @@ -30,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State().state.consultant.certificates != null) { + if (context.read().state.consultant.certificates != + null) { diplomasList = [ - ...context.read().state.consultant.certificates!.map( + ...context + .read() + .state + .consultant + .certificates! + .map( (e) => DiplomaEntity( date: DateTime.tryParse(e.date), textEditingController: TextEditingController(text: e.title), @@ -71,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State _diplomaInfo(diploma: diplomasList[index]), + itemBuilder: (context, index) => + _diplomaInfo(diploma: diplomasList[index]), separatorBuilder: (context, index) => Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: _divider(context), @@ -94,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - certificates: diplomasList - .map( - (e) => CertificatesModel( - title: e.textEditingController.text, - date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), - ), - ) - .toList() - .sublist(0, diplomasList.length - 1), + context.read().state.consultant.copyWith( + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null + ? '' + : DateFormat('yyyy-MM-dd').format(e.date!), ), - ); + ) + .toList() + .sublist(0, diplomasList.length - 1), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index afdcbf83..5e87ba50 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -1,8 +1,10 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -24,7 +26,8 @@ class _LastStepScreenState extends State { @override void initState() { super.initState(); - _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + _frdoTapRecognizer = TapGestureRecognizer() + ..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); } @override @@ -46,8 +49,22 @@ class _LastStepScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Последний шаг', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), + if (state.awaitingScoringOutcome || + state.isScoringPolling || + state.alfaScoringStatus != + AlfaScoringStatus.none) ...[ + const SizedBox(height: 16), + const ScoringStatusWidget(), + ], if (state.requestModel?.canceledModeration != null) ...[ const SizedBox(height: 16), Padding( @@ -64,12 +81,24 @@ class _LastStepScreenState extends State { children: [ Text( state.requestModel!.canceledModeration!.title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.2, + ), ), - if (state.requestModel!.canceledModeration!.additional != null) ...[ + 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)), + for (final item + in state + .requestModel! + .canceledModeration! + .additional!) ...[ + Text( + '• $item', + style: const TextStyle(fontSize: 14, height: 1.3), + ), ], ], ], @@ -98,9 +127,16 @@ class _LastStepScreenState extends State { value: state.acceptFrdoAgreement, title: Text.rich( TextSpan( - style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + style: const TextStyle( + fontSize: 18, + height: 1.2, + color: Color(0xFF323F4B), + ), children: [ - const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + const TextSpan( + text: + 'Обязательное требование: Один из моих документов об образовании есть в реестре ', + ), TextSpan( text: 'ФРДО', style: TextStyle( @@ -120,16 +156,31 @@ class _LastStepScreenState extends State { controlAffinity: ListTileControlAffinity.leading, ), ], - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( onPressed: () async => await launchUrl( - Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + Uri.parse( + hidePlans + ? '${sl().baseUrl}/docs/privacy.pdf' + : '${sl().baseUrl}/oferta_bs.pdf', + ), ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', - style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), + hidePlans + ? 'Политика конфиденциальности и пользовательское соглашение' + : 'Договор оферты', + style: const TextStyle( + height: 1, + fontSize: 16, + color: Color(0xFF323F4B), + decoration: TextDecoration.underline, + ), ), ), ), @@ -142,7 +193,10 @@ class _LastStepScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart index 814f2139..466f4418 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -1,8 +1,11 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -100,9 +103,7 @@ class _PassportDataScreenState extends State { birthPlaceController = TextEditingController( text: consultant.birthPlace ?? '', ); - citizenshipController = TextEditingController( - text: consultant.citizenship, - ); + citizenshipController = TextEditingController(text: consultant.citizenship); registrationAddressController = TextEditingController( text: consultant.registrationAddress ?? '', ); @@ -269,258 +270,321 @@ class _PassportDataScreenState extends State { } } - Widget _sectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 12), - child: Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ); - } - @override Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, listener: (context, state) => _hydrateSbpBankNameIfNeeded(), - child: SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: 80, - right: 16, - left: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Паспортные данные', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.fieldErrors != current.fieldErrors, + builder: (context, state) { + final errors = state.fieldErrors; + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, ), - _sectionTitle('Персональные данные (как в паспорте)'), - CustomTextFormField( - hintText: 'Фамилия', - controller: lastNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Имя', - controller: firstNameController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Отчество', - controller: middleNameController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Идентификация'), - CustomTextFormField( - hintText: 'ИНН (12 цифр)', - controller: innController, - keyboardType: TextInputType.number, - maxLength: 12, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'СНИЛС', - controller: snilsController, - keyboardType: TextInputType.number, - inputFormatters: [snilsMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата рождения (ДД.ММ.ГГГГ)', - controller: birthDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskBirth], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Место рождения', - controller: birthPlaceController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Гражданство (ISO, например RU)', - controller: citizenshipController, - maxLength: 2, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес регистрации', - controller: registrationAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Адрес проживания', - controller: residenceAddressController, - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 8), - CheckboxListTile( - value: taxResidentUs, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь налоговым резидентом США', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => taxResidentUs = value ?? false); - _syncToCubit(); - }, - ), - CheckboxListTile( - value: foreignTaxResident, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: appTheme.blueA400, - title: const Text( - 'Я являюсь иностранным налоговым резидентом', - style: TextStyle(fontSize: 16, height: 1.2), - ), - onChanged: (value) { - setState(() => foreignTaxResident = value ?? false); - _syncToCubit(); - }, - ), - _sectionTitle('Паспорт'), - Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + const PassportScreenTitle(title: 'Паспортные данные'), + const PassportSectionTitle( + title: 'Персональные данные (как в паспорте)', + ), + PassportFieldWithError( + error: errors['last_name'], child: CustomTextFormField( - hintText: 'Серия', - controller: seriesController, - keyboardType: TextInputType.number, - maxLength: 4, + hintText: 'Фамилия', + controller: lastNameController, + hasError: errors.containsKey('last_name'), onChanged: (_) => _syncToCubit(), ), ), - const SizedBox(width: 8), - Expanded( + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['first_name'], child: CustomTextFormField( - hintText: 'Номер', - controller: numberController, - keyboardType: TextInputType.number, - maxLength: 6, + hintText: 'Имя', + controller: firstNameController, + hasError: errors.containsKey('first_name'), onChanged: (_) => _syncToCubit(), ), ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['middle_name'], + child: CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + hasError: errors.containsKey('middle_name'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Идентификация'), + PassportFieldWithError( + error: errors['inn'], + child: CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + hasError: errors.containsKey('inn'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['snils'], + child: CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + hasError: errors.containsKey('snils'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_date'], + child: CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + hasError: errors.containsKey('birth_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['birth_place'], + child: CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + hasError: errors.containsKey('birth_place'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['citizenship'], + child: CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + hasError: errors.containsKey('citizenship'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['registration_address'], + child: CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + hasError: errors.containsKey('registration_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['residence_address'], + child: CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + hasError: errors.containsKey('residence_address'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь налоговым резидентом США', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyles.textMMediumAuto.copyWith( + color: appTheme.black, + ), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + const PassportSectionTitle(title: 'Паспорт'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: PassportFieldWithError( + error: errors['passport_series'], + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + hasError: errors.containsKey('passport_series'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: PassportFieldWithError( + error: errors['passport_number'], + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + hasError: errors.containsKey('passport_number'), + onChanged: (_) => _syncToCubit(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issue_date'], + child: CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + hasError: errors.containsKey('passport_issue_date'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer_code'], + child: CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + hasError: errors.containsKey('passport_issuer_code'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['passport_issuer'], + child: CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + hasError: errors.containsKey('passport_issuer'), + onChanged: (_) => _syncToCubit(), + ), + ), + const PassportSectionTitle(title: 'Реквизиты СБП'), + PassportFieldWithError( + error: errors['sbp_phone'], + child: CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + hasError: errors.containsKey('sbp_phone'), + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(height: 12), + PassportFieldWithError( + error: errors['sbp_bank_member_id'], + child: TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => + CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + hasError: errors.containsKey('sbp_bank_member_id'), + onChanged: _clearSbpBankIfNeeded, + ), + suggestionsCallback: (query) async { + return context + .read() + .searchSbpBanks(query); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: TextStyles.textMMedium.copyWith( + color: appTheme.black900, + ), + ), + subtitle: + bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: appTheme.whiteA700, + child: child, + ), + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyles.textSMedium.copyWith( + color: appTheme.grey7B, + ), + ), + ), + onSelected: _selectSbpBank, + ), + ), + const SizedBox(height: 16), + Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyles.textSMedium.copyWith( + color: appTheme.blueA400, + ), + ), ], ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', - controller: issueDateController, - keyboardType: TextInputType.number, - inputFormatters: [dateMaskIssue], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Код подразделения', - controller: issuerCodeController, - keyboardType: TextInputType.number, - inputFormatters: [issuerCodeMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - CustomTextFormField( - hintText: 'Кем выдан', - controller: issuerController, - onChanged: (_) => _syncToCubit(), - ), - _sectionTitle('Реквизиты СБП'), - CustomTextFormField( - hintText: 'Телефон для выплат через СБП', - controller: sbpPhoneController, - keyboardType: TextInputType.phone, - inputFormatters: [phoneMask], - onChanged: (_) => _syncToCubit(), - ), - const SizedBox(height: 12), - TypeAheadField( - debounceDuration: const Duration(milliseconds: 250), - controller: sbpBankController, - suggestionsController: sbpBankSuggestionsController, - builder: (context, controller, focusNode) => CustomTextFormField( - controller: controller, - focusNode: focusNode, - hintText: 'Банк получатель (поиск)', - onChanged: (value) { - _clearSbpBankIfNeeded(value); - }, - ), - suggestionsCallback: (query) async { - return context.read().searchSbpBanks( - query, - ); - }, - itemBuilder: (context, bank) { - return ListTile( - dense: true, - title: Text( - bank.bankName, - style: const TextStyle(fontSize: 16, color: Colors.black), - ), - subtitle: bank.shortName == null || bank.shortName!.isEmpty - ? null - : Text( - bank.shortName!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), - ); - }, - decorationBuilder: (context, child) => Material( - type: MaterialType.card, - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: child, - ), - emptyBuilder: (context) => const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'Ничего не найдено', - style: TextStyle(color: Colors.black54), - ), - ), - onSelected: _selectSbpBank, - ), - const SizedBox(height: 16), - const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), - const SizedBox(height: 16), - Text( - 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', - style: TextStyle( - fontSize: 14, - height: 1.2, - color: appTheme.blueA400, - ), - ), - ], - ), + ); + }, ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index cf21b646..87269eb5 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + const Text( + 'Загрузите фото или сканы дипломов и сертификатов', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final photos = state.certPhotos + .where((photo) => !state.deletedPhotoIds.contains(photo.id)) + .toList(); final addedPhotos = state.newAddedPhotos; final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ - if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], + if (isUploading) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 16), + ], GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 3 / 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 3 / 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), itemBuilder: (BuildContext context, int index) { if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), child: Ink( - decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: const Color(0xFFF6F6F6), + borderRadius: BorderRadius.circular(20), + ), child: Icon(Icons.add, color: appTheme.blueA400), ), ); @@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State { return _LocalCertPhotoCard( photo: localPhoto, allPhotos: addedPhotos, - onRemove: () => _removePhoto(path: localPhoto.uri.path), + onRemove: () => + _removePhoto(path: localPhoto.uri.path), ); } } @@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos( + newImages.map((file) => File(file.path)).toList(), + ); } finally { if (mounted) { setState(() { @@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _CertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget { onTap: () { context.pushTransparentRoute( rootNavigator: true, - PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)), + PhotoViewScreen( + imageUrls: allPhotos.map((e) => e.url).toList(), + isLocal: false, + index: allPhotos.indexOf(photo), + ), ); }, borderRadius: BorderRadius.circular(20), @@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget { Ink( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), - image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)), + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(photo.url), + ), ), ), Positioned( @@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), @@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget { final List allPhotos; final VoidCallback onRemove; - const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + const _LocalCertPhotoCard({ + required this.photo, + required this.allPhotos, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget { customBorder: const CircleBorder(), child: Ink( padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), - child: const Icon(size: 20, Icons.close, color: Colors.black), + decoration: const BoxDecoration( + color: Color(0xFFF1F2F6), + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index a754de27..e10fc74a 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -13,7 +13,8 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = + RegistrationAsType.physical; @override void initState() { @@ -24,7 +25,9 @@ class _RegistrationAsScreenState extends State { } final cubit = context.read(); cubit.updateConsultantProfileModel( - cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.consultant.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + ), cubit.state.acceptOffer, cubit.state.acceptFrdoAgreement, ); @@ -41,7 +44,14 @@ class _RegistrationAsScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), + child: Text( + 'Регистрация в качестве', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), ), const SizedBox(height: 16), Column( @@ -59,7 +69,10 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), + child: Text( + 'Физического лица', + style: TextStyle(fontSize: 18, height: 1), + ), ), ), ], @@ -77,7 +90,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Самозанятого', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -95,7 +115,14 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Индивидуального предпринимателя', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], @@ -113,17 +140,29 @@ class _RegistrationAsScreenState extends State { const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), + child: Text( + 'Общества с ограниченной ответственностью', + style: TextStyle( + fontSize: 18, + height: 1, + color: Colors.black38, + ), + ), ), ), ], ), ], ), - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: _divider(context), + ), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), + child: Text( + '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', + ), ), ], ), @@ -134,7 +173,10 @@ class _RegistrationAsScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + ), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart index 609e94a5..1847ec60 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart @@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State { var maskFormatter = MaskTextInputFormatter( mask: 'https://########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskTgFormatter = MaskTextInputFormatter( mask: 'https://tg.me/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); var maskVkFormatter = MaskTextInputFormatter( mask: 'https://vk.com/########################################', - filter: { - '#': RegExp(r"[a-zA-Z. ]+"), - }, + filter: {'#': RegExp(r"[a-zA-Z. ]+")}, type: MaskAutoCompletionType.lazy, ); @override void initState() { super.initState(); - vkController.text = context.read().state.consultant.socialLinks!.vkLink ?? ''; - telegramController.text = context.read().state.consultant.socialLinks!.tgLink ?? ''; - webSiteController.text = context.read().state.consultant.socialLinks!.personalSiteLink ?? ''; + vkController.text = + context + .read() + .state + .consultant + .socialLinks! + .vkLink ?? + ''; + telegramController.text = + context + .read() + .state + .consultant + .socialLinks! + .tgLink ?? + ''; + webSiteController.text = + context + .read() + .state + .consultant + .socialLinks! + .personalSiteLink ?? + ''; } @override @@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/vk.svg', - ), + child: SvgPicture.asset('assets/icons/vk.svg'), ), const SizedBox(width: 8), SizedBox( @@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/telegram.svg', - ), + child: SvgPicture.asset('assets/icons/telegram.svg'), ), const SizedBox(width: 8), SizedBox( @@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State { color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), ), - child: SvgPicture.asset( - 'assets/icons/web.svg', - ), + child: SvgPicture.asset('assets/icons/web.svg'), ), const SizedBox(width: 8), SizedBox( @@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State { String? personalSiteLink, }) { context.read().updateConsultantProfileModel( - context.read().state.consultant.copyWith( - socialLinks: context.read().state.consultant.socialLinks?.copyWith( - tgLink: tgLink == null - ? context.read().state.consultant.socialLinks?.tgLink - : tgLink.isEmpty - ? null - : tgLink, - vkLink: vkLink == null - ? context.read().state.consultant.socialLinks?.vkLink - : vkLink.isEmpty - ? null - : vkLink, - personalSiteLink: personalSiteLink == null - ? context.read().state.consultant.socialLinks?.personalSiteLink - : personalSiteLink.isEmpty - ? null - : personalSiteLink, - ), - ), - ); + context.read().state.consultant.copyWith( + socialLinks: context + .read() + .state + .consultant + .socialLinks + ?.copyWith( + tgLink: tgLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.tgLink + : tgLink.isEmpty + ? null + : tgLink, + vkLink: vkLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.vkLink + : vkLink.isEmpty + ? null + : vkLink, + personalSiteLink: personalSiteLink == null + ? context + .read() + .state + .consultant + .socialLinks + ?.personalSiteLink + : personalSiteLink.isEmpty + ? null + : personalSiteLink, + ), + ), + ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 73359e25..12b016af 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; @@ -22,26 +23,37 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; final bool? isCanceled; - const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); + final bool isNewRegistration; + const StepperBecomeConsultantScreen({ + super.key, + this.slug, + this.isCanceled, + this.isNewRegistration = false, + }); @override - State createState() => _StepperBecomeConsultantScreenState(); + State createState() => + _StepperBecomeConsultantScreenState(); } -class _StepperBecomeConsultantScreenState extends State { +class _StepperBecomeConsultantScreenState + extends State { double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; bool isLoading = false; + bool _successSheetShown = false; @override void initState() { super.initState(); - if (widget.slug == null) { - registrationConsultantCubit = sl()..disableLoading(); + if (widget.isNewRegistration || widget.slug == null) { + registrationConsultantCubit = sl() + ..disableLoading(slug: widget.slug); } else { - registrationConsultantCubit = sl()..getMyRequestStatus(widget.slug!); + registrationConsultantCubit = sl() + ..getMyRequestStatus(widget.slug!); } } @@ -59,16 +71,97 @@ class _StepperBecomeConsultantScreenState extends State _showSuccessSheet(BuildContext context) async { + if (_successSheetShown) { + return; + } + _successSheetShown = true; + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + height: 1, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Lottie.asset( + 'assets/lottie_animations/success.json', + width: 160, + height: 160, + ), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: appTheme.blueA400, + ), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocProvider( create: (context) => registrationConsultantCubit, - child: BlocBuilder( + child: BlocConsumer( + listenWhen: (previous, current) => + previous.awaitingScoringOutcome && + !current.awaitingScoringOutcome && + current.alfaScoringStatus.isComplete, + listener: (context, state) { + _showSuccessSheet(context); + }, builder: (context, state) { if (state.loading) { return const Scaffold(body: Loader()); } - if (state.error != null && state.requestModel == null && widget.slug != null) { + if (state.error != null && + state.requestModel == null && + widget.slug != null && + state.alfaScoringStatus == AlfaScoringStatus.none) { return Scaffold( appBar: AppBar( centerTitle: true, @@ -87,11 +180,15 @@ class _StepperBecomeConsultantScreenState extends State context.read().reloadData(widget.slug), + onReload: () => context + .read() + .reloadData(widget.slug), ), ); } @@ -100,8 +197,14 @@ class _StepperBecomeConsultantScreenState extends State Navigator.pop(context), child: const Text('OK'))], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], ), ); }, @@ -150,11 +268,19 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Заполните паспортные данные'), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), + content: const Text( + 'Заполните паспортные данные', + ), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), ); return; } - if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + if (registrationConsultantCubit + .hasAcceptedRequiredAgreements) { setState(() { isLoading = true; }); try { - await registrationConsultantCubit.registerConsultant(); + await registrationConsultantCubit + .registerConsultant(); if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), - ), + if (registrationConsultantCubit.state.error != + null) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, ), + 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) { if (!context.mounted) return; showDialog( context: context, builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: + const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => + Navigator.of(context).pop(), ), ], ), @@ -275,7 +392,9 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + actionsPadding: const EdgeInsets.symmetric( + horizontal: 4, + ), content: Text( state.isUpdating ? 'Примите договор публичной оферты' @@ -295,17 +414,25 @@ class _StepperBecomeConsultantScreenState extends State 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, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + title: SvgPicture.asset( + ImageConstant.imgLogoEatvio, + height: 48, + fit: BoxFit.contain, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Text('шаг $step из $totalSteps'), + ), + ], bottom: PreferredSize( preferredSize: Size(MediaQuery.of(context).size.width, 32), child: Padding( @@ -326,13 +465,18 @@ class _StepperBecomeConsultantScreenState extends State( duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, - tween: Tween(begin: 1 / totalSteps, end: progressValue), + tween: Tween( + begin: 1 / totalSteps, + end: progressValue, + ), builder: (context, value, _) => LinearProgressIndicator( value: value, borderRadius: BorderRadius.circular(16), minHeight: 8, backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + valueColor: AlwaysStoppedAnimation( + appTheme.blueA400, + ), ), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart new file mode 100644 index 00000000..5e34fa79 --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart @@ -0,0 +1,28 @@ +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; + +/// Wraps a form field with [ValidationErrorWidget] when [error] is present. +/// Pass [hasError] into [CustomTextFormField] from the parent. +class PassportFieldWithError extends StatelessWidget { + const PassportFieldWithError({required this.child, this.error, super.key}); + + final Widget child; + final String? error; + + @override + Widget build(BuildContext context) { + final message = error?.trim(); + final showError = message != null && message.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + if (showError) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: message), + ], + ], + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart new file mode 100644 index 00000000..29abf8af --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart @@ -0,0 +1,34 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class PassportSectionTitle extends StatelessWidget { + const PassportSectionTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: TextStyles.textLSemibold.copyWith(color: appTheme.black), + ), + ); + } +} + +class PassportScreenTitle extends StatelessWidget { + const PassportScreenTitle({required this.title, super.key}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: TextStyles.textFS24FW700.copyWith(color: appTheme.black), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart new file mode 100644 index 00000000..764c803d --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart @@ -0,0 +1,218 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ScoringStatusWidget extends StatelessWidget { + const ScoringStatusWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.alfaScoringStatus != current.alfaScoringStatus || + previous.isScoringPolling != current.isScoringPolling || + previous.scoringRetryInProgress != current.scoringRetryInProgress || + previous.awaitingScoringOutcome != current.awaitingScoringOutcome, + builder: (context, state) { + final isWaiting = + state.awaitingScoringOutcome || state.isScoringPolling; + final status = state.alfaScoringStatus; + final showProcessing = status.isProcessing || + (isWaiting && !status.isTerminal); + + if (status == AlfaScoringStatus.none && !isWaiting) { + return const SizedBox.shrink(); + } + + final displayStatus = showProcessing && !status.isProcessing + ? AlfaScoringStatus.beneficiaryCreating + : status; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: appTheme.greyD9), + color: appTheme.whiteA700, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScoringStatusHeader(status: displayStatus), + const SizedBox(height: 12), + if (showProcessing) const ScoringProcessingBody(), + if (status.isComplete) const ScoringCompleteBody(), + if (status.isFailed) + ScoringFailedBody( + retryInProgress: state.scoringRetryInProgress, + onRetry: () => context + .read() + .retryScoring(), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class ScoringStatusHeader extends StatelessWidget { + const ScoringStatusHeader({required this.status, super.key}); + + final AlfaScoringStatus status; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + 'Проверка данных', + style: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + Text( + ScoringStatusLabels.hintFor(status), + style: TextStyles.textSMedium.copyWith( + color: ScoringStatusLabels.colorFor(status), + ), + ), + ], + ); + } +} + +class ScoringProcessingBody extends StatelessWidget { + const ScoringProcessingBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.blueA400, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Идёт проверка…', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringCompleteBody extends StatelessWidget { + const ScoringCompleteBody({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.check_circle, color: appTheme.green, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Проверка успешно завершена', + style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), + ), + ), + ], + ); + } +} + +class ScoringFailedBody extends StatelessWidget { + const ScoringFailedBody({ + required this.retryInProgress, + required this.onRetry, + super.key, + }); + + final bool retryInProgress; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ValidationErrorWidget(error: 'Не удалось пройти проверку'), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: retryInProgress ? null : onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + disabledBackgroundColor: appTheme.greyD9, + foregroundColor: appTheme.whiteA700, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: retryInProgress + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: appTheme.whiteA700, + ), + ) + : Text( + 'Повторить', + style: TextStyles.textMSemibold.copyWith( + color: appTheme.whiteA700, + ), + ), + ), + ), + ], + ); + } +} + +abstract final class ScoringStatusLabels { + static String hintFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.beneficiaryCreating => 'Создание профиля', + AlfaScoringStatus.scoringPending => 'Ожидание результата', + AlfaScoringStatus.scoringComplete => 'Успешно', + AlfaScoringStatus.beneficiaryFailed => 'Ошибка', + AlfaScoringStatus.scoringFailed => 'Ошибка', + AlfaScoringStatus.none => '', + }; + } + + static Color colorFor(AlfaScoringStatus status) { + return switch (status) { + AlfaScoringStatus.scoringComplete => appTheme.green, + AlfaScoringStatus.beneficiaryFailed || + AlfaScoringStatus.scoringFailed => appTheme.red, + AlfaScoringStatus.beneficiaryCreating || + AlfaScoringStatus.scoringPending => appTheme.blueA400, + AlfaScoringStatus.none => appTheme.grey7B, + }; + } +} diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index b8d55b5a..4bc97dce 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -12,16 +12,25 @@ class ConsultantRepository { const ConsultantRepository({required this.consultantService}); - Future registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { + Future registerConsultant({ + required ConsultantProfileModel consultant, + bool update = false, + }) async { if (update) { - return await consultantService.consultantUpdate(consultantModel: consultant); + return await consultantService.consultantUpdate( + consultantModel: consultant, + ); } else { - return await consultantService.consultantRegistration(consultantModel: consultant); + return await consultantService.consultantRegistration( + consultantModel: consultant, + ); } } /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. - Future getConsultantRequestStatus(String slug) async { + Future getConsultantRequestStatus( + String slug, + ) async { return await consultantService.getConsultantRequestStatus(slug: slug); } @@ -38,12 +47,18 @@ class ConsultantRepository { return await consultantService.getConsultantCertPhotos(); } - Future> storeConsultantCertPhotos({required List files}) async { + Future> storeConsultantCertPhotos({ + required List files, + }) async { final multipartFiles = []; for (final file in files) { final fileBytes = await file.readAsBytes(); - final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream')); + final multipartFile = MultipartFile.fromBytes( + fileBytes, + filename: file.path.split('/').last, + contentType: MediaType('application', 'octet-stream'), + ); multipartFiles.add(multipartFile); } @@ -51,15 +66,21 @@ class ConsultantRepository { return []; } - return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); + return await consultantService.storeConsultantCertPhotos( + photos: multipartFiles, + ); } Future hideConsultantCertPhoto(int id) async { - await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.hideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future unHideConsultantCertPhoto(int id) async { - await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); + await consultantService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': id}, + ); } Future deletePhoto(int id) async { diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 6482c667..8458824d 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -12,24 +12,35 @@ abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; @POST('/api/v2/consultant_request/registration') - Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); + Future consultantRegistration({ + @Body() required ConsultantProfileModel consultantModel, + }); @POST('/api/consultant_request/photos/upload') @MultiPart() - Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + Future> uploadPhotos({ + @Part(name: 'photos[]', contentType: 'multipart/form-data') + List? photos, + }); /// Публичный статус консультанта для просмотра профиля. /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') - Future getConsultantRequestStatus({@Path() required String slug}); + Future getConsultantRequestStatus({ + @Path() required String slug, + }); /// Свой статус заявки для редактирования/модерации. /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. @GET('/api/v2/consultant_request/{slug}/status') - Future getMyRequestStatus({@Path() required String slug}); + Future getMyRequestStatus({ + @Path() required String slug, + }); @PUT('/api/v2/consultant_request/update') - Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + Future consultantUpdate({ + @Body() required ConsultantProfileModel consultantModel, + }); @GET('/api/finances/sbp_banks') Future> getSbpBanks(); @@ -40,14 +51,19 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/store') @MultiPart() Future> storeConsultantCertPhotos({ - @Part(name: 'photos[]', contentType: 'multipart/form-data') required List photos, + @Part(name: 'photos[]', contentType: 'multipart/form-data') + required List photos, }); @POST('/api/v2/consultant_request/cert_photos/hide') - Future hideConsultantCertPhoto({@Body() required Map payload}); + Future hideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/un_hide') - Future unHideConsultantCertPhoto({@Body() required Map payload}); + Future unHideConsultantCertPhoto({ + @Body() required Map payload, + }); @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') Future deletePhoto({@Path() required int id}); diff --git a/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart new file mode 100644 index 00000000..d4b0d947 --- /dev/null +++ b/lib/features/consultant/become_consultant/utils/laravel_validation_errors.dart @@ -0,0 +1,167 @@ +import 'package:dio/dio.dart'; + +/// Parsed Laravel 422 validation payload for consultant registration UI. +class LaravelValidationErrors { + const LaravelValidationErrors({ + required this.summary, + this.fieldErrors = const {}, + }); + + final String summary; + final Map fieldErrors; + + static const LaravelValidationErrors empty = LaravelValidationErrors( + summary: '', + ); + + bool get hasFieldErrors => fieldErrors.isNotEmpty; +} + +const _genericValidationSummary = 'Проверьте введённые данные'; +const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; + +/// Parses Laravel `errors` map and maps them to user-facing Russian texts. +/// Raw backend messages are never shown in the UI. +LaravelValidationErrors parseLaravelValidationErrors(Object error) { + if (error is! DioException) { + return const LaravelValidationErrors(summary: _genericNetworkSummary); + } + + final data = error.response?.data; + if (error.response?.statusCode == 422 && data is Map) { + final fieldErrors = {}; + final errors = data['errors']; + + if (errors is Map) { + errors.forEach((key, value) { + final fieldKey = key.toString(); + final raw = _rawErrorText(value); + fieldErrors[fieldKey] = mapConsultantValidationMessage( + fieldKey: fieldKey, + rawMessage: raw, + ); + }); + } + + if (fieldErrors.isNotEmpty) { + final uniqueMessages = fieldErrors.values.toSet().toList(); + return LaravelValidationErrors( + summary: uniqueMessages.join('\n'), + fieldErrors: fieldErrors, + ); + } + + return const LaravelValidationErrors(summary: _genericValidationSummary); + } + + return const LaravelValidationErrors(summary: _genericNetworkSummary); +} + +String _rawErrorText(Object? value) { + if (value is List && value.isNotEmpty) { + return value.map((e) => e.toString()).join(' '); + } + if (value is String) { + return value; + } + return ''; +} + +/// Maps a backend validation message for [fieldKey] to a short Russian UI string. +String mapConsultantValidationMessage({ + required String fieldKey, + required String rawMessage, +}) { + final field = fieldKey.split('.').last.toLowerCase(); + final raw = rawMessage.toLowerCase(); + + final isUnique = + raw.contains('taken') || + raw.contains('unique') || + raw.contains('already') || + raw.contains('уже существ') || + raw.contains('уже занят'); + final isRequired = + raw.contains('required') || + raw.contains('обязательн') || + raw.contains('required_if'); + final isAccepted = + raw.contains('accepted') || field.contains('agreement'); + final isUrl = raw.contains('url') || field.contains('link'); + final isFormat = + raw.contains('format') || + raw.contains('invalid') || + raw.contains('regex') || + raw.contains('digits') || + raw.contains('size') || + raw.contains('date') || + raw.contains('before'); + + if (isAccepted) { + if (field.contains('frdo')) { + return 'Подтвердите согласие ФРДО'; + } + return 'Примите условия оферты'; + } + + if (isUnique) { + return switch (field) { + 'inn' => 'Такой ИНН существует', + 'snils' => 'Такой СНИЛС существует', + 'passport_number' || 'passport_series' => 'Такой паспорт существует', + 'sbp_phone' => 'Такой телефон СБП существует', + _ => 'Такие данные уже существуют', + }; + } + + if (isRequired) { + return switch (field) { + 'inn' => 'Укажите ИНН', + 'snils' => 'Укажите СНИЛС', + 'last_name' => 'Укажите фамилию', + 'first_name' => 'Укажите имя', + 'middle_name' => 'Укажите отчество', + 'passport_series' => 'Укажите серию паспорта', + 'passport_number' => 'Укажите номер паспорта', + 'passport_issue_date' => 'Укажите дату выдачи паспорта', + 'passport_issuer' => 'Укажите, кем выдан паспорт', + 'passport_issuer_code' => 'Укажите код подразделения', + 'birth_date' => 'Укажите дату рождения', + 'birth_place' => 'Укажите место рождения', + 'registration_address' => 'Укажите адрес регистрации', + 'residence_address' => 'Укажите адрес проживания', + 'citizenship' => 'Укажите гражданство', + 'sbp_phone' => 'Укажите телефон СБП', + 'sbp_bank_member_id' => 'Выберите банк СБП', + 'completed_courses' => 'Добавьте хотя бы один курс', + 'title' => 'Укажите название курса', + 'date' => 'Укажите дату курса', + 'agreement_accepted' => 'Примите условия оферты', + 'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО', + _ => 'Заполните поле', + }; + } + + if (isUrl) { + return 'Некорректная ссылка'; + } + + if (isFormat || raw.isNotEmpty) { + return switch (field) { + 'inn' => 'Некорректный ИНН', + 'snils' => 'Некорректный СНИЛС', + 'passport_series' => 'Некорректная серия паспорта', + 'passport_number' => 'Некорректный номер паспорта', + 'passport_issue_date' => 'Некорректная дата выдачи', + 'passport_issuer_code' => 'Некорректный код подразделения', + 'birth_date' => 'Некорректная дата рождения', + 'citizenship' => 'Некорректный код гражданства', + 'sbp_phone' => 'Некорректный телефон СБП', + 'title' => 'Слишком короткое название курса', + 'date' => 'Некорректная дата курса', + _ => 'Некорректное значение', + }; + } + + return 'Проверьте поле'; +} diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index db230482..a1b0acab 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -238,7 +238,10 @@ class _SettingsScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'), + onTap: () => GoRouter.of(context).pushNamed( + 'becomeConsultantScreen', + extra: {'slug': state.profile.slug}, + ), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, diff --git a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart index e0f0e80b..3604a737 100644 --- a/test/features/consultant/become_consultant/entities/diploma_entity_test.dart +++ b/test/features/consultant/become_consultant/entities/diploma_entity_test.dart @@ -33,4 +33,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart index 19292196..b1a832c6 100644 --- a/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_cert_photo_model_test.dart @@ -100,4 +100,3 @@ void main() { }); }); } - diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index fb88c9c5..12676d48 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -63,7 +63,10 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15'); + const model = CertificatesModel( + title: 'Certificate Title', + date: '2024-01-15', + ); final json = model.toJson(); @@ -105,7 +108,10 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); - const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); + const certificates = CertificatesModel( + title: 'Course 1', + date: '2024-01-01', + ); const model = ConsultantProfileModel( hasMedicalEducation: true, socialLinks: socialLinks, diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index f11423e4..11c95acb 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -46,7 +47,11 @@ void main() { }); test('fromJson handles null additional field', () { - final json = {'title': 'Test title', 'status': 'canceled', 'additional': null}; + final json = { + 'title': 'Test title', + 'status': 'canceled', + 'additional': null, + }; final model = CanceledModerationModel.fromJson(json); @@ -56,7 +61,11 @@ void main() { }); test('toJson serializes all fields correctly', () { - const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']); + const model = CanceledModerationModel( + title: 'Test title', + status: ModerationStatus.canceled, + additional: ['reason1', 'reason2'], + ); final json = model.toJson(); @@ -133,7 +142,7 @@ void main() { 'inn': '123456789012', 'snils': '123-456-789 00', 'state': 'created', - 'alfa_scoring_status': 'pending', + 'alfa_scoring_status': 'scoring_failed', 'alfa_scoring_request_id': 'abc', 'passport_series': '1234', 'passport_number': '567890', @@ -154,19 +163,35 @@ void main() { expect(model.legalEntity!.inn, '123456789012'); expect(model.legalEntity!.passportSeries, '1234'); expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); - expect(model.legalEntity!.alfaScoringStatus, 'pending'); + 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 00:00:00.000'), + '2023-09-22', + ); expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); expect(LegalEntityModel.normalizeApiDate(null), isNull); }); test('toJson serializes ConsultationRequestModel correctly', () { - const educationModel = EducationModel(requestState: ModerationStatus.approved); - const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; - const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null); + const educationModel = EducationModel( + requestState: ModerationStatus.approved, + ); + const photos = [ + CertPhotosModel(url: 'https://example.com/photo1.jpg'), + CertPhotosModel(url: 'https://example.com/photo2.jpg'), + ]; + const model = ConsultationRequestModel( + ratingPlace: 5, + educationModel: educationModel, + photos: photos, + canceledModeration: null, + ); final json = model.toJson(); diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart index 48a90b13..beb67773 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart @@ -12,7 +12,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); expect(state.consultant, consultant); @@ -20,6 +20,8 @@ void main() { expect(state.loading, false); expect(state.requestModel, isNull); expect(state.certPhotos, isEmpty); + expect(state.awaitingScoringOutcome, isFalse); + expect(state.fieldErrors, isEmpty); }); test('creates instance with custom values', () { @@ -38,7 +40,7 @@ void main() { loading: true, requestModel: null, certPhotos: [certPhoto], - isUpdating: false + isUpdating: false, ); expect(state.acceptOffer, true); @@ -54,7 +56,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -92,7 +94,7 @@ void main() { loading: false, requestModel: null, certPhotos: [certPhoto1], - isUpdating: false + isUpdating: false, ); final updatedState = originalState.copyWith( @@ -100,9 +102,14 @@ void main() { ); expect(updatedState.certPhotos.length, 2); - expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg'); - expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg'); + expect( + updatedState.certPhotos.first.url, + 'https://example.com/photo1.jpg', + ); + expect( + updatedState.certPhotos.last.url, + 'https://example.com/photo2.jpg', + ); }); }); } - diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 9eeeb304..6f6a0090 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -27,18 +29,47 @@ void main() { late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; + ConsultationRequestModel requestWithScoring(String scoringStatus) { + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final legal = Map.from( + json['legal_entity_resource'] as Map, + ); + legal['alfa_scoring_status'] = scoringStatus; + json['legal_entity_resource'] = legal; + return ConsultationRequestModel.fromJson(json); + } + setUp(() { repository = MockConsultantRepository(); - cubit = RegisterConsultantCubit(consultantRepository: repository); - requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotosFromFixture = FixtureReader.readAsList( - 'consultant/consultant_cert_photos.json', - ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); - sbpBanksFromFixture = FixtureReader.readAsList( - 'consultant/sbp_banks.json', - ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); + cubit = RegisterConsultantCubit( + consultantRepository: repository, + scoringPollInterval: const Duration(milliseconds: 30), + ); + requestModel = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotosFromFixture = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); + sbpBanksFromFixture = + FixtureReader.readAsList('consultant/sbp_banks.json') + .map( + (item) => SbpBankModel.fromJson(item as Map), + ) + .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + when( + () => repository.getSbpBanks(), + ).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -50,11 +81,16 @@ void main() { expect(cubit.state.consultant.hasMedicalEducation, isTrue); expect(cubit.state.acceptOffer, isFalse); expect(cubit.state.requestModel, isNull); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none); }); test('getMyRequestStatus hydrates state with backend data', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.getMyRequestStatus('my-consultant-slug'); @@ -62,41 +98,90 @@ void main() { expect(state.loading, isFalse); expect(state.requestModel, equals(requestModel)); expect(state.consultant.certificates, isNotNull); - // Certificates list is built from backend courses; we just ensure it is not empty. expect(state.consultant.certificates!.length, greaterThan(0)); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); - expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); + expect( + state.consultant.socialLinks?.instaLink, + isNull, + reason: 'empty links are sanitized', + ); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); expect(state.consultant.legalType, 'individualPerson'); expect(state.consultant.inn, '123456789012'); expect(state.consultant.passportSeries, '1234'); expect(state.consultant.passportIssueDate, '2020-01-15'); expect(state.consultant.birthPlace, 'г. Москва'); - // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect( + state.certPhotos.map((e) => e.id), + containsAll([101, 102, 103]), + ); expect(state.sbpBanks, hasLength(2)); expect(state.sbpBanks.first.bankName, 'Сбербанк'); - verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); + expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + expect(state.alfaScoringStatus.canRetry, isTrue); + expect(state.isScoringPolling, isFalse); + expect( + state.awaitingScoringOutcome, + isFalse, + reason: 'hydrate must not arm success sheet', + ); + verify( + () => repository.getMyRequestStatus('my-consultant-slug'), + ).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); verify(() => repository.getSbpBanks()).called(1); }); - test('disableLoading stops spinner and loads cert photos', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + 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())).thenAnswer(( + _, + ) async { + calls++; + return calls == 1 ? pending : complete; + }); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); - await cubit.disableLoading(); + await cubit.getMyRequestStatus('my-consultant-slug'); + + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.scoringComplete, + ); + expect(cubit.state.isScoringPolling, isFalse); + }, + ); + + test('disableLoading stops spinner and loads cert photos', () async { + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + + await cubit.disableLoading(slug: 'my-consultant-slug'); expect(cubit.state.loading, isFalse); - // disableLoading now keeps all photos from repository. 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); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final results = cubit.searchSbpBanks('сбер'); @@ -106,7 +191,9 @@ void main() { }); test('findSbpBankById returns matching bank', () async { - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); final bank = cubit.findSbpBankById('100000000004'); @@ -114,42 +201,205 @@ void main() { expect(bank?.bankName, 'Т-Банк'); }); - test('updateConsultantProfileModel replaces consultant data and offer flag', () { - cubit.updateConsultantProfileModel(consultantProfile, true); + test( + 'updateConsultantProfileModel replaces consultant data and offer flag', + () { + cubit.updateConsultantProfileModel(consultantProfile, true); - expect(cubit.state.consultant, consultantProfile); - expect(cubit.state.acceptOffer, isTrue); - expect(cubit.state.loading, isFalse); - }); + 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(named: 'consultant'), - update: any(named: 'update'), - ), - ).thenAnswer((_) async {}); + test( + 'registerConsultant forwards payload with update=false by default', + () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); - cubit.updateConsultantProfileModel(consultantProfile); - cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); - await cubit.registerConsultant(); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: false, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + 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(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()), + ).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()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(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.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()), + ).thenAnswer((_) async => requestWithScoring('scoring_failed')); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(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.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())).thenAnswer(( + _, + ) async { + calls++; + if (calls <= 2) { + return requestWithScoring('none'); + } + return requestWithScoring('scoring_complete'); + }); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(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.delayed(const Duration(milliseconds: 50)); + expect( + cubit.state.alfaScoringStatus, + AlfaScoringStatus.beneficiaryCreating, + ); + expect(cubit.state.isScoringPolling, isTrue); + + await Future.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())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); when( () => repository.registerConsultant( consultant: any(named: 'consultant'), @@ -160,12 +410,16 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - final captured = verify( - () => repository.registerConsultant( - consultant: captureAny(named: 'consultant'), - update: true, - ), - ).captured.single as ConsultantProfileModel; + final captured = + verify( + () => repository.registerConsultant( + consultant: captureAny( + named: 'consultant', + ), + update: true, + ), + ).captured.single + as ConsultantProfileModel; final json = captured.toJson(); expect(json['legal_type'], 'individualPerson'); @@ -175,6 +429,40 @@ void main() { expect(json.containsKey('frdo_agreement_accepted'), isFalse); }); + test('retryScoring calls update and restarts poll', () async { + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.getMyRequestStatus('my-consultant-slug'); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed); + + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestWithScoring('scoring_complete')); + await cubit.retryScoring(); + + verify( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: true, + ), + ).called(1); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('registerConsultant blocks submit without passport data', () async { when( () => repository.registerConsultant( @@ -194,18 +482,59 @@ void main() { ); }); + test('registerConsultant maps Laravel 422 into fieldErrors', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + response: Response( + requestOptions: RequestOptions( + path: '/api/v2/consultant_request/registration', + ), + statusCode: 422, + data: { + 'message': 'The given data was invalid.', + 'errors': { + 'inn': ['The inn format is invalid.'], + }, + }, + ), + type: DioExceptionType.badResponse, + ), + ); + + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); + await cubit.registerConsultant(); + + expect(cubit.state.fieldErrors['inn'], 'Некорректный ИНН'); + expect(cubit.state.error, contains('Некорректный ИНН')); + expect(cubit.state.awaitingScoringOutcome, isFalse); + expect(cubit.state.isScoringPolling, isFalse); + }); + test('removeCertPhoto marks cert as deleted locally', () async { - when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); - when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); - when(() => repository.hideConsultantCertPhoto(any())).thenAnswer((_) async {}); + when( + () => repository.getMyRequestStatus(any()), + ).thenAnswer((_) async => requestModel); + when( + () => repository.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotosFromFixture); + when( + () => repository.hideConsultantCertPhoto(any()), + ).thenAnswer((_) async {}); await cubit.getMyRequestStatus('my-consultant-slug'); - // All photos from backend are present in state. expect(cubit.state.certPhotos, hasLength(3)); await cubit.removeCertPhoto(101, null); - // Current implementation only tracks deleted IDs; photos list remains unchanged here. verifyNever(() => repository.hideConsultantCertPhoto(any())); expect(cubit.state.deletedPhotoIds, contains(101)); expect(cubit.state.certPhotos, hasLength(3)); @@ -214,7 +543,9 @@ void main() { test('uploadCertPhotos - не загружает если список пуст', () async { await cubit.addCertPhotos([]); - verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); + verifyNever( + () => repository.storeConsultantCertPhotos(files: any(named: 'files')), + ); }); test('updateConsultantProfileModel - обновляет без acceptOffer', () { @@ -225,4 +556,38 @@ void main() { expect(cubit.state.loading, isFalse); }); }); + + group('AlfaScoringStatus', () { + test('fromString maps known values', () { + expect( + AlfaScoringStatus.fromString('beneficiary_creating'), + AlfaScoringStatus.beneficiaryCreating, + ); + expect( + AlfaScoringStatus.fromString('scoring_pending'), + AlfaScoringStatus.scoringPending, + ); + expect( + AlfaScoringStatus.fromString('scoring_complete'), + AlfaScoringStatus.scoringComplete, + ); + expect( + AlfaScoringStatus.fromString('scoring_failed'), + AlfaScoringStatus.scoringFailed, + ); + expect( + AlfaScoringStatus.fromString('beneficiary_failed'), + AlfaScoringStatus.beneficiaryFailed, + ); + expect(AlfaScoringStatus.fromString(null), AlfaScoringStatus.none); + expect(AlfaScoringStatus.fromString('unknown'), AlfaScoringStatus.none); + }); + + test('helpers', () { + expect(AlfaScoringStatus.scoringPending.isProcessing, isTrue); + expect(AlfaScoringStatus.scoringFailed.canRetry, isTrue); + expect(AlfaScoringStatus.scoringComplete.isComplete, isTrue); + expect(AlfaScoringStatus.scoringComplete.isTerminal, isTrue); + }); + }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart index 79425f7f..c4fc2eb9 100644 --- a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart @@ -18,15 +18,28 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } testWidgets('renders additional settings screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart index 8101e5eb..5288e170 100644 --- a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'certificates_and_diplomas_screen_smoke_test.mocks.dart'; void main() { group('CertificatesAndDiplomasScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -45,15 +51,30 @@ void main() { testWidgets('renders certificates and diplomas screen', (tester) async { final cubit = MockRegisterConsultantCubit(); - const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const consultant = ConsultantProfileModel( + hasMedicalEducation: null, + socialLinks: null, + photos: null, + certificates: null, + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -67,16 +88,28 @@ void main() { hasMedicalEducation: null, socialLinks: null, photos: null, - certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')], + certificates: [ + CertificatesModel(title: 'Test Certificate', date: '2025-01-01'), + ], + ); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const CertificatesAndDiplomasScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const CertificatesAndDiplomasScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index c95b21c1..800f7430 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -19,50 +19,78 @@ import 'last_step_screen_test.mocks.dart'; void main() { group('LastStepScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } - 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); + 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.value()); + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); - await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); - await tester.pump(); + 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); - }); + expect(find.byType(LastStepScreen), findsOneWidget); + expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }, + ); testWidgets('hides FRDO checkbox in update mode', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: true, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart index 3344e719..1b508e45 100644 --- a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart index 94bdfe3b..5086cf86 100644 --- a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart @@ -19,12 +19,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('PhotoOrScansScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child, RegisterConsultantCubit cubit) { @@ -38,7 +44,10 @@ void main() { ], supportedLocales: const [Locale('ru')], home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } @@ -49,11 +58,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -61,7 +81,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(PhotoOrScansScreen), findsOneWidget); - expect(find.text('Загрузите фото или сканы дипломов и сертификатов'), findsOneWidget); + expect( + find.text('Загрузите фото или сканы дипломов и сертификатов'), + findsOneWidget, + ); }); }); @@ -71,11 +94,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index d4818dd8..28d432f6 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -16,7 +16,9 @@ void main() { late RegisterConsultantCubit cubit; setUp(() { - cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit = RegisterConsultantCubit( + consultantRepository: MockConsultantRepository(), + ); cubit.emit( const RegisterConsultantState( consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), @@ -35,7 +37,10 @@ void main() { return MaterialApp( theme: ThemeHelper().themeData(), home: Scaffold( - body: BlocProvider.value(value: cubit, child: child), + body: BlocProvider.value( + value: cubit, + child: child, + ), ), ); } diff --git a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart index adddd454..7db759e2 100644 --- a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'social_networks_screen_smoke_test.mocks.dart'; void main() { group('SocialNetworksScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -47,18 +53,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -69,18 +91,34 @@ void main() { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: 'https://vk.com/test', tgLink: 'https://t.me/test', fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: 'https://vk.com/test', + tgLink: 'https://t.me/test', + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); when(cubit.updateConsultantProfileModel(any)).thenReturn(null); - await tester.pumpWidget(BlocProvider.value(value: cubit, child: wrap(const SocialNetworksScreen()))); + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: wrap(const SocialNetworksScreen()), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 0f742c33..d663ea7e 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -21,12 +21,18 @@ import 'stepper_become_consultant_screen_smoke_test.mocks.dart'; void main() { group('StepperBecomeConsultantScreen smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); Widget wrap(Widget child) { @@ -48,11 +54,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -71,11 +88,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -94,11 +122,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -117,11 +156,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -140,11 +190,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); @@ -163,11 +224,22 @@ void main() { final cubit = RegisterConsultantCubit(consultantRepository: repository); const consultant = ConsultantProfileModel( hasMedicalEducation: null, - socialLinks: SocialLinksModel(vkLink: null, tgLink: null, fbLink: null, instaLink: null, personalSiteLink: null), + socialLinks: SocialLinksModel( + vkLink: null, + tgLink: null, + fbLink: null, + instaLink: null, + personalSiteLink: null, + ), photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); + const state = RegisterConsultantState( + consultant: consultant, + acceptOffer: false, + loading: false, + isUpdating: false, + ); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index 468e638c..3ea68162 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -37,42 +37,99 @@ void main() { setUp(() { mockService = MockConsultantService(); repository = ConsultantRepository(consultantService: mockService); - consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); - consultationRequest = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json')); - certPhotos = FixtureReader.readAsList('consultant/consultant_cert_photos.json') - .map((item) => ConsultantCertPhotoModel.fromJson(item as Map)) - .toList(); + consultantProfile = ConsultantProfileModel.fromJson( + FixtureReader.readAsMap('consultant/consultant_profile_request.json'), + ); + consultationRequest = ConsultationRequestModel.fromJson( + FixtureReader.readAsMap('consultant/consultation_request.json'), + ); + certPhotos = + FixtureReader.readAsList('consultant/consultant_cert_photos.json') + .map( + (item) => ConsultantCertPhotoModel.fromJson( + item as Map, + ), + ) + .toList(); }); - test('registerConsultant delegates to consultantRegistration when update=false', () async { - when(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantRegistration when update=false', + () async { + when( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile); + await repository.registerConsultant(consultant: consultantProfile); - verify(() => mockService.consultantRegistration(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => mockService.consultantRegistration( + consultantModel: consultantProfile, + ), + ).called(1); + verifyNever( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); - test('registerConsultant delegates to consultantUpdate when update=true', () async { - when(() => mockService.consultantUpdate(consultantModel: any(named: 'consultantModel'))).thenAnswer((_) async {}); + test( + 'registerConsultant delegates to consultantUpdate when update=true', + () async { + when( + () => mockService.consultantUpdate( + consultantModel: any( + named: 'consultantModel', + ), + ), + ).thenAnswer((_) async {}); - await repository.registerConsultant(consultant: consultantProfile, update: true); + await repository.registerConsultant( + consultant: consultantProfile, + update: true, + ); - verify(() => mockService.consultantUpdate(consultantModel: consultantProfile)).called(1); - verifyNever(() => mockService.consultantRegistration(consultantModel: any(named: 'consultantModel'))); - }); + verify( + () => + mockService.consultantUpdate(consultantModel: consultantProfile), + ).called(1); + verifyNever( + () => mockService.consultantRegistration( + consultantModel: any( + named: 'consultantModel', + ), + ), + ); + }, + ); test('getConsultantRequestStatus returns request from service', () async { - when(() => mockService.getConsultantRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getConsultantRequestStatus( + slug: any(named: 'slug'), + ), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getConsultantRequestStatus('user-slug'); expect(result, equals(consultationRequest)); - verify(() => mockService.getConsultantRequestStatus(slug: 'user-slug')).called(1); + verify( + () => mockService.getConsultantRequestStatus(slug: 'user-slug'), + ).called(1); }); test('getMyRequestStatus returns request from service', () async { - when(() => mockService.getMyRequestStatus(slug: any(named: 'slug'))).thenAnswer((_) async => consultationRequest); + when( + () => mockService.getMyRequestStatus(slug: any(named: 'slug')), + ).thenAnswer((_) async => consultationRequest); final result = await repository.getMyRequestStatus('my-slug'); @@ -81,7 +138,9 @@ void main() { }); test('getConsultantCertPhotos returns photos from service', () async { - when(() => mockService.getConsultantCertPhotos()).thenAnswer((_) async => certPhotos); + when( + () => mockService.getConsultantCertPhotos(), + ).thenAnswer((_) async => certPhotos); final result = await repository.getConsultantCertPhotos(); @@ -101,42 +160,80 @@ void main() { verify(() => mockService.getSbpBanks()).called(1); }); - test('storeConsultantCertPhotos returns empty list when no files provided', () async { - final result = await repository.storeConsultantCertPhotos(files: []); + test( + 'storeConsultantCertPhotos returns empty list when no files provided', + () async { + final result = await repository.storeConsultantCertPhotos(files: []); - expect(result, isEmpty); - verifyNever(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))); - }); + expect(result, isEmpty); + verifyNever( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ); + }, + ); - test('storeConsultantCertPhotos uploads prepared files and returns response', () async { - final mockFile = MockFile(); - when(() => mockFile.readAsBytes()).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); - when(() => mockFile.path).thenReturn('/tmp/certificate.png'); - when(() => mockService.storeConsultantCertPhotos(photos: any>(named: 'photos'))).thenAnswer((_) async => certPhotos); + test( + 'storeConsultantCertPhotos uploads prepared files and returns response', + () async { + final mockFile = MockFile(); + when( + () => mockFile.readAsBytes(), + ).thenAnswer((_) async => Uint8List.fromList([1, 2, 3])); + when(() => mockFile.path).thenReturn('/tmp/certificate.png'); + when( + () => mockService.storeConsultantCertPhotos( + photos: any>(named: 'photos'), + ), + ).thenAnswer((_) async => certPhotos); - final result = await repository.storeConsultantCertPhotos(files: [mockFile]); + final result = await repository.storeConsultantCertPhotos( + files: [mockFile], + ); - expect(result, certPhotos); - final capturedArgument = verify(() => mockService.storeConsultantCertPhotos(photos: captureAny>(named: 'photos'))).captured.single as List; - expect(capturedArgument, hasLength(1)); - expect(capturedArgument.first.filename, 'certificate.png'); - }); + expect(result, certPhotos); + final capturedArgument = + verify( + () => mockService.storeConsultantCertPhotos( + photos: captureAny>(named: 'photos'), + ), + ).captured.single + as List; + expect(capturedArgument, hasLength(1)); + expect(capturedArgument.first.filename, 'certificate.png'); + }, + ); test('hideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.hideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.hideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.hideConsultantCertPhoto(42); - verify(() => mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42})).called(1); + verify( + () => + mockService.hideConsultantCertPhoto(payload: {'cert_photo_id': 42}), + ).called(1); }); test('unHideConsultantCertPhoto sends payload to service', () async { - when(() => mockService.unHideConsultantCertPhoto(payload: any>(named: 'payload'))).thenAnswer((_) async {}); + when( + () => mockService.unHideConsultantCertPhoto( + payload: any>(named: 'payload'), + ), + ).thenAnswer((_) async {}); await repository.unHideConsultantCertPhoto(7); - verify(() => mockService.unHideConsultantCertPhoto(payload: {'cert_photo_id': 7})).called(1); + verify( + () => mockService.unHideConsultantCertPhoto( + payload: {'cert_photo_id': 7}, + ), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/services/consultant_service_test.dart b/test/features/consultant/become_consultant/services/consultant_service_test.dart index dd429904..0ae3b63b 100644 --- a/test/features/consultant/become_consultant/services/consultant_service_test.dart +++ b/test/features/consultant/become_consultant/services/consultant_service_test.dart @@ -20,11 +20,14 @@ void main() { test('getMyRequestStatus - возвращает статус запроса', () async { const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getMyRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getMyRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); final result = await mockConsultantService.getMyRequestStatus(slug: slug); @@ -33,25 +36,36 @@ void main() { verify(mockConsultantService.getMyRequestStatus(slug: slug)).called(1); }); - test('getConsultantRequestStatus - возвращает статус запроса консультанта', () async { - const slug = 'test-consultant'; - final json = FixtureReader.readAsMap('consultant/consultation_request.json'); - final expectedRequest = ConsultationRequestModel.fromJson(json); + test( + 'getConsultantRequestStatus - возвращает статус запроса консультанта', + () async { + const slug = 'test-consultant'; + final json = FixtureReader.readAsMap( + 'consultant/consultation_request.json', + ); + final expectedRequest = ConsultationRequestModel.fromJson(json); - when(mockConsultantService.getConsultantRequestStatus(slug: slug)) - .thenAnswer((_) async => expectedRequest); + when( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).thenAnswer((_) async => expectedRequest); - final result = await mockConsultantService.getConsultantRequestStatus(slug: slug); + final result = await mockConsultantService.getConsultantRequestStatus( + slug: slug, + ); - expect(result, isA()); - verify(mockConsultantService.getConsultantRequestStatus(slug: slug)).called(1); - }); + expect(result, isA()); + verify( + mockConsultantService.getConsultantRequestStatus(slug: slug), + ).called(1); + }, + ); test('getConsultantCertPhotos - возвращает фото сертификатов', () async { final expectedPhotos = []; - when(mockConsultantService.getConsultantCertPhotos()) - .thenAnswer((_) async => expectedPhotos); + when( + mockConsultantService.getConsultantCertPhotos(), + ).thenAnswer((_) async => expectedPhotos); final result = await mockConsultantService.getConsultantCertPhotos(); @@ -60,16 +74,22 @@ void main() { }); test('consultantRegistration - регистрирует консультанта', () async { - final profileJson = FixtureReader.readAsMap('consultant/consultant_profile_request.json'); + final profileJson = FixtureReader.readAsMap( + 'consultant/consultant_profile_request.json', + ); final profile = ConsultantProfileModel.fromJson(profileJson); - when(mockConsultantService.consultantRegistration(consultantModel: profile)) - .thenAnswer((_) async => Future.value()); + when( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).thenAnswer((_) async => Future.value()); - await mockConsultantService.consultantRegistration(consultantModel: profile); + await mockConsultantService.consultantRegistration( + consultantModel: profile, + ); - verify(mockConsultantService.consultantRegistration(consultantModel: profile)).called(1); + verify( + mockConsultantService.consultantRegistration(consultantModel: profile), + ).called(1); }); }); } - diff --git a/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart new file mode 100644 index 00000000..4470e4e3 --- /dev/null +++ b/test/features/consultant/become_consultant/utils/laravel_validation_errors_test.dart @@ -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 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.', + ), + 'Такой СНИЛС существует', + ); + }); + }); +} diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index bf7ae187..7d1fcc78 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -32,7 +32,7 @@ "inn": "123456789012", "snils": "123-456-789 00", "state": "created", - "alfa_scoring_status": "pending", + "alfa_scoring_status": "scoring_failed", "alfa_scoring_request_id": "abc-123", "passport_series": "1234", "passport_number": "567890", From 9de0efa28ec8ad24c60ebd6fad26a3febdbaf69e Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 3 Aug 2026 15:30:54 +0300 Subject: [PATCH 12/12] fix: test fixed --- .../presentation/pages/blog_post_screen_smoke_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index a021157a..f4253129 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -221,7 +221,10 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.favorite_border_rounded)); + final likeIcon = find.byIcon(Icons.favorite_border_rounded); + await tester.ensureVisible(likeIcon); + await tester.pumpAndSettle(); + await tester.tap(likeIcon); await tester.pump(); verify(blogPostCubit.toggleLike()).called(1);