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 9067738c..e8840cd2 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -26,7 +26,11 @@ class BlogPostScreen extends StatefulWidget { final int id; final int? highlightedCommentId; - const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); + const BlogPostScreen({ + super.key, + required this.id, + this.highlightedCommentId, + }); @override State createState() => _BlogPostScreenState(); @@ -44,8 +48,11 @@ class _BlogPostScreenState extends State { super.initState(); } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -53,7 +60,8 @@ class _BlogPostScreenState extends State { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit + ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -81,15 +89,23 @@ class _BlogPostScreenState extends State { body: const Loader(), ); } - final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; - final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; + final bool isLiked = + blogPostState.blogPostModel?.wasLikedByMe ?? false; + final int totalLikes = + blogPostState.blogPostModel?.totalLikes ?? 0; return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 8, + ), decoration: BoxDecoration( color: Colors.white, - border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), + border: Border( + top: BorderSide( + color: Colors.black.withValues(alpha: 0.1), + ), + ), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -107,10 +123,17 @@ class _BlogPostScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(); }, child: Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.close_rounded, + size: 18, + color: Colors.white, + ), ), ), if (replyId != null) const SizedBox(width: 8), @@ -118,7 +141,8 @@ class _BlogPostScreenState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: TextFormField( - textCapitalization: TextCapitalization.sentences, + textCapitalization: + TextCapitalization.sentences, onChanged: (_) { setState(() {}); }, @@ -126,26 +150,48 @@ class _BlogPostScreenState extends State { maxLines: 5, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.black, + height: 1.2, + ), decoration: InputDecoration( isDense: false, // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), // вертикальный паддинг увеличен hintText: S.of(context).comment_hint, - hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), + hintStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.2, + color: Color(0xFFAEAEB2), + ), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), ), ), @@ -163,7 +209,10 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) context.read().incrementCommentsState(widget.id); + if (isSent) + context + .read() + .incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -174,10 +223,19 @@ class _BlogPostScreenState extends State { } }, child: Container( - decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), + decoration: BoxDecoration( + color: textEditingController.text.length > 2 + ? appTheme.blueA400 + : Colors.grey, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.send_rounded, + size: 18, + color: Colors.white, + ), ), ), ], @@ -197,23 +255,33 @@ class _BlogPostScreenState extends State { ), actions: [ CustomPopup( - isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, + isAuthor: + blogPostState.blogPostModel?.author.authorIsMe ?? + false, complainedContentType: ComplainedContent.posts, path: 'blog_post/${(widget.id)}', id: widget.id, feedBlogPostModel: blogPostState.blogPostModel, - userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', + userSlug: + blogPostState.blogPostModel?.author.authorSlug ?? + '', ), const SizedBox(width: 16), ], ), body: SingleChildScrollView( - padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area + padding: EdgeInsets.only( + bottom: 160 + MediaQuery.of(context).padding.bottom, + ), // поле комментария (52-200) + padding (12+12+56) + safe area physics: const BouncingScrollPhysics(), child: Column( children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), + onTap: () => GoRouter.of(context).pushNamed( + 'profileScreen', + extra: + blogPostState.blogPostModel?.author.authorSlug, + ), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -222,21 +290,46 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + sl().baseUrl + + (blogPostState + .blogPostModel + ?.author + .authorProfileIcon ?? + ''), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', + ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - blogPostState.blogPostModel?.author.authorName ?? '', - style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), + blogPostState + .blogPostModel + ?.author + .authorName ?? + '', + style: const TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1, + ), ), const SizedBox(height: 2), Text( - (blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), - style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), + (blogPostState.blogPostModel?.createdAt ?? + DateTime.now()) + .format('dd MMMM', 'ru'), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1, + ), ), ], ), @@ -244,10 +337,20 @@ class _BlogPostScreenState extends State { ), ), ), - if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) + if (blogPostState + .blogPostModel + ?.description + .isNotEmpty ?? + false) Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: MarkdownHtmlWidget( + markdownText: + blogPostState.blogPostModel!.description, + ), ), const SizedBox(height: 8), Builder( @@ -256,15 +359,31 @@ class _BlogPostScreenState extends State { if (post == null) { return const SizedBox.shrink(); } - final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final imageUrl = PostFeedMapper.resolveMediaUrl( + post.image, + ); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, - videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + videoEmbedUrl: + embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl( + post.videoUrlThumb, + ) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'blog-post-screen-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'blog-post-screen-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -274,22 +393,43 @@ class _BlogPostScreenState extends State { }, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), child: Row( children: [ _iconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + 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, + 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 { - await context.read().toggleLike(); + await context + .read() + .toggleLike(); // ignore: use_build_context_synchronously - final updated = context.read().state.blogPostModel; + final updated = context + .read() + .state + .blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); + context + .read() + .updateFeedBlogPostList( + updated, + updated.wasLikedByMe, + ); } }, ), @@ -297,21 +437,36 @@ class _BlogPostScreenState extends State { _iconButton( icon: Icons.reply_rounded, onTap: () { - final box = context.findRenderObject() as RenderBox?; + final box = + context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${widget.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${widget.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & + box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + Icon( + Icons.visibility_rounded, + size: 16, + color: appTheme.gray500, + ), const SizedBox(width: 4), Text( - blogPostState.blogPostModel?.totalViews.toString() ?? '', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + blogPostState.blogPostModel?.totalViews + .toString() ?? + '', + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), @@ -319,7 +474,10 @@ class _BlogPostScreenState extends State { Container( width: MediaQuery.of(context).size.width - 32, height: 0.4, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), ), const SizedBox(height: 8), Container( @@ -327,7 +485,11 @@ class _BlogPostScreenState extends State { alignment: Alignment.centerLeft, child: Text( '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ), const SizedBox(height: 16), @@ -352,13 +514,23 @@ class _BlogPostScreenState extends State { ); } - InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { + InkWell _iconButton({ + required IconData icon, + String? text, + Function()? onTap, + Color? iconColor, + Color? backgroundColor, + Color? textColor, + }) { return InkWell( onTap: onTap ?? () {}, borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: backgroundColor ?? appTheme.gray100, + borderRadius: BorderRadius.circular(16), + ), height: 32, child: Row( mainAxisSize: MainAxisSize.min, @@ -368,7 +540,10 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), + style: TextStyle( + color: textColor ?? appTheme.gray600, + fontWeight: FontWeight.w500, + ), ), ], ), diff --git a/lib/features/chat/presentation/pages/photo_view_screen.dart b/lib/features/chat/presentation/pages/photo_view_screen.dart index 3cbdc744..b9910feb 100644 --- a/lib/features/chat/presentation/pages/photo_view_screen.dart +++ b/lib/features/chat/presentation/pages/photo_view_screen.dart @@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget { final int index; final bool isLocal; + /// When set, used instead of the image URL so source/destination tags stay unique. + final Object? heroTag; + const PhotoViewScreen({ super.key, required this.imageUrls, required this.index, this.isLocal = false, + this.heroTag, }); @override @@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State { super.initState(); } + PhotoViewHeroAttributes? _heroAttributesFor(int index) { + if (widget.heroTag != null) { + if (index != widget.index) { + return null; + } + return PhotoViewHeroAttributes( + tag: widget.heroTag!, + transitionOnUserGestures: true, + ); + } + + return PhotoViewHeroAttributes( + tag: widget.imageUrls[index], + transitionOnUserGestures: true, + ); + } + @override Widget build(BuildContext context) { return DismissiblePage( @@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State { direction: DismissiblePageDismissDirection.vertical, isFullScreen: true, child: PhotoViewGallery.builder( - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), scrollPhysics: const BouncingScrollPhysics(), builder: (BuildContext context, int index) { final imageProvider = widget.isLocal ? FileImage(widget.imageUrls[index]) as ImageProvider - : CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider; + : CachedNetworkImageProvider(widget.imageUrls[index]) + as ImageProvider; return PhotoViewGalleryPageOptions( imageProvider: imageProvider, initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.imageUrls[index], - transitionOnUserGestures: true, - ), + heroAttributes: _heroAttributesFor(index), errorBuilder: (context, object, stackTrace) { return Scaffold( backgroundColor: Colors.black, @@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State { ), contentPadding: const EdgeInsets.all(16), onTap: () async { - if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) { - throw Exception('Could not launch ${widget.imageUrls[index]}'); + if (!await launchUrl( + Uri.parse(widget.imageUrls[index]), + )) { + throw Exception( + 'Could not launch ${widget.imageUrls[index]}', + ); } }, leading: const Icon( @@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State { color: Colors.white, ), title: Text( - widget.imageUrls[index].replaceAll('${sl().baseUrl}/storage/chat_files/', ''), + widget.imageUrls[index].replaceAll( + '${sl().baseUrl}/storage/chat_files/', + '', + ), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State { width: 20.0, height: 20.0, child: CircularProgressIndicator( - value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!, + value: event == null + ? 0 + : event.cumulativeBytesLoaded / event.expectedTotalBytes!, ), ), ), 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 a0d8ddc8..e0e87f31 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget { required this.onOpenGallery, required this.onGalleryChanged, required this.videoUrlThumb, + this.heroTag, }); final List galleryImages; @@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget { final void Function(List urls, int index) onOpenGallery; final ValueChanged onGalleryChanged; + /// Unique per post. Required when several cards with the same [imageUrl] share a route. + final Object? heroTag; + @override State createState() => _BlogPostMediaContentState(); } @@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State { bool _isCounterVisible = false; Timer? _counterHideTimer; - bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + 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) + if (PostFeedMapper.resolveMediaUrl(image.url) case final url + when url.isNotEmpty) FeedGalleryImage( url: url, orientation: image.orientation, @@ -71,7 +77,8 @@ class _BlogPostMediaContentState extends State { return 0; } - bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; + bool get _isCurrentSlideVideo => + _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; @@ -84,7 +91,9 @@ class _BlogPostMediaContentState extends State { } double _heightForOrientation(String orientation, double width) { - final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; + final aspectRatio = orientation == 'vertical' + ? _verticalAspectRatio + : _horizontalAspectRatio; final maxHeight = MediaQuery.sizeOf(context).height * 0.7; return (width / aspectRatio).clamp(180.0, maxHeight); } @@ -115,12 +124,15 @@ class _BlogPostMediaContentState extends State { } bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + if (notification is ScrollStartNotification || + notification is ScrollUpdateNotification) { _counterHideTimer?.cancel(); if (!_isCounterVisible) { setState(() => _isCounterVisible = true); } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + } else if (notification is ScrollEndNotification && + _isCounterVisible && + _isCurrentSlideVideo) { _scheduleCounterHideOnVideo(); } return false; @@ -140,17 +152,25 @@ class _BlogPostMediaContentState extends State { } Widget _videoSlide() { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + return FeedVideoPlayer( + embedUrl: widget.videoEmbedUrl!, + videoUrlThumb: widget.videoUrlThumb, + ); } - Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { + Widget _carousel({ + required int itemCount, + required NullableIndexedWidgetBuilder itemBuilder, + required double height, + }) { return Stack( children: [ NotificationListener( onNotification: _handleCarouselScroll, child: CarouselSlider.builder( itemCount: itemCount, - itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + itemBuilder: (context, itemIndex, _) => + itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), options: CarouselOptions( viewportFraction: 1, height: height, @@ -167,7 +187,10 @@ class _BlogPostMediaContentState extends State { child: AnimatedOpacity( opacity: _shouldShowCounter ? 1 : 0, duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + child: _GalleryPhotoCounter( + currentIndex: widget.currentGalleryIndex, + total: itemCount, + ), ), ), ), @@ -203,25 +226,21 @@ class _BlogPostMediaContentState extends State { } if (_hasVideo) { - return SizedBox( - height: mediaHeight, - width: width, - child: _videoSlide(), - ); + return SizedBox(height: mediaHeight, width: width, child: _videoSlide()); } if (widget.imageUrl.isNotEmpty) { + final image = CachedNetworkImage( + fit: BoxFit.fitWidth, + width: width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ); return GestureDetector( onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), + child: widget.heroTag == null + ? image + : Hero(tag: widget.heroTag!, child: image), ); } @@ -239,15 +258,27 @@ class _GalleryPhotoCounter extends StatelessWidget { 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)), + 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 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), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), ), ], ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 3977d462..ac467501 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State { totalLikes = post.totalLikes; return; } - if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { + if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || + oldWidget.post.totalLikes != post.totalLikes) { isLiked = post.wasLikedByMe; totalLikes = post.totalLikes; } } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } Future _toggleLike() async { @@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Material( borderRadius: BorderRadius.circular(12), color: Colors.white, @@ -96,10 +101,19 @@ class _BlogPostWidgetState extends State { BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, videoUrlThumb: post.videoUrlThumb, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'feed-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'feed-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -107,7 +121,12 @@ class _BlogPostWidgetState extends State { }, ), const SizedBox(height: 12), - _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), + _BlogPostActionsRow( + post: post, + isLiked: isLiked, + totalLikes: totalLikes, + onToggleLike: _toggleLike, + ), const SizedBox(height: 8), ], ), @@ -129,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), + 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, + foregroundImage: post.author.authorProfileIcon.isNotEmpty + ? NetworkImage( + PostFeedMapper.resolveMediaUrl( + post.author.authorProfileIcon, + ), + ) + : null, backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), ), @@ -145,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget { Text( post.author.authorName, overflow: TextOverflow.ellipsis, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + 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), + style: const TextStyle( + height: 1, + color: Colors.black45, + fontSize: 14, + ), ), ], ), ), - if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) + if (!(post.author.isEntitySubscribedByMe ?? false) && + !post.author.authorIsMe) Padding( padding: const EdgeInsets.only(right: 8), child: SubscribeButtonWidget(slug: post.author.authorSlug), @@ -190,33 +227,51 @@ class _BlogPostTextContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), + 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.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)), + 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), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty || post.description.isNotEmpty) + const SizedBox(height: 8), ], ); } } class _BlogPostActionsRow extends StatelessWidget { - const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); + const _BlogPostActionsRow({ + required this.post, + required this.isLiked, + required this.totalLikes, + required this.onToggleLike, + }); final FeedBlogPostModel post; final bool isLiked; @@ -230,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + 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, @@ -239,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget { ), const SizedBox(width: 8), FeedIconButton( - svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + 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), + 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)), + 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().baseUrl}/ru/blog_post/${post.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, @@ -261,7 +333,11 @@ class _BlogPostActionsRow extends StatelessWidget { const SizedBox(width: 4), Text( post.totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), 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 2f19f12a..af70c9df 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -24,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget { final int index; final ProfileCubit profileCubit; - const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit}); + const ProfileBlogPostWidget({ + super.key, + required this.index, + required this.profileCubit, + }); @override State createState() => _ProfileBlogPostWidgetState(); @@ -35,8 +39,11 @@ class _ProfileBlogPostWidgetState extends State { late int totalLikes; int currentGalleryIndex = 0; - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -56,9 +63,13 @@ class _ProfileBlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -74,9 +85,12 @@ class _ProfileBlogPostWidgetState extends State { foregroundImage: NetworkImage( post.author.authorProfileIcon.contains('https') ? post.author.authorProfileIcon - : sl().baseUrl + post.author.authorProfileIcon, + : sl().baseUrl + + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), const SizedBox(width: 8), Column( @@ -84,12 +98,19 @@ class _ProfileBlogPostWidgetState extends State { children: [ Text( post.author.authorName, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + 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), + style: const TextStyle( + height: 1, + color: Colors.black45, + ), ), ], ), @@ -112,12 +133,17 @@ class _ProfileBlogPostWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), + 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.title.isNotEmpty && post.description.isNotEmpty) + const SizedBox(height: 8), if (post.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -132,17 +158,34 @@ class _ProfileBlogPostWidgetState extends State { padding: EdgeInsets.symmetric(horizontal: 12), child: Text( 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + 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, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'profile-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'profile-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -155,18 +198,35 @@ class _ProfileBlogPostWidgetState extends State { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + 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, + 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); + isLiked + ? await context.read().likePost( + post.id, + ) + : await context.read().unlikedPost( + post.id, + ); + widget.profileCubit.updateFeedBlogPostList( + post, + isLiked ? false : true, + ); }, ), const SizedBox(width: 8), @@ -175,10 +235,15 @@ class _ProfileBlogPostWidgetState extends State { 'assets/icons/comment.svg', height: 20, width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.gray600, + BlendMode.srcIn, + ), ), text: post.totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => GoRouter.of( + context, + ).pushNamed('blogPostScreen', extra: post.id), ), const SizedBox(width: 8), FeedIconButton( @@ -186,24 +251,38 @@ class _ProfileBlogPostWidgetState extends State { height: 20, width: 20, 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + 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, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + 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), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), 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 44ab062a..4102ca44 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 @@ -47,13 +47,77 @@ class _FakeHttpClientRequest implements HttpClientRequest { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { +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 @@ -63,11 +127,22 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes int get contentLength => _png.length; @override - HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; @override - StreamSubscription> listen(void Function(List)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) { - return Stream>.fromIterable([_png]).listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.fromIterable([_png]).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } @override @@ -78,12 +153,18 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes void main() { group('ProfileBlogPostWidget smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); const profile = ProfileModel( @@ -160,7 +241,13 @@ void main() { 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 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()); @@ -174,7 +261,13 @@ void main() { 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 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()); @@ -188,7 +281,13 @@ void main() { 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 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()); @@ -199,5 +298,84 @@ void main() { expect(find.byType(BlogPostMediaContent), findsOneWidget); expect(find.text('Видео'), findsOneWidget); }); + + testWidgets('cover-only posts with the same image use unique hero tags', ( + tester, + ) async { + const sharedImage = 'https://example.com/same-cover.jpg'; + final posts = [ + FeedFixtures.feedBlogPostModel().copyWith( + id: 11, + title: 'Первый', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + FeedFixtures.feedBlogPostModel().copyWith( + id: 22, + title: 'Второй', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + ]; + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: posts, + 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 HttpOverrides.runZoned(() async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(size: Size(800, 1600)), + child: SingleChildScrollView( + child: Column( + children: [ + ProfileBlogPostWidget(index: 0, profileCubit: cubit), + ProfileBlogPostWidget(index: 1, profileCubit: cubit), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + }, createHttpClient: (_) => _FakeHttpClient()); + + final tags = tester + .widgetList(find.byType(Hero)) + .map((hero) => hero.tag) + .toList(); + expect( + tags, + containsAll([ + 'profile-blog-post-11-cover', + 'profile-blog-post-22-cover', + ]), + ); + expect(tags.toSet().length, tags.length); + }); }); }