feat(post_creatin): fixed animation widget for profile feed

This commit is contained in:
misha
2026-08-05 15:21:23 +03:00
parent 04de956769
commit 478a53674a
6 changed files with 727 additions and 162 deletions

View File

@@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget {
required this.onOpenGallery,
required this.onGalleryChanged,
required this.videoUrlThumb,
this.heroTag,
});
final List<FeedGalleryImage> galleryImages;
@@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget {
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged;
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
final Object? heroTag;
@override
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
}
@@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
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<FeedGalleryImage> 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<BlogPostMediaContent> {
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<BlogPostMediaContent> {
}
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<BlogPostMediaContent> {
}
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<BlogPostMediaContent> {
}
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<ScrollNotification>(
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<BlogPostMediaContent> {
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<BlogPostMediaContent> {
}
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,
),
),
],
),

View File

@@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
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<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
}
Future<void> _toggleLike() async {
@@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
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<BlogPostWidget> {
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<BlogPostWidget> {
},
),
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<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
uri: Uri.parse(
'${sl<AppConfig>().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,
),
),
],
),