forked from eatvio/eatvio_app
feat(personalized_feed): added video and multiple images handling to detailed blog post screen
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
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/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
int? replyId;
|
int? replyId;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
@@ -242,8 +250,33 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
Builder(
|
||||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
builder: (context) {
|
||||||
|
final post = blogPostState.blogPostModel;
|
||||||
|
if (post == null) {
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
|||||||
@JsonKey(name: "total_comments") required int totalComments,
|
@JsonKey(name: "total_comments") required int totalComments,
|
||||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||||
@Default([]) List<FeedGalleryImage> images,
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
@JsonKey(name: "video_embed_url") String? videoEmbedUrl,
|
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
}) = _FeedBlogPostModel;
|
}) = _FeedBlogPostModel;
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel {
|
|||||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||||
const factory FeedGalleryImage({
|
const factory FeedGalleryImage({
|
||||||
required String url,
|
required String url,
|
||||||
required String orientation,
|
@Default('horizontal') String orientation,
|
||||||
|
int? id,
|
||||||
|
int? position,
|
||||||
}) = _FeedGalleryImage;
|
}) = _FeedGalleryImage;
|
||||||
|
|
||||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Gallery + optional embed video for feed card and blog post detail.
|
||||||
|
class BlogPostMediaContent extends StatefulWidget {
|
||||||
|
const BlogPostMediaContent({
|
||||||
|
super.key,
|
||||||
|
required this.galleryUrls,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.videoEmbedUrl,
|
||||||
|
required this.currentGalleryIndex,
|
||||||
|
required this.onOpenGallery,
|
||||||
|
required this.onGalleryChanged,
|
||||||
|
required this.videoUrlThumb,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> galleryUrls;
|
||||||
|
final String imageUrl;
|
||||||
|
final String? videoEmbedUrl;
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
final int currentGalleryIndex;
|
||||||
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
|
final ValueChanged<int> onGalleryChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||||
|
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||||
|
static const _carouselHeight = 260.0;
|
||||||
|
|
||||||
|
bool _isCounterVisible = false;
|
||||||
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
|
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
|
int get _slideCount {
|
||||||
|
if (widget.galleryUrls.isNotEmpty) {
|
||||||
|
return widget.galleryUrls.length + (_hasVideo ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == widget.galleryUrls.length;
|
||||||
|
|
||||||
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleCounterHideOnVideo() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCurrentSlideVideo) return;
|
||||||
|
|
||||||
|
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||||
|
if (mounted && _isCurrentSlideVideo) {
|
||||||
|
setState(() => _isCounterVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||||
|
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCounterVisible) {
|
||||||
|
setState(() => _isCounterVisible = true);
|
||||||
|
}
|
||||||
|
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imageSlide(String itemUrl, int itemIndex) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
height: _carouselHeight,
|
||||||
|
imageUrl: itemUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _videoSlide() {
|
||||||
|
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder}) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleCarouselScroll,
|
||||||
|
child: CarouselSlider.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||||
|
options: CarouselOptions(
|
||||||
|
viewportFraction: 1,
|
||||||
|
height: _carouselHeight,
|
||||||
|
enableInfiniteScroll: false,
|
||||||
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (itemCount > 1)
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _shouldShowCounter ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.galleryUrls.length > 1 || (widget.galleryUrls.length == 1 && _hasVideo)) {
|
||||||
|
final itemCount = _slideCount;
|
||||||
|
return _carousel(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, itemIndex) {
|
||||||
|
if (itemIndex < widget.galleryUrls.length) {
|
||||||
|
return _imageSlide(widget.galleryUrls[itemIndex], itemIndex);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return _videoSlide();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.galleryUrls.length == 1) {
|
||||||
|
return _imageSlide(widget.galleryUrls.first, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasVideo) {
|
||||||
|
return SizedBox(
|
||||||
|
height: _carouselHeight,
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: _videoSlide(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.imageUrl.isNotEmpty) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
|
child: Hero(
|
||||||
|
tag: widget.imageUrl,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryPhotoCounter extends StatelessWidget {
|
||||||
|
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||||
|
|
||||||
|
final int currentIndex;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${currentIndex + 1} / $total',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
@@ -15,7 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da
|
|||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.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/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -98,7 +94,7 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_BlogPostHeader(post: post),
|
_BlogPostHeader(post: post),
|
||||||
_BlogPostTextContent(post: post),
|
_BlogPostTextContent(post: post),
|
||||||
_BlogPostMediaContent(
|
BlogPostMediaContent(
|
||||||
galleryUrls: galleryUrls,
|
galleryUrls: galleryUrls,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
@@ -220,186 +216,6 @@ class _BlogPostTextContent extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BlogPostMediaContent extends StatefulWidget {
|
|
||||||
const _BlogPostMediaContent({
|
|
||||||
required this.galleryUrls,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.videoEmbedUrl,
|
|
||||||
required this.currentGalleryIndex,
|
|
||||||
required this.onOpenGallery,
|
|
||||||
required this.onGalleryChanged,
|
|
||||||
required this.videoUrlThumb,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<String> galleryUrls;
|
|
||||||
final String imageUrl;
|
|
||||||
final String? videoEmbedUrl;
|
|
||||||
final String? videoUrlThumb;
|
|
||||||
final int currentGalleryIndex;
|
|
||||||
final void Function(List<String> urls, int index) onOpenGallery;
|
|
||||||
final ValueChanged<int> onGalleryChanged;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BlogPostMediaContentState extends State<_BlogPostMediaContent> {
|
|
||||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
|
||||||
|
|
||||||
bool _isCounterVisible = false;
|
|
||||||
Timer? _counterHideTimer;
|
|
||||||
|
|
||||||
bool get _isCurrentSlideVideo =>
|
|
||||||
widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length;
|
|
||||||
|
|
||||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
|
||||||
_scheduleCounterHideOnVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleCounterHideOnVideo() {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
if (!_isCurrentSlideVideo) return;
|
|
||||||
|
|
||||||
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
|
||||||
if (mounted && _isCurrentSlideVideo) {
|
|
||||||
setState(() => _isCounterVisible = false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _handleCarouselScroll(ScrollNotification notification) {
|
|
||||||
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
if (!_isCounterVisible) {
|
|
||||||
setState(() => _isCounterVisible = true);
|
|
||||||
}
|
|
||||||
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
|
||||||
_scheduleCounterHideOnVideo();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final hasVideo = widget.videoEmbedUrl != null;
|
|
||||||
final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length;
|
|
||||||
final showCarousel = mediaContentCount > 1;
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
if (showCarousel) ...[
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
NotificationListener<ScrollNotification>(
|
|
||||||
onNotification: _handleCarouselScroll,
|
|
||||||
child: CarouselSlider.builder(
|
|
||||||
itemCount: mediaContentCount,
|
|
||||||
itemBuilder: (context, itemIndex, _) {
|
|
||||||
if (itemIndex == widget.galleryUrls.length) {
|
|
||||||
if (widget.videoEmbedUrl != null) {
|
|
||||||
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final itemUrl = widget.galleryUrls[itemIndex];
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: itemUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
options: CarouselOptions(
|
|
||||||
viewportFraction: 1,
|
|
||||||
height: 260,
|
|
||||||
enableInfiniteScroll: false,
|
|
||||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 12,
|
|
||||||
right: 12,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: _shouldShowCounter ? 1 : 0,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else if (widget.galleryUrls.length == 1) ...[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery(widget.galleryUrls, 0),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
height: 260,
|
|
||||||
imageUrl: widget.galleryUrls.first,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else if (widget.imageUrl.isNotEmpty) ...[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
|
||||||
child: Hero(
|
|
||||||
tag: widget.imageUrl,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GalleryPhotoCounter extends StatelessWidget {
|
|
||||||
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
|
||||||
|
|
||||||
final int currentIndex;
|
|
||||||
final int total;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${currentIndex + 1} / $total',
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BlogPostActionsRow extends StatelessWidget {
|
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});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ abstract class FeedService {
|
|||||||
@Query('cursor') String? cursor,
|
@Query('cursor') String? cursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@GET('/api/blogPosts/show/{postId}')
|
@GET('/api/v2/blogPosts/show/{postId}')
|
||||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||||
|
|
||||||
@GET('/api/recipes/index')
|
@GET('/api/recipes/index')
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AppRouter', () {
|
group('AppRouter', () {
|
||||||
|
group('resolveDeepLinkTarget - blog_post', () {
|
||||||
|
test('ru/blog_post/{id} → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.ru/ru/blog_post/123'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en/blog_post/{id} → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.com/en/blog_post/456'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.com/blog_post/789'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
||||||
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ void main() {
|
|||||||
final blogPostRoute = routes.firstWhere(
|
final blogPostRoute = routes.firstWhere(
|
||||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||||
orElse: () => throw Exception('Route not found'),
|
orElse: () => throw Exception('Route not found'),
|
||||||
);
|
) as GoRoute;
|
||||||
expect(blogPostRoute, isA<GoRoute>());
|
expect(blogPostRoute, isA<GoRoute>());
|
||||||
|
expect(blogPostRoute.path, '/blog-post-screen');
|
||||||
|
expect(blogPostRoute.name, 'blogPostScreen');
|
||||||
|
expect(blogPostRoute.builder, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/feed_fixtures.dart';
|
||||||
|
|
||||||
class MockFeedRepository extends Mock implements FeedRepository {}
|
class MockFeedRepository extends Mock implements FeedRepository {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -29,21 +31,7 @@ void main() {
|
|||||||
|
|
||||||
test('getBlogPost - успешная загрузка поста', () async {
|
test('getBlogPost - успешная загрузка поста', () async {
|
||||||
const postId = 1;
|
const postId = 1;
|
||||||
final mockPost = FeedBlogPostModel(
|
final mockPost = FeedFixtures.feedBlogPostModel();
|
||||||
id: postId,
|
|
||||||
title: 'Test Post',
|
|
||||||
state: 'active',
|
|
||||||
image: '',
|
|
||||||
description: 'Test Content',
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
totalLikes: 0,
|
|
||||||
totalViews: 0,
|
|
||||||
totalComments: 0,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false),
|
|
||||||
);
|
|
||||||
|
|
||||||
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
||||||
|
|
||||||
@@ -51,6 +39,8 @@ void main() {
|
|||||||
|
|
||||||
expect(cubit.state.isLoading, false);
|
expect(cubit.state.isLoading, false);
|
||||||
expect(cubit.state.blogPostModel, mockPost);
|
expect(cubit.state.blogPostModel, mockPost);
|
||||||
|
expect(cubit.state.blogPostModel?.images, hasLength(2));
|
||||||
|
expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||||
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart';
|
|||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
import 'package:network_image_mock/network_image_mock.dart';
|
import 'package:network_image_mock/network_image_mock.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/feed_fixtures.dart';
|
||||||
import 'blog_post_screen_smoke_test.mocks.dart';
|
import 'blog_post_screen_smoke_test.mocks.dart';
|
||||||
|
|
||||||
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
||||||
@@ -167,21 +167,7 @@ void main() {
|
|||||||
final blogPostCubit = MockBlogPostCubit();
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
final feedCubit = MockFeedCubit();
|
final feedCubit = MockFeedCubit();
|
||||||
final commentsCubit = MockCommentsCubit();
|
final commentsCubit = MockCommentsCubit();
|
||||||
final blogPostModel = FeedBlogPostModel(
|
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||||
id: 1,
|
|
||||||
title: 'Test Post Title',
|
|
||||||
description: 'Test post description',
|
|
||||||
image: 'https://example.com/image.jpg',
|
|
||||||
state: 'published',
|
|
||||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime(2024, 1, 15),
|
|
||||||
updatedAt: DateTime(2024, 1, 15),
|
|
||||||
totalLikes: 10,
|
|
||||||
totalViews: 100,
|
|
||||||
totalComments: 5,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
);
|
|
||||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
when(blogPostCubit.state).thenReturn(state);
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
@@ -202,7 +188,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(BlogPostScreen), findsOneWidget);
|
expect(find.byType(BlogPostScreen), findsOneWidget);
|
||||||
expect(find.text('Test Post Title'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,21 +198,7 @@ void main() {
|
|||||||
final blogPostCubit = MockBlogPostCubit();
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
final feedCubit = MockFeedCubit();
|
final feedCubit = MockFeedCubit();
|
||||||
final commentsCubit = MockCommentsCubit();
|
final commentsCubit = MockCommentsCubit();
|
||||||
final blogPostModel = FeedBlogPostModel(
|
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||||
id: 1,
|
|
||||||
title: 'Test Post Title',
|
|
||||||
description: 'Test post description',
|
|
||||||
image: 'https://example.com/image.jpg',
|
|
||||||
state: 'published',
|
|
||||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime(2024, 1, 15),
|
|
||||||
updatedAt: DateTime(2024, 1, 15),
|
|
||||||
totalLikes: 10,
|
|
||||||
totalViews: 100,
|
|
||||||
totalComments: 5,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
);
|
|
||||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
when(blogPostCubit.state).thenReturn(state);
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
@@ -254,5 +227,95 @@ void main() {
|
|||||||
verify(blogPostCubit.toggleLike()).called(1);
|
verify(blogPostCubit.toggleLike()).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders gallery from fixture', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostWithGallery();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Галерея'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders video-only post', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostVideoOnly();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Видео'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders gallery and video post', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Галерея и видео'), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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/models/diets_pagination_model.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../../../fixtures/feed_fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('FeedBlogPostModel', () {
|
group('FeedBlogPostModel', () {
|
||||||
test('creates FeedBlogPostModel with all parameters', () {
|
test('creates FeedBlogPostModel with all parameters', () {
|
||||||
@@ -102,6 +104,52 @@ void main() {
|
|||||||
expect(model.totalLikes, 10);
|
expect(model.totalLikes, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses v2 show JSON with gallery images and video_url_embed', () {
|
||||||
|
final model = FeedBlogPostModel.fromJson({
|
||||||
|
'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},
|
||||||
|
],
|
||||||
|
'description': 'Описание',
|
||||||
|
'author': {
|
||||||
|
'author_slug': 'author-slug',
|
||||||
|
'author_name': 'Автор',
|
||||||
|
'author_profile_icon': 'icon.jpg',
|
||||||
|
'author_is_me': false,
|
||||||
|
},
|
||||||
|
'video_url': 'https://rutube.ru/video/abc/',
|
||||||
|
'video_url_embed': 'https://rutube.ru/play/embed/abc/',
|
||||||
|
'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,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.images, hasLength(2));
|
||||||
|
expect(model.images.first.id, 10);
|
||||||
|
expect(model.images.first.url, 'media/1/a.jpg');
|
||||||
|
expect(model.images.first.orientation, 'horizontal');
|
||||||
|
expect(model.images[1].orientation, 'horizontal');
|
||||||
|
expect(model.images[1].position, 2);
|
||||||
|
expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/');
|
||||||
|
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses fixture feedBlogPostModel with gallery and embed', () {
|
||||||
|
final model = FeedFixtures.feedBlogPostModel();
|
||||||
|
|
||||||
|
expect(model.images, hasLength(2));
|
||||||
|
expect(model.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||||
|
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
test('creates FeedBlogPostModel with different states', () {
|
test('creates FeedBlogPostModel with different states', () {
|
||||||
const author = Author(
|
const author = Author(
|
||||||
authorSlug: 'author',
|
authorSlug: 'author',
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ void main() {
|
|||||||
|
|
||||||
expect(result, isA<FeedBlogPostModel>());
|
expect(result, isA<FeedBlogPostModel>());
|
||||||
expect(result.id, postId);
|
expect(result.id, postId);
|
||||||
|
expect(result.images, hasLength(2));
|
||||||
|
expect(result.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||||
|
expect(result.videoUrlThumb, isNotEmpty);
|
||||||
verify(mockFeedService.getBlogPost(postId: postId)).called(1);
|
verify(mockFeedService.getBlogPost(postId: postId)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
39
test/fixtures/feed/feed_blog_post_gallery_and_video_response.json
vendored
Normal file
39
test/fixtures/feed/feed_blog_post_gallery_and_video_response.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"state": "active",
|
||||||
|
"image": "media/4/a.jpg",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"url": "media/4/a.jpg",
|
||||||
|
"position": 1,
|
||||||
|
"orientation": "horizontal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"url": "media/4/b.jpg",
|
||||||
|
"position": 2,
|
||||||
|
"orientation": "horizontal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Галерея и видео",
|
||||||
|
"author": {
|
||||||
|
"author_slug": "author-slug",
|
||||||
|
"author_name": "Автор",
|
||||||
|
"author_profile_icon": "https://example.com/photo.jpg",
|
||||||
|
"author_is_me": false,
|
||||||
|
"is_entity_subscribed_by_me": true,
|
||||||
|
"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/4/thumb.jpg",
|
||||||
|
"created_at": "2024-01-18T12:00:00Z",
|
||||||
|
"updated_at": "2024-01-18T12:00:00Z",
|
||||||
|
"title": "Галерея и видео",
|
||||||
|
"total_likes": 12,
|
||||||
|
"total_views": 200,
|
||||||
|
"total_comments": 4,
|
||||||
|
"was_liked_by_me": false,
|
||||||
|
"is_favorite": false
|
||||||
|
}
|
||||||
45
test/fixtures/feed/feed_blog_post_gallery_response.json
vendored
Normal file
45
test/fixtures/feed/feed_blog_post_gallery_response.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"url": "media/2/c.jpg",
|
||||||
|
"position": 3,
|
||||||
|
"orientation": "vertical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Пост только с галереей",
|
||||||
|
"author": {
|
||||||
|
"author_slug": "author-slug",
|
||||||
|
"author_name": "Автор",
|
||||||
|
"author_profile_icon": "https://example.com/photo.jpg",
|
||||||
|
"author_is_me": false,
|
||||||
|
"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
|
||||||
|
}
|
||||||
26
test/fixtures/feed/feed_blog_post_response.json
vendored
26
test/fixtures/feed/feed_blog_post_response.json
vendored
@@ -1,7 +1,21 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"state": "published",
|
"state": "active",
|
||||||
"image": "https://example.com/image.jpg",
|
"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": "Описание поста",
|
"description": "Описание поста",
|
||||||
"author": {
|
"author": {
|
||||||
"author_slug": "author-slug",
|
"author_slug": "author-slug",
|
||||||
@@ -11,13 +25,15 @@
|
|||||||
"is_entity_subscribed_by_me": null,
|
"is_entity_subscribed_by_me": null,
|
||||||
"author_place_in_rating": null
|
"author_place_in_rating": null
|
||||||
},
|
},
|
||||||
"video_url": "",
|
"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",
|
"created_at": "2024-01-15T12:00:00Z",
|
||||||
"updated_at": "2024-01-15T12:00:00Z",
|
"updated_at": "2024-01-15T12:00:00Z",
|
||||||
"title": "Заголовок",
|
"title": "Заголовок",
|
||||||
"total_likes": 10,
|
"total_likes": 10,
|
||||||
"total_views": 100,
|
"total_views": 100,
|
||||||
"total_comments": 5,
|
"total_comments": 5,
|
||||||
"was_liked_by_me": false
|
"was_liked_by_me": false,
|
||||||
|
"is_favorite": false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
test/fixtures/feed/feed_blog_post_video_only_response.json
vendored
Normal file
26
test/fixtures/feed/feed_blog_post_video_only_response.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"state": "active",
|
||||||
|
"image": "images/element-image-placeholder.svg",
|
||||||
|
"images": [],
|
||||||
|
"description": "Пост только с видео",
|
||||||
|
"author": {
|
||||||
|
"author_slug": "author-slug",
|
||||||
|
"author_name": "Автор",
|
||||||
|
"author_profile_icon": "https://example.com/photo.jpg",
|
||||||
|
"author_is_me": false,
|
||||||
|
"is_entity_subscribed_by_me": false,
|
||||||
|
"author_place_in_rating": null
|
||||||
|
},
|
||||||
|
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||||
|
"video_url_thumb": "media/3/thumb.jpg",
|
||||||
|
"created_at": "2024-01-17T12:00:00Z",
|
||||||
|
"updated_at": "2024-01-17T12:00:00Z",
|
||||||
|
"title": "Видео",
|
||||||
|
"total_likes": 8,
|
||||||
|
"total_views": 90,
|
||||||
|
"total_comments": 2,
|
||||||
|
"was_liked_by_me": true,
|
||||||
|
"is_favorite": false
|
||||||
|
}
|
||||||
24
test/fixtures/feed_fixtures.dart
vendored
24
test/fixtures/feed_fixtures.dart
vendored
@@ -23,6 +23,18 @@ class FeedFixtures {
|
|||||||
return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> feedBlogPostGalleryResponseJson() {
|
||||||
|
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> feedBlogPostVideoOnlyResponseJson() {
|
||||||
|
return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> feedBlogPostGalleryAndVideoResponseJson() {
|
||||||
|
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json');
|
||||||
|
}
|
||||||
|
|
||||||
static PostFeedPaginationModel postFeedPaginationModel() {
|
static PostFeedPaginationModel postFeedPaginationModel() {
|
||||||
return PostFeedPaginationModel.fromJson(postFeedResponseJson());
|
return PostFeedPaginationModel.fromJson(postFeedResponseJson());
|
||||||
}
|
}
|
||||||
@@ -48,4 +60,16 @@ class FeedFixtures {
|
|||||||
static FeedBlogPostModel feedBlogPostModel() {
|
static FeedBlogPostModel feedBlogPostModel() {
|
||||||
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson());
|
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static FeedBlogPostModel feedBlogPostWithGallery() {
|
||||||
|
return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
static FeedBlogPostModel feedBlogPostVideoOnly() {
|
||||||
|
return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
static FeedBlogPostModel feedBlogPostGalleryAndVideo() {
|
||||||
|
return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user