feat(personalized_feed): fixed photos rendering and fixed profile posts rendering

This commit is contained in:
misha
2026-07-23 15:17:01 +03:00
parent c8c8ade083
commit a6fe6afb80
13 changed files with 491 additions and 406 deletions

View File

@@ -257,13 +257,9 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
return const SizedBox.shrink();
}
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final galleryUrls = post.images
.map((e) => PostFeedMapper.resolveMediaUrl(e.url))
.where((e) => e.isNotEmpty)
.toList();
final embedUrl = post.videoEmbedUrl?.trim();
return BlogPostMediaContent(
galleryUrls: galleryUrls,
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
import 'package:flutter/material.dart';
@@ -9,7 +11,7 @@ import 'package:flutter/material.dart';
class BlogPostMediaContent extends StatefulWidget {
const BlogPostMediaContent({
super.key,
required this.galleryUrls,
required this.galleryImages,
required this.imageUrl,
required this.videoEmbedUrl,
required this.currentGalleryIndex,
@@ -18,7 +20,7 @@ class BlogPostMediaContent extends StatefulWidget {
required this.videoUrlThumb,
});
final List<String> galleryUrls;
final List<FeedGalleryImage> galleryImages;
final String imageUrl;
final String? videoEmbedUrl;
final String? videoUrlThumb;
@@ -32,16 +34,36 @@ class BlogPostMediaContent extends StatefulWidget {
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1);
static const _carouselHeight = 260.0;
/// Landscape / video slide aspect (width / height).
static const _horizontalAspectRatio = 16 / 9;
/// Portrait slide aspect (width / height).
static const _verticalAspectRatio = 3 / 4;
bool _isCounterVisible = false;
Timer? _counterHideTimer;
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
List<FeedGalleryImage> get _gallery {
return [
for (final image in widget.galleryImages)
if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty)
FeedGalleryImage(
url: url,
orientation: image.orientation,
id: image.id,
position: image.position,
),
];
}
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
int get _slideCount {
if (widget.galleryUrls.isNotEmpty) {
return widget.galleryUrls.length + (_hasVideo ? 1 : 0);
if (_gallery.isNotEmpty) {
return _gallery.length + (_hasVideo ? 1 : 0);
}
if (_hasVideo) {
return 1;
@@ -49,10 +71,24 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return 0;
}
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length;
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length;
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
/// Height is fixed for the whole post from the first gallery image (web-like).
String get _postOrientation {
if (_gallery.isNotEmpty) {
return _gallery.first.orientation;
}
return 'horizontal';
}
double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
return (width / aspectRatio).clamp(180.0, maxHeight);
}
@override
void dispose() {
_counterHideTimer?.cancel();
@@ -90,13 +126,13 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return false;
}
Widget _imageSlide(String itemUrl, int itemIndex) {
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
return GestureDetector(
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
height: _carouselHeight,
width: MediaQuery.sizeOf(context).width,
height: height,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
@@ -107,7 +143,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
}
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) {
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) {
return Stack(
children: [
NotificationListener<ScrollNotification>(
@@ -117,7 +153,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
options: CarouselOptions(
viewportFraction: 1,
height: _carouselHeight,
height: height,
enableInfiniteScroll: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index),
),
@@ -141,13 +177,18 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
@override
Widget build(BuildContext context) {
if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) {
final gallery = _gallery;
final width = MediaQuery.sizeOf(context).width;
final mediaHeight = _heightForOrientation(_postOrientation, width);
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
final itemCount = _slideCount;
return _carousel(
itemCount: itemCount,
height: mediaHeight,
itemBuilder: (context, itemIndex) {
if (itemIndex < widget.galleryUrls.length) {
return _imageSlide(widget.galleryUrls[itemIndex], itemIndex);
if (itemIndex < gallery.length) {
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
}
if (_hasVideo) {
return _videoSlide();
@@ -157,14 +198,14 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
);
}
if (widget.galleryUrls.length == 1) {
return _imageSlide(widget.galleryUrls.first, 0);
if (gallery.length == 1) {
return _imageSlide(gallery.first.url, 0, mediaHeight);
}
if (_hasVideo) {
return SizedBox(
height: _carouselHeight,
width: MediaQuery.of(context).size.width,
height: mediaHeight,
width: width,
child: _videoSlide(),
);
}
@@ -176,7 +217,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
tag: widget.imageUrl,
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),

View File

@@ -78,7 +78,6 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
@override
Widget build(BuildContext context) {
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 InkWell(
@@ -95,7 +94,7 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
_BlogPostHeader(post: post),
_BlogPostTextContent(post: post),
BlogPostMediaContent(
galleryUrls: galleryUrls,
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb,

View File

@@ -169,9 +169,8 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
@override
Widget build(BuildContext context) {
final player = SizedBox(
height: 220,
width: MediaQuery.sizeOf(context).width,
final player = ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -22,10 +22,13 @@ class FeedVideoPreviewPlaceholder extends StatelessWidget {
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
if (previewUrl != null && previewUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: previewUrl!,
fit: BoxFit.cover,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
)
else

View File

@@ -1,19 +1,21 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
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/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
@@ -31,18 +33,30 @@ class ProfileBlogPostWidget extends StatefulWidget {
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
late bool isLiked;
late int totalLikes;
int currentGalleryIndex = 0;
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileCubit, ProfileState>(
bloc: widget.profileCubit,
builder: (BuildContext context, ProfileState state) {
if (state is ProfileStateLoaded) {
isLiked = state.blogPosts[widget.index].wasLikedByMe;
totalLikes = state.blogPosts[widget.index].totalLikes;
if (state is! ProfileStateLoaded) {
return const SizedBox.shrink();
}
final FeedBlogPostModel post = state.blogPosts[widget.index];
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
child: Ink(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
@@ -58,9 +72,9 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
? state.blogPosts[widget.index].author.authorProfileIcon
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
post.author.authorProfileIcon.contains('https')
? post.author.authorProfileIcon
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon,
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
@@ -69,89 +83,50 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.blogPosts[widget.index].author.authorName,
post.author.authorName,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
'${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),
),
],
),
const Spacer(),
CustomPopup(
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
isAuthor: post.author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
id: state.blogPosts[widget.index].id,
feedBlogPostModel: state.blogPosts[widget.index],
userSlug: state.blogPosts[widget.index].author.authorSlug,
path: 'blog_post/${post.id}',
id: post.id,
feedBlogPostModel: post,
userSlug: post.author.authorSlug,
),
],
),
),
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
if (post.title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
state.blogPosts[widget.index].title,
post.title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
),
),
],
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
if (state.blogPosts[widget.index].description.isNotEmpty)
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: Builder(
builder: (context) {
String description = state.blogPosts[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);
}
return MarkdownHtmlWidget(
markdownText: description,
child: MarkdownHtmlWidget(
markdownText: _truncateDescription(post.description),
fontSize: 16,
textColor: Colors.black87,
);
},
),
),
if (state.blogPosts[widget.index].description.length > 200)
if (post.description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
@@ -159,23 +134,19 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
GestureDetector(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
);
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery,
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
child: Hero(
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
),
const SizedBox(height: 12),
Padding(
@@ -193,10 +164,8 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--;
});
isLiked
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id);
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true);
},
),
const SizedBox(width: 8),
@@ -207,8 +176,8 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: state.blogPosts[widget.index].totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
),
const SizedBox(width: 8),
FeedIconButton(
@@ -222,7 +191,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
@@ -232,7 +201,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
state.blogPosts[widget.index].totalViews.toString(),
post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
@@ -243,9 +212,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
),
),
);
}
return const SizedBox.shrink();
},
);
}
}
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

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.35+124
version: 1.3.36+125
publish_to: 'none'
environment:
@@ -171,14 +171,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'

View File

@@ -150,6 +150,19 @@ void main() {
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
});
test('parses profile search_by_author fixture with gallery and video', () {
final posts = FeedFixtures.blogPostsByAuthor();
expect(posts, hasLength(2));
expect(posts.first.images, hasLength(2));
expect(posts.first.images.first.orientation, 'horizontal');
expect(posts.first.images[1].orientation, 'vertical');
expect(posts.first.videoEmbedUrl, contains('rutube.ru/play/embed'));
expect(posts.first.videoUrlThumb, 'media/1/thumb.jpg');
expect(posts[1].images, hasLength(2));
expect(posts[1].videoEmbedUrl, isNull);
});
test('creates FeedBlogPostModel with different states', () {
const author = Author(
authorSlug: 'author',

View File

@@ -10,6 +10,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../../fixtures/feed_fixtures.dart';
import 'profile_cubit_test.mocks.dart';
@GenerateNiceMocks([
@@ -267,20 +268,10 @@ void main() {
authorPlaceInRating: null,
);
final blogPost = FeedBlogPostModel(
id: 1,
state: 'published',
title: 'Test Post',
description: 'Test Content',
image: 'https://example.com/image.jpg',
final blogPost = FeedFixtures.feedBlogPostGalleryAndVideo().copyWith(
author: author,
videoUrl: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
totalLikes: 10,
totalComments: 5,
totalViews: 100,
wasLikedByMe: false,
totalLikes: 10,
);
const profileDetails = ProfileDetailsModel(

View File

@@ -5,20 +5,20 @@ 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/theme/theme_helper.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:eatvio_app/generated/l10n.dart';
import '../../../../fixtures/feed_fixtures.dart';
import 'profile_blog_post_widget_smoke_test.mocks.dart';
class _FakeHttpClient implements HttpClient {
@@ -35,7 +35,6 @@ class _FakeHttpClient implements HttpClient {
@override
Future<HttpClientRequest> getUrl(Uri url) async => _FakeHttpClientRequest();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -51,73 +50,10 @@ class _FakeHttpClientRequest implements HttpClientRequest {
class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientResponse {
// 1x1 transparent PNG.
static final List<int> _png = <int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
@override
@@ -150,29 +86,6 @@ void main() {
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
});
testWidgets('renders blog post card', (tester) async {
final post = FeedBlogPostModel(
id: 1,
state: 'published',
image: 'test.png',
description: 'Hello',
author: const Author(
authorSlug: 'author',
authorName: 'Author',
authorProfileIcon: 'https://example.com/icon.png',
authorIsMe: false,
isEntitySubscribedByMe: false,
),
videoUrl: '',
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 1),
title: 'Title',
totalLikes: 0,
totalViews: 0,
totalComments: 0,
wasLikedByMe: false,
);
const profile = ProfileModel(
id: 1,
name: 'User',
@@ -219,12 +132,7 @@ void main() {
activityCoefficient: 1.5,
);
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
Future<void> pumpPost(WidgetTester tester, ProfileCubit cubit) async {
await HttpOverrides.runZoned(() async {
await tester.pumpWidget(
MaterialApp(
@@ -236,15 +144,60 @@ void main() {
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: ProfileBlogPostWidget(index: 0, profileCubit: cubit)),
home: Scaffold(
body: MediaQuery(
data: const MediaQueryData(size: Size(800, 1600)),
child: SingleChildScrollView(
child: ProfileBlogPostWidget(index: 0, profileCubit: cubit),
),
),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 200));
}, createHttpClient: (context) => _FakeHttpClient());
}, createHttpClient: (_) => _FakeHttpClient());
}
expect(find.text('Регистрация'), findsNothing);
expect(find.text('Title'), findsOneWidget);
expect(find.text('Author'), findsOneWidget);
testWidgets('renders gallery and video media content', (tester) async {
final post = FeedFixtures.feedBlogPostGalleryAndVideo();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await pumpPost(tester, cubit);
expect(find.byType(BlogPostMediaContent), findsOneWidget);
expect(find.textContaining('Галерея и видео'), findsWidgets);
});
testWidgets('renders gallery-only media content', (tester) async {
final post = FeedFixtures.feedBlogPostWithGallery();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await pumpPost(tester, cubit);
expect(find.byType(BlogPostMediaContent), findsOneWidget);
expect(find.text('Галерея'), findsOneWidget);
});
testWidgets('renders video-only media content', (tester) async {
final post = FeedFixtures.feedBlogPostVideoOnly();
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
final cubit = MockProfileCubit();
when(cubit.state).thenReturn(loadedState);
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
await pumpPost(tester, cubit);
expect(find.byType(BlogPostMediaContent), findsOneWidget);
expect(find.text('Видео'), findsOneWidget);
});
});
}

View File

@@ -3,9 +3,11 @@ import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/features/profile/services/profile_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/feed_fixtures.dart';
import 'profile_service_test.mocks.dart';
@GenerateNiceMocks([MockSpec<ProfileService>()])
@@ -72,9 +74,17 @@ void main() {
expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA<ProfileDetailsModel>());
});
test('getBlogPostByAuthorSlug', () async {
when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []);
expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA<List<FeedBlogPostModel>>());
test('getBlogPostByAuthorSlug returns posts with gallery and video fields', () async {
final posts = FeedFixtures.blogPostsByAuthor();
when(mockProfileService.getBlogPostByAuthorSlug(slug: 'slug')).thenAnswer((_) async => posts);
final result = await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug');
expect(result, isA<List<FeedBlogPostModel>>());
expect(result, hasLength(2));
expect(result.first.images, isNotEmpty);
expect(result.first.videoEmbedUrl, isNotNull);
expect(result.first.videoUrlThumb, isNotNull);
});
test('getWeight', () async {

View File

@@ -72,4 +72,10 @@ class FeedFixtures {
static FeedBlogPostModel feedBlogPostGalleryAndVideo() {
return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson());
}
static List<FeedBlogPostModel> blogPostsByAuthor() {
return FixtureReader.readAsList('profile/blog_posts_by_author_response.json')
.map((item) => FeedBlogPostModel.fromJson(item as Map<String, dynamic>))
.toList();
}
}

View File

@@ -0,0 +1,80 @@
[
{
"id": 1,
"state": "active",
"image": "media/1/cover.jpg",
"images": [
{
"id": 10,
"url": "media/1/a.jpg",
"position": 1,
"orientation": "horizontal"
},
{
"id": 11,
"url": "media/1/b.jpg",
"position": 2,
"orientation": "vertical"
}
],
"description": "Описание поста профиля",
"author": {
"author_slug": "test-user",
"author_name": "Test User",
"author_profile_icon": "https://example.com/photo.jpg",
"author_is_me": true,
"is_entity_subscribed_by_me": false,
"author_place_in_rating": null
},
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
"video_url_thumb": "media/1/thumb.jpg",
"created_at": "2024-01-15T12:00:00Z",
"updated_at": "2024-01-15T12:00:00Z",
"title": "Пост профиля",
"total_likes": 10,
"total_views": 100,
"total_comments": 5,
"was_liked_by_me": false,
"is_favorite": false
},
{
"id": 2,
"state": "active",
"image": "media/2/a.jpg",
"images": [
{
"id": 20,
"url": "media/2/a.jpg",
"position": 1,
"orientation": "horizontal"
},
{
"id": 21,
"url": "media/2/b.jpg",
"position": 2,
"orientation": "horizontal"
}
],
"description": "Галерея без видео",
"author": {
"author_slug": "test-user",
"author_name": "Test User",
"author_profile_icon": "https://example.com/photo.jpg",
"author_is_me": true,
"is_entity_subscribed_by_me": false,
"author_place_in_rating": null
},
"video_url": "",
"video_url_embed": null,
"video_url_thumb": null,
"created_at": "2024-01-16T12:00:00Z",
"updated_at": "2024-01-16T12:00:00Z",
"title": "Галерея",
"total_likes": 3,
"total_views": 40,
"total_comments": 1,
"was_liked_by_me": false,
"is_favorite": false
}
]