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/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..29e85300 --- /dev/null +++ b/lib/features/feed/presentation/widgets/feed_video_player_widget.dart @@ -0,0 +1,312 @@ +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: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}); + + final String embedUrl; + + /// Пока бэкенд не отдаёт превью — null, показывается серая заглушка. + final String? videoUrlThumb; + + @override + State createState() => _FeedVideoPlayerState(); +} + +class _FeedVideoPlayerState extends State { + static const _readyChannel = 'EmbedReady'; + static const Duration _readyFallbackDelay = Duration(seconds: 4); + + /// Пока 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); + if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) { + _reset(); + } + } + + @override + void dispose() { + _readyFallback?.cancel(); + super.dispose(); + } + + void _reset() { + _readyFallback?.cancel(); + _readyFallback = null; + _initialData = null; + _isLoading = false; + _isReady = false; + _hasError = false; + } + + /// После жеста пользователя включаем 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; + _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), + ), + ), + ), + ], + ); + } +} 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..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,6 +82,7 @@ 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 @@ -165,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" 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()); + } +}