feat(personalized_feed): added video and multiple images handling to detailed blog post screen

This commit is contained in:
misha
2026-07-22 14:41:26 +03:00
parent dcf7b25989
commit 2cccc9e253
17 changed files with 595 additions and 246 deletions

View File

@@ -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/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.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/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_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.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/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/features/feed/presentation/widgets/comments_widget.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
final CommentsCubit commentsCubit = sl<CommentsCubit>(); final CommentsCubit commentsCubit = sl<CommentsCubit>();
final textEditingController = TextEditingController(); final textEditingController = TextEditingController();
int? replyId; int? replyId;
int currentGalleryIndex = 0;
@override @override
void initState() { void initState() {
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
super.initState(); super.initState();
} }
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
@@ -242,8 +250,33 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) Builder(
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'), 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(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(

View File

@@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
@JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "total_comments") required int totalComments,
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
@Default([]) List<FeedGalleryImage> images, @Default([]) List<FeedGalleryImage> images,
@JsonKey(name: "video_embed_url") String? videoEmbedUrl, @JsonKey(name: "video_url_embed") String? videoEmbedUrl,
@JsonKey(name: "video_url_thumb") String? videoUrlThumb, @JsonKey(name: "video_url_thumb") String? videoUrlThumb,
}) = _FeedBlogPostModel; }) = _FeedBlogPostModel;

View File

@@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel {
abstract class FeedGalleryImage with _$FeedGalleryImage { abstract class FeedGalleryImage with _$FeedGalleryImage {
const factory FeedGalleryImage({ const factory FeedGalleryImage({
required String url, required String url,
required String orientation, @Default('horizontal') String orientation,
int? id,
int? position,
}) = _FeedGalleryImage; }) = _FeedGalleryImage;
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json); factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);

View File

@@ -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<String> galleryUrls;
final String imageUrl;
final String? videoEmbedUrl;
final String? videoUrlThumb;
final int currentGalleryIndex;
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged;
@override
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
}
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
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<ScrollNotification>(
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),
),
],
),
);
}
}

View File

@@ -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:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.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/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_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_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:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -98,7 +94,7 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
const SizedBox(height: 12), const SizedBox(height: 12),
_BlogPostHeader(post: post), _BlogPostHeader(post: post),
_BlogPostTextContent(post: post), _BlogPostTextContent(post: post),
_BlogPostMediaContent( BlogPostMediaContent(
galleryUrls: galleryUrls, galleryUrls: galleryUrls,
imageUrl: imageUrl, imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, 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<String> galleryUrls;
final String imageUrl;
final String? videoEmbedUrl;
final String? videoUrlThumb;
final int currentGalleryIndex;
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> 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<ScrollNotification>(
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 { class _BlogPostActionsRow extends StatelessWidget {
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});

View File

@@ -25,7 +25,7 @@ abstract class FeedService {
@Query('cursor') String? cursor, @Query('cursor') String? cursor,
}); });
@GET('/api/blogPosts/show/{postId}') @GET('/api/v2/blogPosts/show/{postId}')
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId}); Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
@GET('/api/recipes/index') @GET('/api/recipes/index')

View File

@@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart';
void main() { void main() {
group('AppRouter', () { 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', () { group('resolveDeepLinkTarget - finance/alfa/return', () {
const orderId = 'alfa_c29_k15138_t1712345678_r1234'; const orderId = 'alfa_c29_k15138_t1712345678_r1234';

View File

@@ -51,8 +51,11 @@ void main() {
final blogPostRoute = routes.firstWhere( final blogPostRoute = routes.firstWhere(
(route) => route is GoRoute && route.path == '/blog-post-screen', (route) => route is GoRoute && route.path == '/blog-post-screen',
orElse: () => throw Exception('Route not found'), orElse: () => throw Exception('Route not found'),
); ) as GoRoute;
expect(blogPostRoute, isA<GoRoute>()); expect(blogPostRoute, isA<GoRoute>());
expect(blogPostRoute.path, '/blog-post-screen');
expect(blogPostRoute.name, 'blogPostScreen');
expect(blogPostRoute.builder, isNotNull);
}); });
test('aboutPlanScreen builder обрабатывает slug из query параметров', () { test('aboutPlanScreen builder обрабатывает slug из query параметров', () {

View File

@@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../../../fixtures/feed_fixtures.dart';
class MockFeedRepository extends Mock implements FeedRepository {} class MockFeedRepository extends Mock implements FeedRepository {}
void main() { void main() {
@@ -29,21 +31,7 @@ void main() {
test('getBlogPost - успешная загрузка поста', () async { test('getBlogPost - успешная загрузка поста', () async {
const postId = 1; const postId = 1;
final mockPost = FeedBlogPostModel( final mockPost = FeedFixtures.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),
);
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost); when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
@@ -51,6 +39,8 @@ void main() {
expect(cubit.state.isLoading, false); expect(cubit.state.isLoading, false);
expect(cubit.state.blogPostModel, mockPost); 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); verify(() => mockRepository.getBlogPost(id: postId)).called(1);
}); });

View File

@@ -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_cubit.dart';
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.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/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/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:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:network_image_mock/network_image_mock.dart'; import 'package:network_image_mock/network_image_mock.dart';
import '../../../../fixtures/feed_fixtures.dart';
import 'blog_post_screen_smoke_test.mocks.dart'; import 'blog_post_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()]) @GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
@@ -167,21 +167,7 @@ void main() {
final blogPostCubit = MockBlogPostCubit(); final blogPostCubit = MockBlogPostCubit();
final feedCubit = MockFeedCubit(); final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit(); final commentsCubit = MockCommentsCubit();
final blogPostModel = FeedBlogPostModel( final blogPostModel = FeedFixtures.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 state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
when(blogPostCubit.state).thenReturn(state); when(blogPostCubit.state).thenReturn(state);
@@ -202,7 +188,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(BlogPostScreen), findsOneWidget); 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 blogPostCubit = MockBlogPostCubit();
final feedCubit = MockFeedCubit(); final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit(); final commentsCubit = MockCommentsCubit();
final blogPostModel = FeedBlogPostModel( final blogPostModel = FeedFixtures.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 state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
when(blogPostCubit.state).thenReturn(state); when(blogPostCubit.state).thenReturn(state);
@@ -254,5 +227,95 @@ void main() {
verify(blogPostCubit.toggleLike()).called(1); 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<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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);
});
});
}); });
} }

View File

@@ -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:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/feed_fixtures.dart';
void main() { void main() {
group('FeedBlogPostModel', () { group('FeedBlogPostModel', () {
test('creates FeedBlogPostModel with all parameters', () { test('creates FeedBlogPostModel with all parameters', () {
@@ -102,6 +104,52 @@ void main() {
expect(model.totalLikes, 10); 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', () { test('creates FeedBlogPostModel with different states', () {
const author = Author( const author = Author(
authorSlug: 'author', authorSlug: 'author',

View File

@@ -85,6 +85,9 @@ void main() {
expect(result, isA<FeedBlogPostModel>()); expect(result, isA<FeedBlogPostModel>());
expect(result.id, postId); 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); verify(mockFeedService.getBlogPost(postId: postId)).called(1);
}); });

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,7 +1,21 @@
{ {
"id": 1, "id": 1,
"state": "published", "state": "active",
"image": "https://example.com/image.jpg", "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": "Описание поста", "description": "Описание поста",
"author": { "author": {
"author_slug": "author-slug", "author_slug": "author-slug",
@@ -11,13 +25,15 @@
"is_entity_subscribed_by_me": null, "is_entity_subscribed_by_me": null,
"author_place_in_rating": 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", "created_at": "2024-01-15T12:00:00Z",
"updated_at": "2024-01-15T12:00:00Z", "updated_at": "2024-01-15T12:00:00Z",
"title": "Заголовок", "title": "Заголовок",
"total_likes": 10, "total_likes": 10,
"total_views": 100, "total_views": 100,
"total_comments": 5, "total_comments": 5,
"was_liked_by_me": false "was_liked_by_me": false,
"is_favorite": false
} }

View File

@@ -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
}

View File

@@ -23,6 +23,18 @@ class FeedFixtures {
return FixtureReader.readAsMap('feed/feed_blog_post_response.json'); return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
} }
static Map<String, dynamic> feedBlogPostGalleryResponseJson() {
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json');
}
static Map<String, dynamic> feedBlogPostVideoOnlyResponseJson() {
return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json');
}
static Map<String, dynamic> feedBlogPostGalleryAndVideoResponseJson() {
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json');
}
static PostFeedPaginationModel postFeedPaginationModel() { static PostFeedPaginationModel postFeedPaginationModel() {
return PostFeedPaginationModel.fromJson(postFeedResponseJson()); return PostFeedPaginationModel.fromJson(postFeedResponseJson());
} }
@@ -48,4 +60,16 @@ class FeedFixtures {
static FeedBlogPostModel feedBlogPostModel() { static FeedBlogPostModel feedBlogPostModel() {
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson()); 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());
}
} }