diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index ce691fb9..d5960c68 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -1,4 +1,4 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; @@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart'; +import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -33,6 +36,7 @@ class _BlogPostScreenState extends State { final CommentsCubit commentsCubit = sl(); final textEditingController = TextEditingController(); int? replyId; + int currentGalleryIndex = 0; @override void initState() { @@ -40,6 +44,10 @@ class _BlogPostScreenState extends State { super.initState(); } + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -242,8 +250,33 @@ class _BlogPostScreenState extends State { child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), - if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) - CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'), + Builder( + builder: (context) { + final post = blogPostState.blogPostModel; + if (post == null) { + return const SizedBox.shrink(); + } + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final galleryUrls = post.images + .map((e) => PostFeedMapper.resolveMediaUrl(e.url)) + .where((e) => e.isNotEmpty) + .toList(); + final embedUrl = post.videoEmbedUrl?.trim(); + return BlogPostMediaContent( + galleryUrls: galleryUrls, + imageUrl: imageUrl, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + currentGalleryIndex: currentGalleryIndex, + onOpenGallery: _openGallery, + onGalleryChanged: (index) { + setState(() { + currentGalleryIndex = index; + }); + }, + ); + }, + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( diff --git a/lib/features/feed/models/feed_blog_post_model.dart b/lib/features/feed/models/feed_blog_post_model.dart index 157a2da0..9ce22b7f 100644 --- a/lib/features/feed/models/feed_blog_post_model.dart +++ b/lib/features/feed/models/feed_blog_post_model.dart @@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel { @JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, @Default([]) List images, - @JsonKey(name: "video_embed_url") String? videoEmbedUrl, + @JsonKey(name: "video_url_embed") String? videoEmbedUrl, @JsonKey(name: "video_url_thumb") String? videoUrlThumb, }) = _FeedBlogPostModel; diff --git a/lib/features/feed/models/post_feed_pagination_model.dart b/lib/features/feed/models/post_feed_pagination_model.dart index c558e64e..31ade2a8 100644 --- a/lib/features/feed/models/post_feed_pagination_model.dart +++ b/lib/features/feed/models/post_feed_pagination_model.dart @@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel { abstract class FeedGalleryImage with _$FeedGalleryImage { const factory FeedGalleryImage({ required String url, - required String orientation, + @Default('horizontal') String orientation, + int? id, + int? position, }) = _FeedGalleryImage; factory FeedGalleryImage.fromJson(Map json) => _$FeedGalleryImageFromJson(json); diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart new file mode 100644 index 00000000..e2141185 --- /dev/null +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider_plus/carousel_slider_plus.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:flutter/material.dart'; + +/// Gallery + optional embed video for feed card and blog post detail. +class BlogPostMediaContent extends StatefulWidget { + const BlogPostMediaContent({ + super.key, + required this.galleryUrls, + required this.imageUrl, + required this.videoEmbedUrl, + required this.currentGalleryIndex, + required this.onOpenGallery, + required this.onGalleryChanged, + required this.videoUrlThumb, + }); + + final List galleryUrls; + final String imageUrl; + final String? videoEmbedUrl; + final String? videoUrlThumb; + final int currentGalleryIndex; + final void Function(List urls, int index) onOpenGallery; + final ValueChanged onGalleryChanged; + + @override + State createState() => _BlogPostMediaContentState(); +} + +class _BlogPostMediaContentState extends State { + static const _counterDisplayDuration = Duration(seconds: 1); + static const _carouselHeight = 260.0; + + bool _isCounterVisible = false; + Timer? _counterHideTimer; + + bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + + int get _slideCount { + if (widget.galleryUrls.isNotEmpty) { + return widget.galleryUrls.length + (_hasVideo ? 1 : 0); + } + if (_hasVideo) { + return 1; + } + return 0; + } + + bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length; + + bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; + + @override + void dispose() { + _counterHideTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant BlogPostMediaContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { + _scheduleCounterHideOnVideo(); + } + } + + void _scheduleCounterHideOnVideo() { + _counterHideTimer?.cancel(); + if (!_isCurrentSlideVideo) return; + + _counterHideTimer = Timer(_counterDisplayDuration, () { + if (mounted && _isCurrentSlideVideo) { + setState(() => _isCounterVisible = false); + } + }); + } + + bool _handleCarouselScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + _counterHideTimer?.cancel(); + if (!_isCounterVisible) { + setState(() => _isCounterVisible = true); + } + } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + _scheduleCounterHideOnVideo(); + } + return false; + } + + Widget _imageSlide(String itemUrl, int itemIndex) { + return GestureDetector( + onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), + child: CachedNetworkImage( + fit: BoxFit.cover, + width: MediaQuery.of(context).size.width, + height: _carouselHeight, + imageUrl: itemUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ); + } + + Widget _videoSlide() { + return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + } + + Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) { + return Stack( + children: [ + NotificationListener( + onNotification: _handleCarouselScroll, + child: CarouselSlider.builder( + itemCount: itemCount, + itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + options: CarouselOptions( + viewportFraction: 1, + height: _carouselHeight, + enableInfiniteScroll: false, + onPageChanged: (index, _) => widget.onGalleryChanged(index), + ), + ), + ), + if (itemCount > 1) + Positioned( + top: 12, + right: 12, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _shouldShowCounter ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) { + final itemCount = _slideCount; + return _carousel( + itemCount: itemCount, + itemBuilder: (context, itemIndex) { + if (itemIndex < widget.galleryUrls.length) { + return _imageSlide(widget.galleryUrls[itemIndex], itemIndex); + } + if (_hasVideo) { + return _videoSlide(); + } + return const SizedBox.shrink(); + }, + ); + } + + if (widget.galleryUrls.length == 1) { + return _imageSlide(widget.galleryUrls.first, 0); + } + + if (_hasVideo) { + return SizedBox( + height: _carouselHeight, + width: MediaQuery.of(context).size.width, + child: _videoSlide(), + ); + } + + if (widget.imageUrl.isNotEmpty) { + return GestureDetector( + onTap: () => widget.onOpenGallery([widget.imageUrl], 0), + child: Hero( + tag: widget.imageUrl, + child: CachedNetworkImage( + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ), + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class _GalleryPhotoCounter extends StatelessWidget { + const _GalleryPhotoCounter({required this.currentIndex, required this.total}); + + final int currentIndex; + final int total; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const SizedBox(width: 8), + Text( + '${currentIndex + 1} / $total', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 0aa3feb2..5c349db8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -1,7 +1,3 @@ -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:carousel_slider_plus/carousel_slider_plus.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/complained_content.dart'; @@ -15,7 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; -import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -98,7 +94,7 @@ class _BlogPostWidgetState extends State { const SizedBox(height: 12), _BlogPostHeader(post: post), _BlogPostTextContent(post: post), - _BlogPostMediaContent( + BlogPostMediaContent( galleryUrls: galleryUrls, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, @@ -220,186 +216,6 @@ class _BlogPostTextContent extends StatelessWidget { } } -class _BlogPostMediaContent extends StatefulWidget { - const _BlogPostMediaContent({ - required this.galleryUrls, - required this.imageUrl, - required this.videoEmbedUrl, - required this.currentGalleryIndex, - required this.onOpenGallery, - required this.onGalleryChanged, - required this.videoUrlThumb, - }); - - final List galleryUrls; - final String imageUrl; - final String? videoEmbedUrl; - final String? videoUrlThumb; - final int currentGalleryIndex; - final void Function(List urls, int index) onOpenGallery; - final ValueChanged onGalleryChanged; - - @override - State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState(); -} - -class _BlogPostMediaContentState extends State<_BlogPostMediaContent> { - static const _counterDisplayDuration = Duration(seconds: 1); - - bool _isCounterVisible = false; - Timer? _counterHideTimer; - - bool get _isCurrentSlideVideo => - widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length; - - bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; - - @override - void dispose() { - _counterHideTimer?.cancel(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { - _scheduleCounterHideOnVideo(); - } - } - - void _scheduleCounterHideOnVideo() { - _counterHideTimer?.cancel(); - if (!_isCurrentSlideVideo) return; - - _counterHideTimer = Timer(_counterDisplayDuration, () { - if (mounted && _isCurrentSlideVideo) { - setState(() => _isCounterVisible = false); - } - }); - } - - bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { - _counterHideTimer?.cancel(); - if (!_isCounterVisible) { - setState(() => _isCounterVisible = true); - } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { - _scheduleCounterHideOnVideo(); - } - return false; - } - - @override - Widget build(BuildContext context) { - final hasVideo = widget.videoEmbedUrl != null; - final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length; - final showCarousel = mediaContentCount > 1; - return Column( - children: [ - if (showCarousel) ...[ - Stack( - children: [ - NotificationListener( - onNotification: _handleCarouselScroll, - child: CarouselSlider.builder( - itemCount: mediaContentCount, - itemBuilder: (context, itemIndex, _) { - if (itemIndex == widget.galleryUrls.length) { - if (widget.videoEmbedUrl != null) { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); - } - return const SizedBox.shrink(); - } - final itemUrl = widget.galleryUrls[itemIndex]; - return GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - imageUrl: itemUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ); - }, - options: CarouselOptions( - viewportFraction: 1, - height: 260, - enableInfiniteScroll: false, - onPageChanged: (index, _) => widget.onGalleryChanged(index), - ), - ), - ), - Positioned( - top: 12, - right: 12, - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _shouldShowCounter ? 1 : 0, - duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount), - ), - ), - ), - ], - ), - ] else if (widget.galleryUrls.length == 1) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery(widget.galleryUrls, 0), - child: CachedNetworkImage( - fit: BoxFit.cover, - width: MediaQuery.of(context).size.width, - height: 260, - imageUrl: widget.galleryUrls.first, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ] else if (widget.imageUrl.isNotEmpty) ...[ - GestureDetector( - onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), - ), - ], - ], - ); - } -} - -class _GalleryPhotoCounter extends StatelessWidget { - const _GalleryPhotoCounter({required this.currentIndex, required this.total}); - - final int currentIndex; - final int total; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), - const SizedBox(width: 8), - Text( - '${currentIndex + 1} / $total', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), - ), - ], - ), - ); - } -} - class _BlogPostActionsRow extends StatelessWidget { const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); diff --git a/lib/features/feed/services/feed_service.dart b/lib/features/feed/services/feed_service.dart index 9d88dd8f..22a453be 100644 --- a/lib/features/feed/services/feed_service.dart +++ b/lib/features/feed/services/feed_service.dart @@ -25,7 +25,7 @@ abstract class FeedService { @Query('cursor') String? cursor, }); - @GET('/api/blogPosts/show/{postId}') + @GET('/api/v2/blogPosts/show/{postId}') Future getBlogPost({@Path('postId') required int postId}); @GET('/api/recipes/index') diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart index 648c5130..bdeef9c1 100644 --- a/test/core/app_router_test.dart +++ b/test/core/app_router_test.dart @@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart'; void main() { group('AppRouter', () { + group('resolveDeepLinkTarget - blog_post', () { + test('ru/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/ru/blog_post/123'), + ); + + expect(target, '/blog-post-screen?id=123'); + }); + + test('en/blog_post/{id} → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/en/blog_post/456'), + ); + + expect(target, '/blog-post-screen?id=456'); + }); + + test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.com/blog_post/789'), + ); + + expect(target, '/blog-post-screen?id=789'); + }); + }); + group('resolveDeepLinkTarget - finance/alfa/return', () { const orderId = 'alfa_c29_k15138_t1712345678_r1234'; diff --git a/test/core/routing/content_routes_test.dart b/test/core/routing/content_routes_test.dart index ff23e2a3..385485a9 100644 --- a/test/core/routing/content_routes_test.dart +++ b/test/core/routing/content_routes_test.dart @@ -51,8 +51,11 @@ void main() { final blogPostRoute = routes.firstWhere( (route) => route is GoRoute && route.path == '/blog-post-screen', orElse: () => throw Exception('Route not found'), - ); + ) as GoRoute; expect(blogPostRoute, isA()); + expect(blogPostRoute.path, '/blog-post-screen'); + expect(blogPostRoute.name, 'blogPostScreen'); + expect(blogPostRoute.builder, isNotNull); }); test('aboutPlanScreen builder обрабатывает slug из query параметров', () { diff --git a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart index 082c154c..4103007c 100644 --- a/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart +++ b/test/features/blog_post/presentation/cubit/blog_post_cubit_test.dart @@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../../fixtures/feed_fixtures.dart'; + class MockFeedRepository extends Mock implements FeedRepository {} void main() { @@ -29,21 +31,7 @@ void main() { test('getBlogPost - успешная загрузка поста', () async { const postId = 1; - final mockPost = FeedBlogPostModel( - id: postId, - title: 'Test Post', - state: 'active', - image: '', - description: 'Test Content', - videoUrl: '', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false), - ); + final mockPost = FeedFixtures.feedBlogPostModel(); when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost); @@ -51,6 +39,8 @@ void main() { expect(cubit.state.isLoading, false); expect(cubit.state.blogPostModel, mockPost); + expect(cubit.state.blogPostModel?.images, hasLength(2)); + expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed')); verify(() => mockRepository.getBlogPost(id: postId)).called(1); }); diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index f194ea0c..a021157a 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart'; -import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:network_image_mock/network_image_mock.dart'; +import '../../../../fixtures/feed_fixtures.dart'; import 'blog_post_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) @@ -167,21 +167,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -202,7 +188,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BlogPostScreen), findsOneWidget); - expect(find.text('Test Post Title'), findsOneWidget); + expect(find.text('Заголовок'), findsOneWidget); + expect(find.byType(BlogPostMediaContent), findsOneWidget); }); }); @@ -211,21 +198,7 @@ void main() { final blogPostCubit = MockBlogPostCubit(); final feedCubit = MockFeedCubit(); final commentsCubit = MockCommentsCubit(); - final blogPostModel = FeedBlogPostModel( - id: 1, - title: 'Test Post Title', - description: 'Test post description', - image: 'https://example.com/image.jpg', - state: 'published', - author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), - videoUrl: '', - createdAt: DateTime(2024, 1, 15), - updatedAt: DateTime(2024, 1, 15), - totalLikes: 10, - totalViews: 100, - totalComments: 5, - wasLikedByMe: false, - ); + final blogPostModel = FeedFixtures.feedBlogPostModel(); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); when(blogPostCubit.state).thenReturn(state); @@ -254,5 +227,95 @@ void main() { verify(blogPostCubit.toggleLike()).called(1); }); }); + + testWidgets('renders gallery from fixture', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostWithGallery(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея'), findsOneWidget); + }); + }); + + testWidgets('renders video-only post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostVideoOnly(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); + }); + }); + + testWidgets('renders gallery and video post', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo(); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Галерея и видео'), findsWidgets); + }); + }); }); } diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 9d84480a..55fbe181 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -2,6 +2,8 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../fixtures/feed_fixtures.dart'; + void main() { group('FeedBlogPostModel', () { test('creates FeedBlogPostModel with all parameters', () { @@ -102,6 +104,52 @@ void main() { expect(model.totalLikes, 10); }); + test('parses v2 show JSON with gallery images and video_url_embed', () { + final model = FeedBlogPostModel.fromJson({ + 'id': 1, + 'state': 'active', + 'image': 'media/1/cover.jpg', + 'images': [ + {'id': 10, 'url': 'media/1/a.jpg', 'position': 1, 'orientation': 'horizontal'}, + {'id': 11, 'url': 'media/1/b.jpg', 'position': 2}, + ], + 'description': 'Описание', + 'author': { + 'author_slug': 'author-slug', + 'author_name': 'Автор', + 'author_profile_icon': 'icon.jpg', + 'author_is_me': false, + }, + 'video_url': 'https://rutube.ru/video/abc/', + 'video_url_embed': 'https://rutube.ru/play/embed/abc/', + 'video_url_thumb': 'media/1/thumb.jpg', + 'created_at': '2024-01-15T12:00:00Z', + 'updated_at': '2024-01-15T12:00:00Z', + 'title': 'Заголовок', + 'total_likes': 10, + 'total_views': 100, + 'total_comments': 5, + 'was_liked_by_me': false, + }); + + expect(model.images, hasLength(2)); + expect(model.images.first.id, 10); + expect(model.images.first.url, 'media/1/a.jpg'); + expect(model.images.first.orientation, 'horizontal'); + expect(model.images[1].orientation, 'horizontal'); + expect(model.images[1].position, 2); + expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/'); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + + test('parses fixture feedBlogPostModel with gallery and embed', () { + final model = FeedFixtures.feedBlogPostModel(); + + expect(model.images, hasLength(2)); + expect(model.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(model.videoUrlThumb, 'media/1/thumb.jpg'); + }); + test('creates FeedBlogPostModel with different states', () { const author = Author( authorSlug: 'author', diff --git a/test/features/feed/repositories/feed_repository_test.dart b/test/features/feed/repositories/feed_repository_test.dart index 9165ba3a..10e9f200 100644 --- a/test/features/feed/repositories/feed_repository_test.dart +++ b/test/features/feed/repositories/feed_repository_test.dart @@ -85,6 +85,9 @@ void main() { expect(result, isA()); expect(result.id, postId); + expect(result.images, hasLength(2)); + expect(result.videoEmbedUrl, contains('rutube.ru/play/embed')); + expect(result.videoUrlThumb, isNotEmpty); verify(mockFeedService.getBlogPost(postId: postId)).called(1); }); diff --git a/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json new file mode 100644 index 00000000..c54b04af --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_and_video_response.json @@ -0,0 +1,39 @@ +{ + "id": 4, + "state": "active", + "image": "media/4/a.jpg", + "images": [ + { + "id": 40, + "url": "media/4/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 41, + "url": "media/4/b.jpg", + "position": 2, + "orientation": "horizontal" + } + ], + "description": "Галерея и видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": true, + "author_place_in_rating": null + }, + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/4/thumb.jpg", + "created_at": "2024-01-18T12:00:00Z", + "updated_at": "2024-01-18T12:00:00Z", + "title": "Галерея и видео", + "total_likes": 12, + "total_views": 200, + "total_comments": 4, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_gallery_response.json b/test/fixtures/feed/feed_blog_post_gallery_response.json new file mode 100644 index 00000000..fd23f3a4 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_gallery_response.json @@ -0,0 +1,45 @@ +{ + "id": 2, + "state": "active", + "image": "media/2/a.jpg", + "images": [ + { + "id": 20, + "url": "media/2/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 21, + "url": "media/2/b.jpg", + "position": 2, + "orientation": "horizontal" + }, + { + "id": 22, + "url": "media/2/c.jpg", + "position": 3, + "orientation": "vertical" + } + ], + "description": "Пост только с галереей", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "", + "video_url_embed": null, + "video_url_thumb": null, + "created_at": "2024-01-16T12:00:00Z", + "updated_at": "2024-01-16T12:00:00Z", + "title": "Галерея", + "total_likes": 3, + "total_views": 40, + "total_comments": 1, + "was_liked_by_me": false, + "is_favorite": false +} diff --git a/test/fixtures/feed/feed_blog_post_response.json b/test/fixtures/feed/feed_blog_post_response.json index 75ae180b..c5af87de 100644 --- a/test/fixtures/feed/feed_blog_post_response.json +++ b/test/fixtures/feed/feed_blog_post_response.json @@ -1,7 +1,21 @@ { "id": 1, - "state": "published", - "image": "https://example.com/image.jpg", + "state": "active", + "image": "media/1/cover.jpg", + "images": [ + { + "id": 10, + "url": "media/1/a.jpg", + "position": 1, + "orientation": "horizontal" + }, + { + "id": 11, + "url": "media/1/b.jpg", + "position": 2, + "orientation": "vertical" + } + ], "description": "Описание поста", "author": { "author_slug": "author-slug", @@ -11,13 +25,15 @@ "is_entity_subscribed_by_me": null, "author_place_in_rating": null }, - "video_url": "", + "video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/", + "video_url_thumb": "media/1/thumb.jpg", "created_at": "2024-01-15T12:00:00Z", "updated_at": "2024-01-15T12:00:00Z", "title": "Заголовок", "total_likes": 10, "total_views": 100, "total_comments": 5, - "was_liked_by_me": false + "was_liked_by_me": false, + "is_favorite": false } - diff --git a/test/fixtures/feed/feed_blog_post_video_only_response.json b/test/fixtures/feed/feed_blog_post_video_only_response.json new file mode 100644 index 00000000..f3025547 --- /dev/null +++ b/test/fixtures/feed/feed_blog_post_video_only_response.json @@ -0,0 +1,26 @@ +{ + "id": 3, + "state": "active", + "image": "images/element-image-placeholder.svg", + "images": [], + "description": "Пост только с видео", + "author": { + "author_slug": "author-slug", + "author_name": "Автор", + "author_profile_icon": "https://example.com/photo.jpg", + "author_is_me": false, + "is_entity_subscribed_by_me": false, + "author_place_in_rating": null + }, + "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url_thumb": "media/3/thumb.jpg", + "created_at": "2024-01-17T12:00:00Z", + "updated_at": "2024-01-17T12:00:00Z", + "title": "Видео", + "total_likes": 8, + "total_views": 90, + "total_comments": 2, + "was_liked_by_me": true, + "is_favorite": false +} diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 3084a69b..0beb1624 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -23,6 +23,18 @@ class FeedFixtures { return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); } + static Map feedBlogPostGalleryResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json'); + } + + static Map feedBlogPostVideoOnlyResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json'); + } + + static Map feedBlogPostGalleryAndVideoResponseJson() { + return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json'); + } + static PostFeedPaginationModel postFeedPaginationModel() { return PostFeedPaginationModel.fromJson(postFeedResponseJson()); } @@ -48,4 +60,16 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostModel() { return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); } + + static FeedBlogPostModel feedBlogPostWithGallery() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson()); + } + + static FeedBlogPostModel feedBlogPostVideoOnly() { + return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson()); + } + + static FeedBlogPostModel feedBlogPostGalleryAndVideo() { + return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); + } }