From 48a984f5e94932be470d615e950b2ddbb21760c9 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 20 Jul 2026 16:47:19 +0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] =?UTF-8?q?=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=83=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=83=D1=81=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=BA,=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B5=20=D1=81=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=BE=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE?= =?UTF-8?q?.=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=20=D0=BD=D0=B0=20=D0=B2=D0=B8?= =?UTF-8?q?=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 6/9] 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 a6fe6afb80430e1d55932a6528b17a215f62a5dd Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:17:01 +0300 Subject: [PATCH 7/9] 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 8/9] 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 9de0efa28ec8ad24c60ebd6fad26a3febdbaf69e Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 3 Aug 2026 15:30:54 +0300 Subject: [PATCH 9/9] 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);