From a6fe6afb80430e1d55932a6528b17a215f62a5dd Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 23 Jul 2026 15:17:01 +0300 Subject: [PATCH] feat(personalized_feed): fixed photos rendering and fixed profile posts rendering --- .../presentation/pages/blog_post_screen.dart | 6 +- .../widgets/blog_post_media_content.dart | 81 +++- .../widgets/blog_post_widget.dart | 3 +- .../feed_video_player_widget.dart | 5 +- .../feed_video_preview_placeholder.dart | 5 +- .../widgets/profile_blog_post_widget.dart | 392 +++++++++--------- pubspec.yaml | 22 +- .../models/feed_blog_post_model_test.dart | 13 + .../profile_cubit/profile_cubit_test.dart | 15 +- .../profile_blog_post_widget_smoke_test.dart | 251 +++++------ .../services/profile_service_test.dart | 18 +- test/fixtures/feed_fixtures.dart | 6 + .../blog_posts_by_author_response.json | 80 ++++ 13 files changed, 491 insertions(+), 406 deletions(-) create mode 100644 test/fixtures/profile/blog_posts_by_author_response.json diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index d5960c68..9067738c 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -257,13 +257,9 @@ class _BlogPostScreenState extends State { 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, diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index e2141185..a0d8ddc8 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -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 galleryUrls; + final List galleryImages; final String imageUrl; final String? videoEmbedUrl; final String? videoUrlThumb; @@ -32,16 +34,36 @@ class BlogPostMediaContent extends StatefulWidget { class _BlogPostMediaContentState extends State { 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 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 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 { 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 { 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 { 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( @@ -117,7 +153,7 @@ class _BlogPostMediaContentState extends State { 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 { @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 { ); } - 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 { 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(), ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 5c349db8..3977d462 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -78,7 +78,6 @@ class _BlogPostWidgetState extends State { @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 { _BlogPostHeader(post: post), _BlogPostTextContent(post: post), BlogPostMediaContent( - galleryUrls: galleryUrls, + galleryImages: post.images, imageUrl: imageUrl, videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, videoUrlThumb: post.videoUrlThumb, diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart index 6778e94b..2f850923 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart @@ -169,9 +169,8 @@ class _FeedVideoPlayerState extends State { @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: [ diff --git a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart index 86036dc6..1721e45b 100644 --- a/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart +++ b/lib/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart @@ -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 diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index e0e5cd01..9ae218cc 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -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,221 +33,213 @@ class ProfileBlogPostWidget extends StatefulWidget { class _ProfileBlogPostWidgetState extends State { late bool isLiked; late int totalLikes; + int currentGalleryIndex = 0; + + void _openGallery(List imageUrls, int index) { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + } @override Widget build(BuildContext context) { return BlocBuilder( bloc: widget.profileCubit, builder: (BuildContext context, ProfileState state) { - if (state is ProfileStateLoaded) { - isLiked = state.blogPosts[widget.index].wasLikedByMe; - totalLikes = state.blogPosts[widget.index].totalLikes; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + 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: post.id), + child: Ink( + decoration: BoxDecoration(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( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Colors.black12, + foregroundImage: NetworkImage( + post.author.authorProfileIcon.contains('https') + ? post.author.authorProfileIcon + : sl().baseUrl + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.author.authorName, + style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + ), + 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), + ), + ], + ), + const Spacer(), + CustomPopup( + isAuthor: post.author.authorIsMe, + complainedContentType: ComplainedContent.posts, + path: 'blog_post/${post.id}', + id: post.id, + feedBlogPostModel: post, + userSlug: post.author.authorSlug, + ), + ], + ), + ), + if (post.title.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - state.blogPosts[widget.index].author.authorProfileIcon.contains('https') - ? state.blogPosts[widget.index].author.authorProfileIcon - : sl().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - state.blogPosts[widget.index].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')}', - style: const TextStyle(height: 1, color: Colors.black45), - ), - ], - ), - const Spacer(), - CustomPopup( - isAuthor: state.blogPosts[widget.index].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, - ), - ], + child: Text( + post.title, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), ), ), - if (state.blogPosts[widget.index].title.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - state.blogPosts[widget.index].title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, 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), + fontSize: 16, + textColor: 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) - 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, - fontSize: 16, - textColor: Colors.black87, + ), + 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), + 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; + }); + }, + ), + 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--; + }); + isLiked ? await context.read().likePost(post.id) : await context.read().unlikedPost(post.id); + widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); + }, + ), + 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: 20, + width: 20, + 'assets/icons/share.svg', + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams( + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), ); }, ), - ), - if (state.blogPosts[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), + 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), ), - ), - 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().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0), - ); - }, - child: Hero( - tag: '${sl().baseUrl}/storage/${state.blogPosts[widget.index].image}', - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, - imageUrl: '${sl().baseUrl}/storage/${state.blogPosts[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--; - }); - isLiked - ? await context.read().likePost(state.blogPosts[widget.index].id) - : await context.read().unlikedPost(state.blogPosts[widget.index].id); - widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true); - }, - ), - 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.blogPosts[widget.index].totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id), - ), - const SizedBox(width: 8), - FeedIconButton( - svgIcon: SvgPicture.asset( - height: 20, - width: 20, - 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), - ), - onTap: () { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${state.blogPosts[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.blogPosts[widget.index].totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), - ), - ], - ), - ), - const SizedBox(height: 8), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ); - } - 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); +} diff --git a/pubspec.yaml b/pubspec.yaml index 01ec1618..ae4effdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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' diff --git a/test/features/feed/models/feed_blog_post_model_test.dart b/test/features/feed/models/feed_blog_post_model_test.dart index 55fbe181..140266ba 100644 --- a/test/features/feed/models/feed_blog_post_model_test.dart +++ b/test/features/feed/models/feed_blog_post_model_test.dart @@ -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', diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9b109014..9e23ea16 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -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( diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 8f6fe8ed..44ab062a 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -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 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> implements HttpClientResponse { // 1x1 transparent PNG. static final List _png = [ - 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,81 +86,53 @@ void main() { sl.registerLazySingleton(() => 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', + email: 'u@e.com', + phoneConfirmationState: 'confirmed', + slug: 'user', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); - const profile = ProfileModel( - id: 1, - name: 'User', - email: 'u@e.com', - phoneConfirmationState: 'confirmed', - slug: 'user', - isPublicProfile: true, - isBlockedByMe: false, - isBlockedByAuthor: false, - isBlockedByUser: false, - isEntitySubscribedByMe: false, - profilePhotoUrl: '', - roles: [], - badges: [], - subscriptions: [], - subscribers: [], - ); - - const details = ProfileDetailsModel( - firstName: 'Test', - lastName: 'User', - birthdate: '1990-01-01', - gender: 'male', - country: 'Russia', - city: 'Moscow', - lang: 'ru', - weight: 70.0, - height: 175, - chestCircumference: 100, - waistCircumference: 80, - hipCircumference: 90, - bloodPressureUp: 120, - bloodPressureDown: 80, - hasHypo: false, - hasHyper: false, - aboutAlergy: '', - aboutTakingPils: '', - aboutSportHobbies: '', - favoriteFoods: '', - unfavoriteFoods: '', - additionalInfo: '', - isMoodFillable: true, - isBedtimeFillable: true, - 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.empty()); - when(cubit.close()).thenAnswer((_) async => Future.value()); + const details = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.5, + ); + Future 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.empty()); + when(cubit.close()).thenAnswer((_) async => Future.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.empty()); + when(cubit.close()).thenAnswer((_) async => Future.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.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await pumpPost(tester, cubit); + + expect(find.byType(BlogPostMediaContent), findsOneWidget); + expect(find.text('Видео'), findsOneWidget); }); }); } diff --git a/test/features/profile/services/profile_service_test.dart b/test/features/profile/services/profile_service_test.dart index 5c773bc3..9d02811a 100644 --- a/test/features/profile/services/profile_service_test.dart +++ b/test/features/profile/services/profile_service_test.dart @@ -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()]) @@ -72,9 +74,17 @@ void main() { expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA()); }); - test('getBlogPostByAuthorSlug', () async { - when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []); - expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA>()); + 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>()); + expect(result, hasLength(2)); + expect(result.first.images, isNotEmpty); + expect(result.first.videoEmbedUrl, isNotNull); + expect(result.first.videoUrlThumb, isNotNull); }); test('getWeight', () async { diff --git a/test/fixtures/feed_fixtures.dart b/test/fixtures/feed_fixtures.dart index 0beb1624..e2fa950a 100644 --- a/test/fixtures/feed_fixtures.dart +++ b/test/fixtures/feed_fixtures.dart @@ -72,4 +72,10 @@ class FeedFixtures { static FeedBlogPostModel feedBlogPostGalleryAndVideo() { return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson()); } + + static List blogPostsByAuthor() { + return FixtureReader.readAsList('profile/blog_posts_by_author_response.json') + .map((item) => FeedBlogPostModel.fromJson(item as Map)) + .toList(); + } } diff --git a/test/fixtures/profile/blog_posts_by_author_response.json b/test/fixtures/profile/blog_posts_by_author_response.json new file mode 100644 index 00000000..a97d2e8d --- /dev/null +++ b/test/fixtures/profile/blog_posts_by_author_response.json @@ -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 + } +]