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

@@ -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';

View File

@@ -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<GoRoute>());
expect(blogPostRoute.path, '/blog-post-screen');
expect(blogPostRoute.name, 'blogPostScreen');
expect(blogPostRoute.builder, isNotNull);
});
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: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);
});

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_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<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
@@ -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<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: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',

View File

@@ -85,6 +85,9 @@ void main() {
expect(result, isA<FeedBlogPostModel>());
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);
});

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,
"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
}

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');
}
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() {
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());
}
}