feat(personalized_feed): added fixtures for new feed, added new feed parsing and layout, added webview player for post videos

This commit is contained in:
misha
2026-07-20 16:47:19 +03:00
parent bd2de07b8f
commit 48a984f5e9
23 changed files with 1330 additions and 903 deletions

View File

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

View 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';
}
}

View 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();

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
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:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
/// Embed-видео в ленте.
///
/// 1) Пока пользователь не нажал play — только превью (без WebView).
/// 2) После play — превью + лоадер, WebView грузится скрыто.
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
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';
WebViewController? _controller;
bool _isLoading = false;
bool _isReady = false;
bool _hasError = false;
@override
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
_reset();
}
}
@override
void dispose() {
_controller = null;
super.dispose();
}
void _reset() {
_controller = 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;"
allowfullscreen=true
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
(function () {
var iframe = document.getElementById('player');
function notify() {
try { $_readyChannel.postMessage('ready'); } catch (e) {}
}
window.addEventListener('message', function(event) {
console.log(JSON.parse(event.data).type);
const type = JSON.parse(event.data).type;
if (type === 'player:canPlay') {
notify();
}
});
})();
</script>
</body>
</html>
''';
}
void _markReady() {
if (!mounted || _isReady || _hasError) return;
setState(() {
_isReady = true;
_isLoading = false;
});
}
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}/';
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
// Жест play уже был — разрешаем media без дополнительного тапа.
params = WebKitWebViewControllerCreationParams(allowsInlineMediaPlayback: true, mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{});
} else {
params = const PlatformWebViewControllerCreationParams();
}
final controller = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel(_readyChannel, onMessageReceived: (_) => _markReady())
..setOnConsoleMessage((message) {
log('FeedVideoPlayer console message: ${message.message}');
})
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (error) {
if (!mounted) return;
if (error.isForMainFrame != true) return;
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
if (_isReady) return;
setState(() {
_hasError = true;
_isLoading = false;
_controller = null;
});
},
),
);
final platform = controller.platform;
if (platform is AndroidWebViewController) {
// Жест уже был на кнопке play.
await platform.setMediaPlaybackRequiresUserGesture(false);
}
await controller.loadHtmlString(_embedHtml(embedUri.toString()), baseUrl: baseUrl);
if (!mounted) return;
setState(() => _controller = controller);
} catch (e, st) {
if (kDebugMode) {
debugPrint('FeedVideoPlayer load failed: $e\n$st');
}
if (mounted) {
setState(() {
_hasError = true;
_isLoading = false;
_controller = null;
});
}
}
}
@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 (_controller != null)
Opacity(
opacity: _isReady ? 1 : 0,
child: IgnorePointer(
ignoring: !_isReady,
child: WebViewWidget(key: ValueKey('feed-webview-${widget.embedUrl}'), controller: _controller!),
),
),
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),
),
),
),
],
);
}
}

View File

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

View File

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