forked from eatvio/eatvio_app
Merge pull request 'personalized_feed with video and multiple images' (#999) from apoloz/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#999
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Ready for development tasks. No active work in progress.
|
||||
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||
|
||||
## Known Issues
|
||||
None documented at initialization.
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'feed_blog_post_model.freezed.dart';
|
||||
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
||||
@JsonKey(name: "total_views") required int totalViews,
|
||||
@JsonKey(name: "total_comments") required int totalComments,
|
||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: "video_embed_url") String? videoEmbedUrl,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
}) = _FeedBlogPostModel;
|
||||
|
||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||
|
||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
|
||||
class PostFeedMapper {
|
||||
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||
return FeedBlogPostModel(
|
||||
id: item.postId,
|
||||
state: item.postState,
|
||||
image: resolveMediaUrl(item.postImageUrl),
|
||||
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||
author: Author(
|
||||
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||
authorName: item.postAuthorName,
|
||||
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: item.postCreatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
title: item.postTitle,
|
||||
totalLikes: item.postLikesCount,
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
}
|
||||
93
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
93
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'post_feed_pagination_model.freezed.dart';
|
||||
part 'post_feed_pagination_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||
const factory PostFeedPaginationModel({
|
||||
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||
required CursorLinks links,
|
||||
required CursorMeta meta,
|
||||
}) = _PostFeedPaginationModel;
|
||||
|
||||
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||
const factory PostFeedItemModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'post_id') required int postId,
|
||||
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||
@JsonKey(name: 'post_title') required String postTitle,
|
||||
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: 'post_url') String? postUrl,
|
||||
@JsonKey(name: 'post_state') required String postState,
|
||||
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||
}) = _PostFeedItemModel;
|
||||
|
||||
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||
const factory FeedGalleryImage({
|
||||
required String url,
|
||||
required String orientation,
|
||||
}) = _FeedGalleryImage;
|
||||
|
||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||
const factory PostAuthorDetails({
|
||||
required String slug,
|
||||
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||
}) = _PostAuthorDetails;
|
||||
|
||||
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorLinks with _$CursorLinks {
|
||||
const factory CursorLinks({
|
||||
required String? first,
|
||||
required String? last,
|
||||
required String? prev,
|
||||
required String? next,
|
||||
}) = _CursorLinks;
|
||||
|
||||
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorMeta with _$CursorMeta {
|
||||
const factory CursorMeta({
|
||||
required String path,
|
||||
@JsonKey(name: 'per_page') required int perPage,
|
||||
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||
}) = _CursorMeta;
|
||||
|
||||
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||
}
|
||||
|
||||
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class FeedCubit extends Cubit<FeedState> {
|
||||
final FeedRepository feedRepository;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
String? _nextCursor;
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
|
||||
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
try {
|
||||
_query = query;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
||||
lastPage = feedBlogPosts.meta.lastPage;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
||||
isLoading = true;
|
||||
Future<void> loadNextPage({int? currentUserId}) async {
|
||||
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
||||
}
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
if (currentPage < lastPage && !isClosed) {
|
||||
currentPage++;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
||||
isLoading = false;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||
} else if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
query: query,
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans(query: query);
|
||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
@@ -9,8 +10,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -19,9 +22,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class BlogPostWidget extends StatefulWidget {
|
||||
final int index;
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
const BlogPostWidget({super.key, required this.index});
|
||||
const BlogPostWidget({super.key, required this.post});
|
||||
|
||||
@override
|
||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||
@@ -30,243 +33,378 @@ class BlogPostWidget extends StatefulWidget {
|
||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
late bool isLiked;
|
||||
late int totalLikes;
|
||||
bool isMoreOptionsVisible = false;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
FeedBlogPostModel get post => widget.post;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.post.id != post.id) {
|
||||
currentGalleryIndex = 0;
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
return;
|
||||
}
|
||||
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||
}
|
||||
|
||||
Future<void> _toggleLike() async {
|
||||
final nextLiked = !isLiked;
|
||||
setState(() {
|
||||
isLiked = nextLiked;
|
||||
nextLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
|
||||
if (nextLiked) {
|
||||
await context.read<FeedCubit>().likePost(post.id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
|
||||
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
|
||||
? state.feedBlogPostList[widget.index].author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Имя и дата внутри Expanded
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Кнопка подписки (если нужно)
|
||||
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
|
||||
!state.feedBlogPostList[widget.index].author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
|
||||
),
|
||||
// Кнопка меню
|
||||
CustomPopup(
|
||||
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
|
||||
id: state.feedBlogPostList[widget.index].id,
|
||||
feedBlogPostModel: state.feedBlogPostList[widget.index],
|
||||
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
state.feedBlogPostList[widget.index].title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.feedBlogPostList[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList();
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
// Update feed list immediately by ID (no reload)
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
|
||||
// Fire network call without introducing UI delay
|
||||
if (isLiked) {
|
||||
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
text: state.feedBlogPostList[widget.index].totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 15,
|
||||
width: 12,
|
||||
'assets/icons/repost.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
_BlogPostMediaContent(
|
||||
galleryUrls: galleryUrls,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
onOpenGallery: _openGallery,
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostHeader extends StatelessWidget {
|
||||
const _BlogPostHeader({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (post.title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
post.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
if (post.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
|
||||
),
|
||||
if (post.description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostMediaContent extends StatelessWidget {
|
||||
const _BlogPostMediaContent({
|
||||
required this.galleryUrls,
|
||||
required this.imageUrl,
|
||||
required this.videoEmbedUrl,
|
||||
required this.currentGalleryIndex,
|
||||
required this.onOpenGallery,
|
||||
required this.onGalleryChanged,
|
||||
required this.videoUrlThumb,
|
||||
});
|
||||
|
||||
final List<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
|
||||
Widget build(BuildContext context) {
|
||||
final mediaContentCount = videoEmbedUrl != null ? galleryUrls.length + 1 : galleryUrls.length;
|
||||
return Column(
|
||||
children: [
|
||||
if (galleryUrls.length > 1) ...[
|
||||
Stack(
|
||||
children: [
|
||||
CarouselSlider.builder(
|
||||
itemCount: galleryUrls.length + 1,
|
||||
itemBuilder: (context, itemIndex, _) {
|
||||
if (itemIndex == galleryUrls.length) {
|
||||
if (videoEmbedUrl != null) return FeedVideoPlayer(embedUrl: videoEmbedUrl!, videoUrlThumb: videoUrlThumb);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final itemUrl = galleryUrls[itemIndex];
|
||||
return GestureDetector(
|
||||
onTap: () => onOpenGallery(galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
options: CarouselOptions(viewportFraction: 1, height: 260, enableInfiniteScroll: false, onPageChanged: (index, _) => onGalleryChanged(index)),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: _GalleryPhotoCounter(currentIndex: currentGalleryIndex, total: mediaContentCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (galleryUrls.length == 1) ...[
|
||||
GestureDetector(
|
||||
onTap: () => onOpenGallery(galleryUrls, 0),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 260,
|
||||
imageUrl: galleryUrls.first,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
] else if (imageUrl.isNotEmpty) ...[
|
||||
GestureDetector(
|
||||
onTap: () => onOpenGallery([imageUrl], 0),
|
||||
child: Hero(
|
||||
tag: imageUrl,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryPhotoCounter extends StatelessWidget {
|
||||
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||
|
||||
final int currentIndex;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${currentIndex + 1} / $total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostActionsRow extends StatelessWidget {
|
||||
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool isLiked;
|
||||
final int totalLikes;
|
||||
final Future<void> Function() onToggleLike;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: onToggleLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||
text: post.totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
post.totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _truncateDescription(String description) {
|
||||
final isLong = description.length > 200;
|
||||
if (!isLong) return description;
|
||||
|
||||
var cutPoint = 200;
|
||||
final preview = description.substring(0, cutPoint);
|
||||
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
final hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
if (hasUnclosedBold) {
|
||||
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
final boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
return description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class BlogPosts extends StatelessWidget {
|
||||
const BlogPosts({super.key, required this.handleVisibility});
|
||||
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
child: BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
||||
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||
return false;
|
||||
},
|
||||
child: ListView.separated(
|
||||
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
|
||||
if (index == state.feedBlogPostList.length) {
|
||||
return const Loader();
|
||||
}
|
||||
final post = state.feedBlogPostList[index];
|
||||
return VisibilityDetector(
|
||||
key: ValueKey(post.id),
|
||||
onVisibilityChanged: (info) {
|
||||
final id = state.feedBlogPostList[index].id;
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
child: BlogPostWidget(index: index),
|
||||
child: BlogPostWidget(post: post),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else if (state is FeedStateError) {
|
||||
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
|
||||
return DataErrorWidget(
|
||||
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
);
|
||||
} else {
|
||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Embed-видео в ленте.
|
||||
///
|
||||
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||
///
|
||||
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/YouTube):
|
||||
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||
class FeedVideoPlayer extends StatefulWidget {
|
||||
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||
|
||||
final String embedUrl;
|
||||
|
||||
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
|
||||
final String? videoUrlThumb;
|
||||
|
||||
@override
|
||||
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
|
||||
}
|
||||
|
||||
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
static const _readyChannel = 'EmbedReady';
|
||||
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||
|
||||
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||
InAppWebViewInitialData? _initialData;
|
||||
bool _isLoading = false;
|
||||
bool _isReady = false;
|
||||
bool _hasError = false;
|
||||
|
||||
/// Страховка готовности для провайдеров без Rutube-протокола `player:canPlay`.
|
||||
Timer? _readyFallback;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_readyFallback?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
_initialData = null;
|
||||
_isLoading = false;
|
||||
_isReady = false;
|
||||
_hasError = false;
|
||||
}
|
||||
|
||||
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
|
||||
Uri _embedUriForPlayback(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final params = Map<String, String>.from(uri.queryParameters);
|
||||
params['autoplay'] = '1';
|
||||
params['mute'] = '1';
|
||||
params.putIfAbsent('playsinline', () => '1');
|
||||
return uri.replace(queryParameters: params);
|
||||
}
|
||||
|
||||
String _embedHtml(String embedUrl) {
|
||||
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="player"
|
||||
src="$src"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
></iframe>
|
||||
<script>
|
||||
(function () {
|
||||
function notify() {
|
||||
try { window.flutter_inappwebview.callHandler('$_readyChannel', 'ready'); } catch (e) {}
|
||||
}
|
||||
window.addEventListener('message', function(event) {
|
||||
try {
|
||||
const type = JSON.parse(event.data).type;
|
||||
console.log(type);
|
||||
if (type === 'player:canPlay') {
|
||||
notify();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
|
||||
void _markReady() {
|
||||
if (!mounted || _isReady || _hasError) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_isReady = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _markError() {
|
||||
if (!mounted || _isReady) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_initialData = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPlayPressed() async {
|
||||
if (_isLoading || _isReady || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final embedUri = _embedUriForPlayback(widget.embedUrl);
|
||||
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initialData = InAppWebViewInitialData(
|
||||
data: _embedHtml(embedUri.toString()),
|
||||
mimeType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
baseUrl: WebUri(baseUrl),
|
||||
);
|
||||
});
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||
}
|
||||
_markError();
|
||||
}
|
||||
}
|
||||
|
||||
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||
iframeAllowFullscreen: true,
|
||||
transparentBackground: false,
|
||||
);
|
||||
|
||||
void _onWebViewCreated(InAppWebViewController controller) {
|
||||
controller.addJavaScriptHandler(handlerName: _readyChannel, callback: (_) => _markReady());
|
||||
}
|
||||
|
||||
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||
// Если провайдер не шлёт Rutube-протокол `player:canPlay` (напр. YouTube),
|
||||
// покажем WebView по таймауту после полной загрузки.
|
||||
if (_isReady || _hasError || !mounted) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||
if (mounted && !_isReady && !_hasError) _markReady();
|
||||
});
|
||||
}
|
||||
|
||||
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||
if (!mounted) return;
|
||||
if (request.isForMainFrame != true) return;
|
||||
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||
if (_isReady) return;
|
||||
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||
_markError();
|
||||
}
|
||||
|
||||
void _onEnterFullscreen(InAppWebViewController _) {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
}
|
||||
|
||||
void _onExitFullscreen(InAppWebViewController _) {
|
||||
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = SizedBox(
|
||||
height: 220,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||
if (_initialData != null)
|
||||
Opacity(
|
||||
opacity: _isReady ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isReady,
|
||||
child: InAppWebView(
|
||||
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||
initialData: _initialData,
|
||||
initialSettings: _settings,
|
||||
onWebViewCreated: _onWebViewCreated,
|
||||
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||
onLoadStop: _onLoadStop,
|
||||
onReceivedError: _onReceivedError,
|
||||
onEnterFullscreen: _onEnterFullscreen,
|
||||
onExitFullscreen: _onExitFullscreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isReady)
|
||||
_VideoPreviewPlaceholder(
|
||||
previewUrl: widget.videoUrlThumb,
|
||||
isLoading: _isLoading,
|
||||
hasError: _hasError,
|
||||
onPlay: _onPlayPressed,
|
||||
onRetry: _onPlayPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
|
||||
if (_isReady) return player;
|
||||
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPreviewPlaceholder extends StatelessWidget {
|
||||
const _VideoPreviewPlaceholder({required this.previewUrl, required this.isLoading, required this.hasError, required this.onPlay, required this.onRetry});
|
||||
|
||||
final String? previewUrl;
|
||||
final bool isLoading;
|
||||
final bool hasError;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: previewUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
)
|
||||
else
|
||||
const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
if (hasError)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isLoading)
|
||||
const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
else
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: onPlay,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
@@ -13,9 +13,12 @@ class FeedRepository {
|
||||
|
||||
FeedRepository({required this.feedService});
|
||||
|
||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
||||
return (await feedService.getBlogPostList(page: page, query: query));
|
||||
|
||||
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||
final response = await feedService.getFeed(search: search, cursor: cursor);
|
||||
return (
|
||||
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
|
||||
nextCursor: response.meta.nextCursor,
|
||||
);
|
||||
}
|
||||
|
||||
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
@@ -18,6 +19,12 @@ abstract class FeedService {
|
||||
@GET('/api/blogPosts/index')
|
||||
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
||||
|
||||
@GET('/api/feed')
|
||||
Future<PostFeedPaginationModel> getFeed({
|
||||
@Query('search') String? search,
|
||||
@Query('cursor') String? cursor,
|
||||
});
|
||||
|
||||
@GET('/api/blogPosts/show/{postId}')
|
||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||
|
||||
|
||||
23
pubspec.yaml
23
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.24+106
|
||||
publish_to: 'none'
|
||||
publish_to: "none"
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -82,6 +82,7 @@ dependencies:
|
||||
rxdart: ^0.27.7
|
||||
gpassword: ^1.0.0
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
flutter_inappwebview: ^6.1.5
|
||||
intl_utils: ^2.8.13
|
||||
sentry_dio: ^9.10.0
|
||||
livekit_client: ^2.6.1
|
||||
@@ -165,14 +166,14 @@ flutter_intl:
|
||||
flutter_native_splash:
|
||||
android: true
|
||||
ios: true
|
||||
color: '#FFFFFF'
|
||||
image_android: 'assets/splash/splash.png'
|
||||
image_dark_android: 'assets/splash/splash.png'
|
||||
image_ios: 'assets/splash/splash.png'
|
||||
image_dark_ios: 'assets/splash/splash.png'
|
||||
branding_android: 'assets/splash/branding.png'
|
||||
branding_dark_android: 'assets/splash/branding.png'
|
||||
color: "#FFFFFF"
|
||||
image_android: "assets/splash/splash.png"
|
||||
image_dark_android: "assets/splash/splash.png"
|
||||
image_ios: "assets/splash/splash.png"
|
||||
image_dark_ios: "assets/splash/splash.png"
|
||||
branding_android: "assets/splash/branding.png"
|
||||
branding_dark_android: "assets/splash/branding.png"
|
||||
android_12:
|
||||
image: 'assets/splash/splash.png'
|
||||
image_dark: 'assets/splash/splash.png'
|
||||
branding: 'assets/splash/branding.png'
|
||||
image: "assets/splash/splash.png"
|
||||
image_dark: "assets/splash/splash.png"
|
||||
branding: "assets/splash/branding.png"
|
||||
|
||||
56
test/features/feed/models/post_feed_mapper_test.dart
Normal file
56
test/features/feed/models/post_feed_mapper_test.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedMapper', () {
|
||||
setUpAll(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
|
||||
test('maps fixture item post_id to FeedBlogPostModel.id', () {
|
||||
final item = FeedFixtures.postFeedItem();
|
||||
final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.author.authorIsMe, isTrue);
|
||||
expect(model.author.authorSlug, 'anna-ivanova');
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, isNull);
|
||||
expect(model.title, 'Завтрак с овсянкой');
|
||||
expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg');
|
||||
expect(model.author.isEntitySubscribedByMe, isFalse);
|
||||
});
|
||||
|
||||
test('maps fixture embed video post and likes', () {
|
||||
final item = FeedFixtures.postFeedItem(index: 1);
|
||||
final model = PostFeedMapper.fromPostFeedItem(item);
|
||||
|
||||
expect(model.id, 2);
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(model.wasLikedByMe, isTrue);
|
||||
expect(model.totalLikes, 42);
|
||||
expect(model.author.authorIsMe, isFalse);
|
||||
expect(model.author.isEntitySubscribedByMe, isTrue);
|
||||
});
|
||||
|
||||
test('maps all fixture posts', () {
|
||||
final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11);
|
||||
|
||||
expect(posts, hasLength(3));
|
||||
expect(posts[1].author.authorIsMe, isTrue);
|
||||
expect(posts[1].author.isEntitySubscribedByMe, isTrue);
|
||||
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts[2].author.isEntitySubscribedByMe, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedPaginationModel fixtures', () {
|
||||
test('parses first page with gallery, video and next_cursor', () {
|
||||
final model = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
expect(model.data, hasLength(3));
|
||||
expect(model.meta.perPage, 20);
|
||||
expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.meta.prevCursor, isNull);
|
||||
expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ'));
|
||||
|
||||
final withGallery = model.data[0];
|
||||
expect(withGallery.postId, 1);
|
||||
expect(withGallery.images, hasLength(2));
|
||||
expect(withGallery.images.first.orientation, 'horizontal');
|
||||
expect(withGallery.videoEmbedUrl, isNull);
|
||||
expect(withGallery.isEntitySubscribedByMe, isFalse);
|
||||
expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova');
|
||||
expect(withGallery.postAuthorDetails?.isConsultant, isTrue);
|
||||
|
||||
final withYoutubeEmbed = model.data[1];
|
||||
expect(withYoutubeEmbed.postId, 2);
|
||||
expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(withYoutubeEmbed.postIsLikedByUser, isTrue);
|
||||
expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue);
|
||||
expect(withYoutubeEmbed.images, isEmpty);
|
||||
|
||||
final withRutubeEmbed = model.data[2];
|
||||
expect(withRutubeEmbed.postId, 3);
|
||||
expect(withRutubeEmbed.postImageUrl, isNull);
|
||||
expect(withRutubeEmbed.images, hasLength(1));
|
||||
expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); });
|
||||
|
||||
test('parses next page without next_cursor', () {
|
||||
final model = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
expect(model.data, hasLength(1));
|
||||
expect(model.data.first.postId, 4);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
|
||||
test('parses empty feed', () {
|
||||
final model = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
|
||||
expect(model.data, isEmpty);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
@@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late FeedBlogPostSettingsModel feedBlogPostsFixture;
|
||||
group('FeedCubit cursor flow', () {
|
||||
late FeedCubit cubit;
|
||||
late MockFeedRepository repository;
|
||||
|
||||
final post = FeedBlogPostModel(
|
||||
id: 10,
|
||||
state: 'active',
|
||||
image: '',
|
||||
description: 'description',
|
||||
author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'title',
|
||||
totalLikes: 1,
|
||||
totalViews: 1,
|
||||
totalComments: 1,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
|
||||
// Создаём тестовые данные
|
||||
final testPosts = [
|
||||
FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image1.jpg',
|
||||
description: 'Описание поста 1',
|
||||
author: const Author(
|
||||
authorSlug: 'author-1',
|
||||
authorName: 'Автор 1',
|
||||
authorProfileIcon: 'https://example.com/photo1.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 1',
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
),
|
||||
FeedBlogPostModel(
|
||||
id: 2,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
description: 'Описание поста 2',
|
||||
author: const Author(
|
||||
authorSlug: 'author-2',
|
||||
authorName: 'Автор 2',
|
||||
authorProfileIcon: 'https://example.com/photo2.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 2',
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
),
|
||||
];
|
||||
|
||||
feedBlogPostsFixture = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: testPosts,
|
||||
meta: const Meta(
|
||||
currentPage: 1,
|
||||
from: null,
|
||||
lastPage: 3,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 25,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - FeedStateLoading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('error state on load failure', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateError>());
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - успешная загрузка постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
test('updateFeedBlogPostList updates likes in loaded state', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null));
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
cubit.updateFeedBlogPostList(post, true);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загрузка с запросом', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'тест';
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обработка ошибки', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception'));
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновление лайка поста', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Сначала загружаем посты
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
// Устанавливаем состояние loaded вручную для теста
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, true);
|
||||
expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - снятие лайка', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true
|
||||
final initialLikes = postToUpdate.totalLikes;
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, false);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, false);
|
||||
expect(updatedPost.totalLikes, initialLikes - 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Состояние loading
|
||||
feedCubit!.emit(const FeedState.loading());
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
// Состояние не должно измениться
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
// Создаём пост с несуществующим ID
|
||||
final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999);
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
|
||||
// Список не должен измениться
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loaded.feedBlogPostList.first.totalLikes, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,404 +1,66 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late MockFeedRepository repository;
|
||||
late FeedCubit cubit;
|
||||
|
||||
final testBlogPost = FeedBlogPostModel(
|
||||
final post = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post',
|
||||
description: 'Test description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
state: 'active',
|
||||
image: 'https://example.com/image.png',
|
||||
description: 'text',
|
||||
author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 1,
|
||||
totalViews: 2,
|
||||
totalComments: 3,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
final testBlogPost2 = FeedBlogPostModel(
|
||||
id: 2,
|
||||
title: 'Test Post 2',
|
||||
description: 'Test description 2',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video2.mp4',
|
||||
createdAt: DateTime(2024, 1, 16),
|
||||
updatedAt: DateTime(2024, 1, 16),
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - loading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('loadFeedBlogPostList emits loaded from getFeed', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateLoaded>());
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(1));
|
||||
verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - успешно загружает список постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
test('loadNextPage appends posts', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings);
|
||||
await cubit.loadFeedBlogPostList();
|
||||
await cubit.loadNextPage();
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 1);
|
||||
expect(loadedState.feedBlogPostList.first.id, 1);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загружает список постов с query', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обрабатывает ошибку', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - загружает следующую страницу', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(feedCubit!.currentPage, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже на последней странице', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже загружается', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.isLoading = true;
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final exception = Exception('Network error');
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - сохраняет query при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновляет лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 11);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - убирает лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost2, false);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, false);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 19);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если пост не найден', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final nonExistentPost = testBlogPost.copyWith(id: 999);
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если state не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если post null', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(null, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('likePost - лайкает пост', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('likePost - обрабатывает ошибку без выбрасывания', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('unlikedPost - убирает лайк с поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.unlikedPost(postId);
|
||||
|
||||
verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPost - отправляет просмотр поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPost(postId);
|
||||
|
||||
verify(mockFeedRepository.viewPost(postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewRecipe - отправляет просмотр рецепта', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'recipe-1';
|
||||
|
||||
when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewRecipe(slug);
|
||||
|
||||
verify(mockFeedRepository.viewRecipe(slug)).called(1);
|
||||
});
|
||||
|
||||
test('viewDiet - отправляет просмотр диеты', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const dietId = 2;
|
||||
|
||||
when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewDiet(dietId);
|
||||
|
||||
verify(mockFeedRepository.viewDiet(dietId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPlan - отправляет просмотр плана', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'plan-1';
|
||||
|
||||
when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPlan(slug);
|
||||
|
||||
verify(mockFeedRepository.viewPlan(slug)).called(1);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(2));
|
||||
verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_posts.dart';
|
||||
@@ -378,12 +376,8 @@ void main() {
|
||||
// Arrange
|
||||
feedCubit.emit(FeedState.loaded(feedBlogPostList: [testBlogPost], isLoading: false));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer(
|
||||
(_) async => FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
),
|
||||
);
|
||||
when(mockFeedRepository.getFeed(search: null, cursor: null, currentUserId: null))
|
||||
.thenAnswer((_) async => (posts: [testBlogPost, testBlogPost2], nextCursor: null));
|
||||
|
||||
final widget = BlogPosts(handleVisibility: ({required visibleFraction, required id, required type}) {});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ void main() {
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(const BlogPostWidget(index: 0))));
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(BlogPostWidget(post: mockBlogPost))));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
@@ -11,9 +13,11 @@ import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'feed_repository_test.mocks.dart';
|
||||
|
||||
@@ -23,72 +27,57 @@ void main() {
|
||||
late FeedRepository feedRepository;
|
||||
late MockFeedService mockFeedService;
|
||||
|
||||
setUp(() {
|
||||
setUp(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
mockFeedService = MockFeedService();
|
||||
feedRepository = FeedRepository(feedService: mockFeedService);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - успешное получение постов', () async {
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], path: null, perPage: 10, to: null, total: 0, totalMinPrice: null, totalMaxPrice: null),
|
||||
);
|
||||
test('getFeed - успешное получение постов', () async {
|
||||
final expectedModel = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts();
|
||||
final result = await feedRepository.getFeed();
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: 1, query: null)).called(1);
|
||||
expect(result.posts, hasLength(3));
|
||||
expect(result.posts.first.id, 1);
|
||||
expect(result.posts[1].videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(result.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - с запросом и страницей', () async {
|
||||
test('getFeed - с запросом и курсором', () async {
|
||||
const query = 'тест';
|
||||
const page = 2;
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(
|
||||
currentPage: page,
|
||||
from: null,
|
||||
lastPage: 2,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 15,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expectedModel = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: page, query: query)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getFeed(search: query, cursor: cursor)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts(query: query, page: page);
|
||||
final result = await feedRepository.getFeed(search: query, cursor: cursor);
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: query)).called(1);
|
||||
expect(result.posts, hasLength(1));
|
||||
expect(result.posts.first.id, 4);
|
||||
expect(result.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: query, cursor: cursor)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - возвращает список постов без query', () async {
|
||||
const page = 1;
|
||||
const expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, lastPage: 1, total: 0, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
test('getFeed - пустая лента', () async {
|
||||
final expectedModel = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: page, query: null)).thenAnswer((_) async => expectedSettings);
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts(page: page);
|
||||
final result = await feedRepository.getFeed();
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
expect(result.feedBlogPostList.isEmpty, true);
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
expect(result.posts, isEmpty);
|
||||
expect(result.nextCursor, isNull);
|
||||
});
|
||||
|
||||
test('getBlogPost - успешное получение поста', () async {
|
||||
const postId = 1;
|
||||
final json = FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
final expectedPost = FeedBlogPostModel.fromJson(json);
|
||||
final expectedPost = FeedFixtures.feedBlogPostModel();
|
||||
|
||||
when(mockFeedService.getBlogPost(postId: postId)).thenAnswer((_) async => expectedPost);
|
||||
|
||||
@@ -529,10 +518,10 @@ void main() {
|
||||
verify(mockFeedService.viewContent(viewModel: viewModel)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenThrow(Exception('Ошибка сети'));
|
||||
test('getFeed - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenThrow(Exception('Ошибка сети'));
|
||||
|
||||
expect(() => feedRepository.getFeedBlogPosts(), throwsException);
|
||||
expect(() => feedRepository.getFeed(), throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import 'feed_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedService>()])
|
||||
@@ -55,6 +56,32 @@ void main() {
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns cursor response', () async {
|
||||
final expected = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: null);
|
||||
|
||||
expect(result.data, hasLength(3));
|
||||
expect(result.meta.perPage, 20);
|
||||
expect(result.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns next page without cursor', () async {
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expected = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: cursor)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: cursor);
|
||||
|
||||
expect(result.data, hasLength(1));
|
||||
expect(result.meta.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: null, cursor: cursor)).called(1);
|
||||
});
|
||||
|
||||
test('getBlogPost returns post', () async {
|
||||
const id = 10;
|
||||
final expected = FeedBlogPostModel(
|
||||
|
||||
15
test/fixtures/feed/post_feed_empty_response.json
vendored
Normal file
15
test/fixtures/feed/post_feed_empty_response.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"data": [],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
45
test/fixtures/feed/post_feed_next_page_response.json
vendored
Normal file
45
test/fixtures/feed/post_feed_next_page_response.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 104,
|
||||
"post_id": 4,
|
||||
"post_author_id": 13,
|
||||
"post_author_name": "Олег Кузнецов",
|
||||
"post_author_image_url": "/storage/avatars/oleg.png",
|
||||
"post_author_profile_url": "/ru/profiles/oleg-kuznetsov",
|
||||
"post_created_at": "2024-06-04T11:00:00.000000Z",
|
||||
"post_title": "Ужин за 20 минут",
|
||||
"post_short_description": "Быстрый ужин после работы",
|
||||
"post_text_md": "Курица с овощами на сковороде.",
|
||||
"post_image_url": "/storage/posts/dinner.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": null,
|
||||
"post_views_count": 88,
|
||||
"post_likes_count": 7,
|
||||
"post_comments_count": 2,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/4",
|
||||
"video_url_thumb": null,
|
||||
"post_state": "active",
|
||||
"post_author_detailz_json": {
|
||||
"slug": "oleg-kuznetsov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-04T11:05:00.000000Z",
|
||||
"updated_at": "2024-06-04T11:05:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ",
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": "eyJpZCI6MTAzfQ"
|
||||
}
|
||||
}
|
||||
115
test/fixtures/feed/post_feed_response.json
vendored
Normal file
115
test/fixtures/feed/post_feed_response.json
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"post_id": 1,
|
||||
"post_author_id": 10,
|
||||
"post_author_name": "Анна Иванова",
|
||||
"post_author_image_url": "/storage/avatars/anna.png",
|
||||
"post_author_profile_url": "/ru/profiles/anna-ivanova",
|
||||
"post_created_at": "2024-06-01T10:00:00.000000Z",
|
||||
"post_title": "Завтрак с овсянкой",
|
||||
"post_short_description": "Простой и полезный завтрак",
|
||||
"post_text_md": "Сегодня приготовила **овсянку** с ягодами.",
|
||||
"post_image_url": "/storage/posts/oatmeal.jpg",
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-1.jpg",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-2.jpg",
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"video_embed_url": "https://rutube.ru/play/embed/08c00eb3a54c056aeca34f58d8fd1db8/",
|
||||
"post_views_count": 120,
|
||||
"post_likes_count": 15,
|
||||
"post_comments_count": 4,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/1",
|
||||
"post_state": "active",
|
||||
"video_url_thumb": "https://mike-dev-ach5iekohk4n.eatv.io/storage/3409/20260720-96266cae-5852-40c9-9692-0a4047e8c934.png",
|
||||
"post_author_detailz_json": {
|
||||
"slug": "anna-ivanova",
|
||||
"is_consultant": true
|
||||
},
|
||||
"created_at": "2024-06-01T10:05:00.000000Z",
|
||||
"updated_at": "2024-06-01T10:05:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"post_id": 2,
|
||||
"post_author_id": 11,
|
||||
"post_author_name": "Игорь Петров",
|
||||
"post_author_image_url": "/storage/avatars/igor.png",
|
||||
"post_author_profile_url": "/ru/profiles/igor-petrov",
|
||||
"post_created_at": "2024-06-02T08:30:00.000000Z",
|
||||
"post_title": "Тренировка дома",
|
||||
"post_short_description": "Короткая тренировка без оборудования",
|
||||
"post_text_md": "15 минут кардио и растяжка.",
|
||||
"post_image_url": "/storage/posts/workout.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"post_views_count": 340,
|
||||
"post_likes_count": 42,
|
||||
"post_comments_count": 9,
|
||||
"post_is_liked_by_user": true,
|
||||
"is_entity_subscribed_by_me": true,
|
||||
"post_url": "/ru/blog_post/2",
|
||||
"post_state": "active",
|
||||
"post_author_detailz_json": {
|
||||
"slug": "igor-petrov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-02T08:35:00.000000Z",
|
||||
"updated_at": "2024-06-02T09:00:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"post_id": 3,
|
||||
"post_author_id": 12,
|
||||
"post_author_name": "Мария Смирнова",
|
||||
"post_author_image_url": null,
|
||||
"post_author_profile_url": "/ru/profiles/maria-smirnova",
|
||||
"post_created_at": "2024-06-03T14:15:00.000000Z",
|
||||
"post_title": "Рецепт смузи",
|
||||
"post_short_description": null,
|
||||
"post_text_md": null,
|
||||
"post_image_url": null,
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/smoothie.jpg",
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"video_embed_url": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"post_views_count": 50,
|
||||
"post_likes_count": 3,
|
||||
"post_comments_count": 1,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": null,
|
||||
"post_url": "/ru/blog_post/3",
|
||||
"post_state": "active",
|
||||
"post_author_detailz_json": {
|
||||
"slug": "maria-smirnova",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-03T14:20:00.000000Z",
|
||||
"updated_at": "2024-06-03T14:20:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ"
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": "eyJpZCI6MTAzfQ",
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
51
test/fixtures/feed_fixtures.dart
vendored
Normal file
51
test/fixtures/feed_fixtures.dart
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
|
||||
import 'fixture_reader.dart';
|
||||
|
||||
class FeedFixtures {
|
||||
FeedFixtures._();
|
||||
|
||||
static Map<String, dynamic> postFeedResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedNextPageResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_next_page_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedEmptyResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_empty_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedNextPagePaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedNextPageResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedEmptyPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedEmptyResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedItemModel postFeedItem({int index = 0}) {
|
||||
return postFeedPaginationModel().data[index];
|
||||
}
|
||||
|
||||
static List<FeedBlogPostModel> mappedFeedPosts({int? currentUserId}) {
|
||||
return postFeedPaginationModel().data
|
||||
.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostModel() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user