Merge pull request 'fix: issue-942, issue-967, fixed images positioning for blog posts' (#1055) from misha/eatvio_app:main into main

Reviewed-on: eatvio/eatvio_app#1055
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
This commit is contained in:
2026-09-15 19:31:06 +00:00
49 changed files with 2109 additions and 470 deletions

View File

@@ -122,7 +122,7 @@ class CubitRegistrations {
// Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));

View File

@@ -0,0 +1,61 @@
/// flutter_svg / vector_graphics ignore `<style>` blocks.
/// Paths then get the SVG default fill (black), so badges look like a black blob.
/// This inlines class rules from `<style>` as attributes before parsing.
String inlineSvgCss(String svg) {
final styleBlockPattern = RegExp(
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
caseSensitive: false,
);
final classProps = <String, Map<String, String>>{};
for (final match in styleBlockPattern.allMatches(svg)) {
final css = match.group(1) ?? '';
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
for (final rule in rulePattern.allMatches(css)) {
final className = rule.group(1)!;
final body = rule.group(2)!;
final props = classProps.putIfAbsent(className, () => <String, String>{});
for (final declaration in body.split(';')) {
final colon = declaration.indexOf(':');
if (colon <= 0) {
continue;
}
final key = declaration.substring(0, colon).trim();
final value = declaration.substring(colon + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) {
props[key] = value;
}
}
}
}
var result = svg.replaceAll(styleBlockPattern, '');
if (classProps.isEmpty) {
return result;
}
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
Match match,
) {
final classes = match
.group(2)!
.split(RegExp(r'\s+'))
.where((name) => name.isNotEmpty);
final attrs = <String, String>{};
for (final className in classes) {
final props = classProps[className];
if (props != null) {
attrs.addAll(props);
}
}
if (attrs.isEmpty) {
return match.group(0)!;
}
final attrString = attrs.entries
.map((entry) => '${entry.key}="${entry.value}"')
.join(' ');
return ' $attrString';
});
return result;
}

View File

@@ -25,6 +25,7 @@ import 'package:share_plus/share_plus.dart';
class CustomPopup extends StatelessWidget {
final bool isAuthor;
final bool isAuthorAdmin;
final ComplainedContent? complainedContentType;
final String path;
final String? slug;
@@ -38,6 +39,7 @@ class CustomPopup extends StatelessWidget {
const CustomPopup({
super.key,
required this.isAuthor,
this.isAuthorAdmin = false,
this.complainedContentType,
required this.path,
this.id,
@@ -49,6 +51,9 @@ class CustomPopup extends StatelessWidget {
this.onPostsChanged,
});
bool get _isAdminAuthor =>
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -167,19 +172,21 @@ class CustomPopup extends StatelessWidget {
),
);
});
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
});
if (!_isAdminAuthor) {
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
});
}
addItem(
'assets/icons/complain.svg',
'Заблокировать автора',

View File

@@ -0,0 +1,57 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
final Map<String, String> _inlinedSvgCache = <String, String>{};
class NetworkSvgPicture extends StatefulWidget {
const NetworkSvgPicture({
super.key,
required this.url,
this.width,
this.height,
});
final String url;
final double? width;
final double? height;
@override
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
}
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
late final Future<String> _svgFuture = _load(widget.url);
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _svgFuture,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (!snapshot.hasData) {
return SizedBox(width: widget.width, height: widget.height);
}
return SvgPicture.string(
snapshot.data!,
width: widget.width,
height: widget.height,
);
},
);
}
static Future<String> _load(String url) async {
final cached = _inlinedSvgCache[url];
if (cached != null) {
return cached;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Failed to load SVG from $url');
}
final processed = inlineSvgCss(response.body);
_inlinedSvgCache[url] = processed;
return processed;
}
}

View File

@@ -198,6 +198,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
actions: [
CustomPopup(
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(widget.id)}',
id: widget.id,

View File

@@ -763,7 +763,6 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(

View File

@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
actions: [
CustomPopup(
isAuthor: dietState.diet.author.authorIsMe,
isAuthorAdmin: dietState.diet.author.isAdmin,
path: 'diet/${dietState.diet.slug}',
complainedContentType: ComplainedContent.diet,
id: dietState.diet.id,

View File

@@ -14,12 +14,21 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:share_plus/share_plus.dart';
class MoreOptions extends StatelessWidget {
const MoreOptions({super.key, this.disableActions, required this.path, this.complainedContentType, this.slug, this.id});
const MoreOptions({
super.key,
this.disableActions,
required this.path,
this.complainedContentType,
this.slug,
this.id,
this.isAuthorAdmin = false,
});
final ComplainedContent? complainedContentType;
final int? id;
final String? slug;
final String path;
final bool? disableActions;
final bool isAuthorAdmin;
@override
Widget build(BuildContext context) {
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
],
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: CustomDivider(),
),
GestureDetector(
onTap: disableActions == true
? null
: () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
},
child: Row(
children: [
Expanded(
child: Text(
s.complain,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
if (!isAuthorAdmin) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: CustomDivider(),
),
GestureDetector(
onTap: disableActions == true
? null
: () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
},
child: Row(
children: [
Expanded(
child: Text(
s.complain,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
),
),
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
],
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
],
),
),
),
],
],
),
),

View File

@@ -86,6 +86,8 @@ int parseDuration(dynamic duration) {
@freezed
abstract class Author with _$Author {
const Author._();
const factory Author({
@JsonKey(name: "author_slug") required String authorSlug,
@JsonKey(name: "author_name") required String authorName,
@@ -93,9 +95,12 @@ abstract class Author with _$Author {
@JsonKey(name: "author_is_me") required bool authorIsMe,
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
}) = _Author;
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
bool get isAdmin => authorRoles.contains('admin');
}
@freezed

View File

@@ -26,16 +26,47 @@ class PostFeedMapper {
totalViews: item.postViewsCount,
totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser,
images: item.images,
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb,
);
}
static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return '${sl<AppConfig>().baseUrl}$url';
}
return '${sl<AppConfig>().baseUrl}/storage/$url';
}
static bool isPlaceholderMediaUrl(String url) {
return url.toLowerCase().contains('element-image-placeholder');
}
static bool hasRenderableImage(String? url) {
if (url == null || url.trim().isEmpty) {
return false;
}
return !isPlaceholderMediaUrl(url);
}
static bool hasVisibleMedia({
required String image,
required List<FeedGalleryImage> images,
String? videoEmbedUrl,
}) {
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
return true;
}
if (hasRenderableImage(image)) {
return true;
}
return images.any((image) => hasRenderableImage(image.url));
}
}

View File

@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1);
/// Landscape / video slide aspect (width / height).
/// Fallback while the real photo size is unknown, and for video-only.
static const _horizontalAspectRatio = 16 / 9;
/// Portrait slide aspect (width / height).
static const _verticalAspectRatio = 3 / 4;
bool _isCounterVisible = false;
Timer? _counterHideTimer;
final Map<String, double> _aspectByUrl = {};
final Map<String, ImageStream> _aspectStreams = {};
final Map<String, ImageStreamListener> _aspectListeners = {};
bool get _hasVideo =>
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
/// Height is fixed for the whole post from the first gallery image (web-like).
String get _postOrientation {
if (_gallery.isNotEmpty) {
return _gallery.first.orientation;
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return 'horizontal';
}
double _heightForAspect(double aspectRatio, double width) {
if (aspectRatio <= 0) {
return width / _horizontalAspectRatio;
}
return width / aspectRatio;
}
double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical'
? _verticalAspectRatio
: _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
return (width / aspectRatio).clamp(180.0, maxHeight);
return _heightForAspect(aspectRatio, width);
}
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
double _mediaHeight(double width) {
final gallery = _gallery;
if (gallery.isEmpty) {
return _heightForOrientation(_postOrientation, width);
}
final index = widget.currentGalleryIndex;
final image = index < gallery.length ? gallery[index] : gallery.first;
final aspect = _aspectByUrl[image.url];
if (aspect != null && aspect > 0) {
return _heightForAspect(aspect, width);
}
return _heightForOrientation(image.orientation, width);
}
void _syncAspectListeners() {
final urls = _gallery.map((image) => image.url).toSet();
for (final url in _aspectStreams.keys.toList()) {
if (urls.contains(url)) {
continue;
}
final stream = _aspectStreams.remove(url);
final listener = _aspectListeners.remove(url);
if (stream != null && listener != null) {
stream.removeListener(listener);
}
_aspectByUrl.remove(url);
}
for (final url in urls) {
if (_aspectStreams.containsKey(url)) {
continue;
}
final stream = CachedNetworkImageProvider(
url,
).resolve(ImageConfiguration.empty);
final listener = ImageStreamListener((info, _) {
final aspect = info.image.width / info.image.height;
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
return;
}
setState(() => _aspectByUrl[url] = aspect);
}, onError: (_, _) {});
stream.addListener(listener);
_aspectStreams[url] = stream;
_aspectListeners[url] = listener;
}
}
@override
void initState() {
super.initState();
_syncAspectListeners();
}
@override
void dispose() {
_counterHideTimer?.cancel();
for (final url in _aspectStreams.keys) {
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
}
super.dispose();
}
@override
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
super.didUpdateWidget(oldWidget);
_syncAspectListeners();
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
_scheduleCounterHideOnVideo();
}
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return false;
}
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
Widget _photo(String url, {required double width, double? height}) {
return CachedNetworkImage(
fit: BoxFit.fitWidth,
alignment: Alignment.center,
width: width,
height: height,
imageUrl: url,
errorWidget: (context, str, object) => const SizedBox.shrink(),
);
}
Widget _imageSlide(
String itemUrl,
int itemIndex, {
required double width,
required double height,
}) {
return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.sizeOf(context).width,
height: height,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
child: _photo(itemUrl, width: width, height: height),
);
}
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
viewportFraction: 1,
height: height,
enableInfiniteScroll: false,
disableCenter: true,
padEnds: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index),
),
),
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
return _buildMedia(width);
},
);
}
Widget _buildMedia(double width) {
final gallery = _gallery;
final width = MediaQuery.sizeOf(context).width;
final mediaHeight = _heightForOrientation(_postOrientation, width);
final mediaHeight = _mediaHeight(width);
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
final itemCount = _slideCount;
return _carousel(
itemCount: itemCount,
height: mediaHeight,
itemBuilder: (context, itemIndex) {
if (itemIndex < gallery.length) {
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
}
if (_hasVideo) {
return _videoSlide();
}
return const SizedBox.shrink();
},
return AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: _carousel(
itemCount: itemCount,
height: mediaHeight,
itemBuilder: (context, itemIndex) {
if (itemIndex < gallery.length) {
return _imageSlide(
gallery[itemIndex].url,
itemIndex,
width: width,
height: mediaHeight,
);
}
if (_hasVideo) {
return _videoSlide();
}
return const SizedBox.shrink();
},
),
);
}
if (gallery.length == 1) {
return _imageSlide(gallery.first.url, 0, mediaHeight);
return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
child: _photo(gallery.first.url, width: width),
);
}
if (_hasVideo) {
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(),
);
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
final image = _photo(widget.imageUrl, width: width);
return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: widget.heroTag == null

View File

@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
Widget build(BuildContext context) {
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
final hasMedia = PostFeedMapper.hasVisibleMedia(
image: imageUrl,
images: post.images,
videoEmbedUrl: embedUrl,
);
return InkWell(
borderRadius: BorderRadius.circular(12),
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
children: [
const SizedBox(height: 12),
_BlogPostHeader(post: post),
_BlogPostTextContent(post: post),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'feed-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'feed-blog-post-${post.id}-cover'
: null,
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
if (hasMedia)
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb,
currentGalleryIndex: currentGalleryIndex,
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;
});
},
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
SizedBox(height: hasMedia ? 12 : 8),
_BlogPostActionsRow(
post: post,
isLiked: isLiked,
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
),
CustomPopup(
isAuthor: post.author.authorIsMe,
isAuthorAdmin: post.author.isAdmin,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}',
id: post.id,
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
}
class _BlogPostTextContent extends StatelessWidget {
const _BlogPostTextContent({required this.post});
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
final FeedBlogPostModel post;
final bool includeBottomSpacing;
@override
Widget build(BuildContext context) {
@@ -258,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty)
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
const SizedBox(height: 8),
],
);

View File

@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/profile/models/badge_model.dart';
import 'package:flutter/material.dart';
@@ -92,7 +93,7 @@ class ConsultantWidget extends StatelessWidget {
children: [
for (int i = 0; i < badges.length; i++) ...[
badges[i].image.contains('svg')
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
? NetworkSvgPicture(url: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
],

View File

@@ -80,6 +80,7 @@ class _DietsWidgetState extends State<DietsWidget> {
borderRadius: BorderRadius.circular(4),
child: CustomPopup(
isAuthor: widget.diet.author.authorIsMe,
isAuthorAdmin: widget.diet.author.isAdmin,
path: 'diet/${widget.diet.slug}',
id: widget.diet.id,
complainedContentType: ComplainedContent.diet,

View File

@@ -85,6 +85,7 @@ class _PlanWidgetState extends State<PlanWidget> {
borderRadius: BorderRadius.circular(4),
child: CustomPopup(
isAuthor: widget.plan.author.authorIsMe,
isAuthorAdmin: widget.plan.author.isAdmin,
path: 'plan/${widget.plan.slug}',
id: widget.plan.id,
slug: widget.plan.slug,

View File

@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
borderRadius: BorderRadius.circular(4),
child: CustomPopup(
isAuthor: widget.recipe.author.authorIsMe,
isAuthorAdmin: widget.recipe.author.isAdmin,
path: 'recipe/${widget.recipe.slug}',
slug: widget.recipe.slug,
complainedContentType: ComplainedContent.recipe,

View File

@@ -1,8 +1,20 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
const _passKeyLoginPath = 'login_with_pass_key';
const _contentReadyProgress = 40;
@visibleForTesting
bool shouldHideGlossaryLoader({required String url, required int progress}) {
if (url.isEmpty || url.contains(_passKeyLoginPath)) {
return false;
}
return progress >= _contentReadyProgress;
}
class GlossaryScreen extends StatefulWidget {
const GlossaryScreen({super.key, required this.passKey});
final String passKey;
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
class _GlossaryScreenState extends State<GlossaryScreen> {
late final WebViewController _controller;
bool _isLoading = true;
String _currentUrl = '';
@override
void initState() {
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
_currentUrl = url;
},
onProgress: (progress) {
if (shouldHideGlossaryLoader(url: _currentUrl, progress: progress)) {
_hideLoader();
}
},
onPageFinished: (url) {
if (shouldHideGlossaryLoader(url: url, progress: 100)) {
_hideLoader();
}
},
),
)
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
}
void _hideLoader() {
if (!_isLoading || !mounted) {
return;
}
setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Глоссарий')),
body: WebViewWidget(controller: _controller),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading)
ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: const SizedBox.expand(child: Loader()),
),
],
),
);
}
}

View File

@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
onPressed: () {
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
},
),
],

View File

@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
child: InfoByOrderScreenContent(
successfulPayment: successfulPayment,
planId: planId,
customerSlug: customerSlug,
isConsultantView: isConsultantView,
),
);
}
}
class InfoByOrderScreenContent extends StatefulWidget {
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
const InfoByOrderScreenContent({
super.key,
required this.planId,
required this.successfulPayment,
this.customerSlug,
this.isConsultantView = false,
});
final int planId;
final bool? successfulPayment;
final String? customerSlug;
final bool isConsultantView;
@override
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
}
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
body: SingleChildScrollView(
child: Column(
children: [
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
],
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
const SizedBox(height: 8),
AuthorCard(

View File

@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class AboutPlanCubit extends Cubit<AboutPlanState> {
final PlanRepository planRepository;
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
final ProfileRepository profileRepository;
final DietRepository dietRepository;
AboutPlanCubit({
required this.planRepository,
required this.profileRepository,
required this.dietRepository,
}) : super(AboutPlanState.initial());
Future<void> getPlan({required String slug, int? index}) async {
try {
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
return (null, '');
}
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
Future<ChosenDietModel?> getActivePublicDiet() async {
try {
final chosenDiets = await profileRepository.getChosenDiets();
final lastChosenDiet = chosenDiets.firstOrNull;
if (lastChosenDiet == null) {
return null;
}
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
final isPublic = lastChosenDiet.chosenPlanId == 0;
if (isActive && isPublic && lastChosenDiet.dietId != null) {
return lastChosenDiet;
}
return null;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return null;
}
}
Future<bool> cancelPublicDiet(int dietId) async {
try {
await dietRepository.cancelDiet(dietId);
return true;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
}
String _getOptionNameFromIndex(int index) => switch (index) {
1 => 'options2',
2 => 'options3',

View File

@@ -17,8 +17,10 @@ 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/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:eatvio_app/core/locator_service.dart';
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
Future<void> _onOrderPressed(BuildContext context) async {
final cubit = context.read<AboutPlanCubit>();
final publicDiet = await cubit.getActivePublicDiet();
if (!context.mounted) {
return;
}
if (publicDiet != null) {
showModalBottomSheet(
context: context,
builder: (_) => ConfirmStartPlanBottomSheet(
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
),
);
return;
}
await _submitOrder(context, cubit);
}
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
final s = S.of(context);
if (publicDietId != null) {
final cancelled = await cubit.cancelPublicDiet(publicDietId);
if (!cancelled) {
showErrorToast('Не удалось отменить диету');
return;
}
}
final (chosenPlan, error) = await cubit.chosePlan();
if (!context.mounted) {
return;
}
if (error == 'hasPlan') {
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
return;
}
if (chosenPlan == null) {
showErrorDialog(context, title: 'Не удалось заказать план');
return;
}
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -147,6 +194,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [
CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '',
@@ -182,20 +230,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: appTheme.greenA700,
onPressed: () async {
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
if (error == 'hasPlan') {
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
return;
}
if (chosenPlan == null) {
if (!context.mounted) return;
showErrorDialog(context, title: 'Не удалось заказать план');
return;
}
if (!context.mounted) return;
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
},
onPressed: () => _onOrderPressed(context),
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
),
),
@@ -339,6 +374,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [
CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '',

View File

@@ -0,0 +1,50 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ConfirmStartPlanBottomSheet extends StatelessWidget {
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
final Future<void> Function() onConfirm;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
const BottomSheetHandlerBar(),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25.0),
child: Text(
'Вы уверены, что хотите начать прохождение плана?',
textAlign: TextAlign.center,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
child: CustomOutlinedButton(
onPressed: () async {
await Navigator.of(context).maybePop();
await onConfirm();
},
height: 60,
text: s.confirm,
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
),
),
),
],
);
}
}

View File

@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
abstract class ChosenDietModel with _$ChosenDietModel {
factory ChosenDietModel({
@JsonKey(name: 'id') required int chosenDietID,
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
@JsonKey() required String state,
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
if (json == null) return null;
return json['id'] as int?;
}
int _parseChosenPlanId(dynamic json) {
if (json == null) return 0;
if (json is num) return json.toInt();
return int.tryParse(json.toString()) ?? 0;
}

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
@@ -382,19 +384,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
),
const SizedBox(height: 8),
SizedBox(
height: 781,
child: PageView.builder(
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemCount: state.plansList.length,
),
ExpandablePageView(
itemCount: state.plansList.length,
onPageChanged: (bullet) {
setState(() {
_currentBullet = bullet;
});
},
itemBuilder: (BuildContext context, int index) {
return PlanWidget(plan: state.plansList[index], index: index);
},
),
const SizedBox(height: 8),
_bullets(_currentBullet, state.plansList.length),
@@ -779,7 +778,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)],
),
child: SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40),
child: NetworkSvgPicture(
url: '${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}',
width: 40,
height: 40,
),
)
: Tooltip(
preferBelow: true,
@@ -1105,7 +1108,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
);
},
),
if (!state.isMyProfile) ...[
if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[
_buildDivider(),
_buildCompactItem(
icon: 'assets/icons/complain.svg',

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
class ExpandablePageView extends StatefulWidget {
const ExpandablePageView({
super.key,
required this.itemCount,
required this.itemBuilder,
this.onPageChanged,
});
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final ValueChanged<int>? onPageChanged;
@override
State<ExpandablePageView> createState() => _ExpandablePageViewState();
}
class _ExpandablePageViewState extends State<ExpandablePageView> {
late List<double> _heights;
int _currentPage = 0;
double get _currentHeight {
if (_heights.isEmpty) {
return 0;
}
final page = _currentPage.clamp(0, _heights.length - 1);
return _heights[page];
}
@override
void initState() {
super.initState();
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
}
@override
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.itemCount != oldWidget.itemCount) {
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
if (widget.itemCount == 0) {
_currentPage = 0;
} else {
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
}
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: _currentHeight,
child: PageView.builder(
itemCount: widget.itemCount,
onPageChanged: (int index) {
setState(() {
_currentPage = index;
});
widget.onPageChanged?.call(index);
},
itemBuilder: (BuildContext context, int index) {
return OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: double.infinity,
child: _SizeReportingWidget(
onSizeChange: (Size size) {
if (index >= _heights.length ||
_heights[index] == size.height) {
return;
}
setState(() {
_heights[index] = size.height;
});
},
child: widget.itemBuilder(context, index),
),
);
},
),
);
}
}
class _SizeReportingWidget extends StatefulWidget {
const _SizeReportingWidget({required this.child, required this.onSizeChange});
final Widget child;
final ValueChanged<Size> onSizeChange;
@override
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
}
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
Size? _oldSize;
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
return widget.child;
}
void _notifySize() {
if (!mounted) {
return;
}
final size = context.size;
if (size != null && _oldSize != size) {
_oldSize = size;
widget.onSizeChange(size);
}
}
}

View File

@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
totalLikes = post.totalLikes;
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim();
final hasMedia = PostFeedMapper.hasVisibleMedia(
image: imageUrl,
images: post.images,
videoEmbedUrl: embedUrl,
);
return InkWell(
borderRadius: BorderRadius.circular(12),
@@ -79,44 +84,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
post.author.authorProfileIcon.contains('https')
? post.author.authorProfileIcon
: sl<AppConfig>().baseUrl +
post.author.authorProfileIcon,
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,
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author.authorName,
style: const TextStyle(
height: 1,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
height: 1,
color: Colors.black45,
),
),
],
),
const Spacer(),
CustomPopup(
isAuthor: post.author.authorIsMe,
isAuthorAdmin: post.author.isAdmin || state.profile.roles.contains('admin'),
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}',
id: post.id,
@@ -166,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty)
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
const SizedBox(height: 8),
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
: null,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'profile-blog-post-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'profile-blog-post-${post.id}-cover'
if (hasMedia)
BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
: null,
currentGalleryIndex: currentGalleryIndex,
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;
});
},
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
SizedBox(height: hasMedia ? 12 : 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(

View File

@@ -223,6 +223,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
actions: [
CustomPopup(
isAuthor: recipeState.recipe.author.authorIsMe,
isAuthorAdmin: recipeState.recipe.author.isAdmin,
path: 'recipe/${widget.recipeSlug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipeSlug,

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.43+132
version: 1.3.45+134
publish_to: 'none'
environment:
@@ -93,6 +93,10 @@ dependencies:
wakelock_plus: ^1.5.2
sbp: 0.1.0
# html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0.
dependency_overrides:
html: 0.15.6
dev_dependencies:
build_runner: ^2.10.4
freezed: ^3.2.0

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('inlineSvgCss', () {
test(
'inlines class fills from a style block so flutter_svg can paint colors',
() {
const svg = '''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<style type="text/css">
.st0{fill:#FFD200;}
.st1{fill:#FFFFFF;}
</style>
<circle class="st0" cx="20" cy="20" r="18"/>
<path class="st1" d="M10 20h20"/>
</svg>
''';
final result = inlineSvgCss(svg);
expect(result, isNot(contains('<style')));
expect(result, contains('fill="#FFD200"'));
expect(result, contains('fill="#FFFFFF"'));
expect(result, isNot(contains('class="st0"')));
},
);
test('keeps SVG without style blocks unchanged', () {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#00AA00" cx="10" cy="10" r="5"/></svg>';
expect(inlineSvgCss(svg), svg);
});
});
}

View File

@@ -120,6 +120,29 @@ void main() {
expect(find.text('Заблокировать автора'), findsOneWidget);
});
testWidgets('CustomPopup for non-author admin hides complain but keeps block', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: false,
isAuthorAdmin: true,
complainedContentType: ComplainedContent.plan,
path: 'plans/slug',
slug: 'slug',
userSlug: 'admin-user',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
expect(find.text('Пожаловаться'), findsNothing);
expect(find.text('Заблокировать автора'), findsOneWidget);
});
testWidgets('CustomPopup for author recipe does not show edit action', (tester) async {
await tester.pumpWidget(
wrap(

View File

@@ -128,6 +128,15 @@ void main() {
);
});
testWidgets('does not add extra space above the title field', (tester) async {
await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle();
final titleTop = tester.getTopLeft(find.text('Заголовок')).dy;
final scrollTop = tester.getTopLeft(find.byType(SingleChildScrollView)).dy;
expect(titleTop - scrollTop, 16);
});
testWidgets('pre-fills form fields when editing existing blog post', (
tester,
) async {

View File

@@ -1,4 +1,3 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/message_action_bottom_sheet.dart';
@@ -8,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
class _FakeAssetBundle extends CachingAssetBundle {
_FakeAssetBundle(this.assets);
@@ -66,7 +66,9 @@ void main() {
testWidgets('AboutOrderButton renders and taps', (tester) async {
var tapped = false;
await tester.pumpWidget(wrap(AboutOrderButton(onTap: () => tapped = true)));
await tester.pumpWidget(
wrap(AboutOrderButton(onTap: () => tapped = true)),
);
await tester.pump();
expect(find.byType(AboutOrderButton), findsOneWidget);
await tester.tap(find.byType(AboutOrderButton));
@@ -75,24 +77,44 @@ void main() {
});
testWidgets('MessageActionsBottomSheet renders', (tester) async {
await tester.pumpWidget(wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})));
await tester.pumpWidget(
wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})),
);
await tester.pump();
expect(find.byType(MessageActionsBottomSheet), findsOneWidget);
expect(find.text(S.current.resend), findsOneWidget);
expect(find.text(S.current.delete), findsOneWidget);
});
testWidgets('NotificationBanner renders for multiple banner types', (tester) async {
testWidgets('NotificationBanner renders for multiple banner types', (
tester,
) async {
await tester.pumpWidget(
wrap(
const SingleChildScrollView(
child: Column(
children: [
NotificationBanner(bannerType: BannerType.date, hasPersonalDiet: false),
NotificationBanner(
bannerType: BannerType.date,
hasPersonalDiet: false,
),
NotificationBanner(bannerType: BannerType.card),
NotificationBanner(bannerType: BannerType.collectCharge, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
NotificationBanner(bannerType: BannerType.finishPlan, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
NotificationBanner(bannerType: BannerType.setPersonalDiet, hasPersonalDiet: true),
NotificationBanner(
bannerType: BannerType.collectCharge,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 1,
),
NotificationBanner(
bannerType: BannerType.finishPlan,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 1,
),
NotificationBanner(
bannerType: BannerType.setPersonalDiet,
hasPersonalDiet: true,
),
],
),
),
@@ -101,5 +123,54 @@ void main() {
await tester.pump();
expect(find.byType(NotificationBanner), findsWidgets);
});
testWidgets('collect charge banner opens card settings with chosenPlanId', (
tester,
) async {
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Scaffold(
body: NotificationBanner(
bannerType: BannerType.collectCharge,
planName: 'Plan',
planPrice: 100,
chosenPlanId: 17,
),
),
),
GoRoute(
path: '/card-settings-screen',
builder: (context, state) =>
Scaffold(body: Text('chosenPlanId:${state.extra}')),
),
],
);
await tester.pumpWidget(
DefaultAssetBundle(
bundle: bundle,
child: MaterialApp.router(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
routerConfig: router,
),
),
);
await tester.pump();
expect(find.text('Получить выплату'), findsOneWidget);
await tester.tap(find.text('Получить выплату'));
await tester.pumpAndSettle();
expect(find.text('chosenPlanId:17'), findsOneWidget);
});
});
}

View File

@@ -16,12 +16,18 @@ import 'package:network_image_mock/network_image_mock.dart';
void main() {
group('ConsultationCardWidget 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>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
});
final mockConsultation = ConsultationModel(
@@ -39,9 +45,21 @@ void main() {
endDate: DateTime(2025, 1, 8),
consultantId: 1,
diaries: [
DiaryModel(id: 1, state: DiaryStateEnum.finished, date: DateTime(2025, 1, 1)),
DiaryModel(id: 2, state: DiaryStateEnum.commented, date: DateTime(2025, 1, 2)),
DiaryModel(id: 3, state: DiaryStateEnum.init, date: DateTime(2025, 1, 3)),
DiaryModel(
id: 1,
state: DiaryStateEnum.finished,
date: DateTime(2025, 1, 1),
),
DiaryModel(
id: 2,
state: DiaryStateEnum.commented,
date: DateTime(2025, 1, 2),
),
DiaryModel(
id: 3,
state: DiaryStateEnum.init,
date: DateTime(2025, 1, 3),
),
],
personalDietId: null,
);
@@ -63,18 +81,25 @@ void main() {
GoRoute(
path: '/profile-screen',
name: 'profileScreen',
builder: (context, state) => const Scaffold(body: Text('Profile')),
builder: (context, state) =>
const Scaffold(body: Text('Profile')),
),
GoRoute(
path: '/user-details-screen',
name: 'userDetailsScreen',
builder: (context, state) => const Scaffold(body: Text('User Details')),
builder: (context, state) =>
const Scaffold(body: Text('User Details')),
),
GoRoute(
path: '/diary-screen',
name: 'diaryScreen',
builder: (context, state) => const Scaffold(body: Text('Diary')),
),
GoRoute(
path: '/card-settings-screen',
builder: (context, state) =>
Scaffold(body: Text('chosenPlanId:${state.extra}')),
),
],
),
);
@@ -82,7 +107,9 @@ void main() {
testWidgets('renders consultation card with user info', (tester) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(ConsultationCardWidget(consultationsModel: mockConsultation)));
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
);
await tester.pumpAndSettle();
expect(find.text('John Doe'), findsOneWidget);
@@ -92,12 +119,49 @@ void main() {
testWidgets('renders diary days', (tester) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(ConsultationCardWidget(consultationsModel: mockConsultation)));
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
);
await tester.pumpAndSettle();
expect(find.byType(InkWell), findsWidgets);
});
});
testWidgets('hides collect charge when consultation is in progress', (
tester,
) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
);
await tester.pumpAndSettle();
expect(find.text('Получить выплату'), findsNothing);
});
});
testWidgets(
'shows collect charge and opens card settings with chosen plan id when done',
(tester) async {
final doneConsultation = mockConsultation.copyWith(
chosenPlanState: ChosenPlanStateEnum.stateDone,
);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: doneConsultation)),
);
await tester.pumpAndSettle();
expect(find.text('Получить выплату'), findsOneWidget);
await tester.tap(find.text('Получить выплату'));
await tester.pumpAndSettle();
expect(find.text('chosenPlanId:1'), findsOneWidget);
});
},
);
});
}

View File

@@ -24,6 +24,7 @@ void main() {
String? slug,
int? id,
bool? disableActions,
bool isAuthorAdmin = false,
}) {
return MaterialApp(
theme: ThemeHelper().themeData(),
@@ -42,6 +43,7 @@ void main() {
slug: slug,
id: id,
disableActions: disableActions,
isAuthorAdmin: isAuthorAdmin,
),
),
);
@@ -71,7 +73,23 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byType(MoreOptions), findsOneWidget);
expect(find.text('Пожаловаться'), findsOneWidget);
});
testWidgets('hides complain option when author is admin', (tester) async {
await tester.pumpWidget(
createTestWidget(
complainedContentType: ComplainedContent.diet,
id: 1,
slug: 'test-slug',
isAuthorAdmin: true,
),
);
await tester.pumpAndSettle();
expect(find.text('Пожаловаться'), findsNothing);
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
});
testWidgets('disables actions when disableActions is true', (tester) async {

View File

@@ -56,6 +56,33 @@ void main() {
});
});
group('Author', () {
test('fromJson читает author_roles и определяет админа', () {
final author = Author.fromJson({
'author_slug': 'admin-user',
'author_name': 'Admin',
'author_profile_icon': 'icon.jpg',
'author_is_me': false,
'author_roles': ['admin', 'consultant'],
});
expect(author.authorRoles, ['admin', 'consultant']);
expect(author.isAdmin, isTrue);
});
test('fromJson без author_roles не считает автора админом', () {
final author = Author.fromJson({
'author_slug': 'regular-user',
'author_name': 'User',
'author_profile_icon': 'icon.jpg',
'author_is_me': false,
});
expect(author.authorRoles, isEmpty);
expect(author.isAdmin, isFalse);
});
});
group('DietState', () {
test('toString возвращает правильные значения', () {
expect(DietState.active.toString(), 'active');

View File

@@ -52,5 +52,14 @@ void main() {
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
expect(posts[2].author.isEntitySubscribedByMe, isNull);
});
test('treats API placeholder cover as missing image', () {
expect(PostFeedMapper.resolveMediaUrl('images/element-image-placeholder.svg'), isEmpty);
expect(PostFeedMapper.hasRenderableImage('images/element-image-placeholder.svg'), isFalse);
expect(
PostFeedMapper.hasVisibleMedia(image: '', images: const [], videoEmbedUrl: null),
isFalse,
);
});
});
}

View File

@@ -0,0 +1,111 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
void main() {
const gallery = [
FeedGalleryImage(
url: 'https://example.com/a.jpg',
orientation: 'horizontal',
),
FeedGalleryImage(url: 'https://example.com/b.jpg', orientation: 'vertical'),
];
Widget wrap(Widget child) {
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: Size(390, 844)),
child: Scaffold(body: child),
),
);
}
BlogPostMediaContent media({
List<FeedGalleryImage> images = gallery,
String imageUrl = '',
}) {
return BlogPostMediaContent(
galleryImages: images,
imageUrl: imageUrl,
videoEmbedUrl: null,
videoUrlThumb: null,
currentGalleryIndex: 0,
onOpenGallery: (_, _) {},
onGalleryChanged: (_) {},
);
}
testWidgets('gallery slides use fitWidth so photos fill the card width', (
tester,
) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(media()));
await tester.pump();
final images = tester.widgetList<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(images, isNotEmpty);
for (final image in images) {
expect(image.fit, BoxFit.fitWidth);
}
});
});
testWidgets('single gallery image uses fitWidth without a forced height', (
tester,
) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(media(images: [gallery.first])));
await tester.pump();
final image = tester.widget<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(image.fit, BoxFit.fitWidth);
expect(image.height, isNull);
});
});
testWidgets('cover-only image keeps fitWidth', (tester) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
media(images: const [], imageUrl: 'https://example.com/cover.jpg'),
),
);
await tester.pump();
final image = tester.widget<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(image.fit, BoxFit.fitWidth);
});
});
testWidgets('empty cover does not reserve media height', (tester) async {
await tester.pumpWidget(wrap(media(images: const [], imageUrl: '')));
await tester.pump();
expect(find.byType(CachedNetworkImage), findsNothing);
expect(tester.getSize(find.byType(BlogPostMediaContent)).height, 0);
});
testWidgets('placeholder cover does not reserve media height', (tester) async {
await tester.pumpWidget(
wrap(
media(
images: const [],
imageUrl: 'images/element-image-placeholder.svg',
),
),
);
await tester.pump();
expect(find.byType(CachedNetworkImage), findsNothing);
expect(tester.getSize(find.byType(BlogPostMediaContent)).height, 0);
});
}

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -46,6 +47,7 @@ void main() {
expect(find.byType(GlossaryScreen), findsOneWidget);
expect(find.byType(Scaffold), findsOneWidget);
expect(find.text('Глоссарий'), findsOneWidget);
expect(find.byType(Loader), findsOneWidget);
});
testWidgets('GlossaryScreen создается с правильным passKey', (WidgetTester tester) async {
@@ -71,6 +73,66 @@ void main() {
expect(find.byType(GlossaryScreen), findsOneWidget);
});
testWidgets('показывает стандартный лоадер при загрузке WebView', (WidgetTester tester) async {
const passKey = 'test-pass-key';
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const GlossaryScreen(passKey: passKey),
),
],
);
await tester.pumpWidget(
MaterialApp.router(
routerConfig: router,
),
);
await tester.pump();
expect(find.byType(Loader), findsOneWidget);
});
});
group('shouldHideGlossaryLoader', () {
test('не скрывает лоадер на странице login_with_pass_key', () {
expect(
shouldHideGlossaryLoader(
url: 'https://eatvio.ru/login_with_pass_key?id=1',
progress: 100,
),
isFalse,
);
});
test('не скрывает лоадер пока URL ещё не известен', () {
expect(shouldHideGlossaryLoader(url: '', progress: 80), isFalse);
});
test('не скрывает лоадер пока прогресс глоссария ниже порога', () {
expect(
shouldHideGlossaryLoader(
url: 'https://eatvio.ru/product_nutritional_value',
progress: 39,
),
isFalse,
);
});
test('скрывает лоадер когда страница глоссария уже начала отрисовываться', () {
expect(
shouldHideGlossaryLoader(
url: 'https://eatvio.ru/product_nutritional_value',
progress: 40,
),
isTrue,
);
});
});
}

View File

@@ -1,10 +1,12 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/notifications/models/notification_model.dart';
import 'package:eatvio_app/features/notifications/presentation/widgets/notification_widget.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
void main() {
group('NotificationWidget', () {
@@ -21,13 +23,13 @@ void main() {
supportedLocales: const [Locale('ru'), Locale('en')],
locale: const Locale('ru'),
theme: ThemeHelper().themeData(),
home: Scaffold(
body: NotificationWidget(notification: notification),
),
home: Scaffold(body: NotificationWidget(notification: notification)),
);
}
testWidgets('displays PlanSubscriptionStartedToAuthorNotification', (tester) async {
testWidgets('displays PlanSubscriptionStartedToAuthorNotification', (
tester,
) async {
final notification = PlanSubscriptionStartedToAuthorNotification(
type: NotificationType.planSubscriptionStartedToAuthor,
planName: 'Test Plan',
@@ -47,7 +49,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PlanSubscriptionCanceledToAuthorNotification', (tester) async {
testWidgets('displays PlanSubscriptionCanceledToAuthorNotification', (
tester,
) async {
final notification = PlanSubscriptionCanceledToAuthorNotification(
type: NotificationType.planSubscriptionCanceledToAuthor,
planName: 'Test Plan',
@@ -66,7 +70,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanStartedToClientNotification', (tester) async {
testWidgets('displays ChosenPlanStartedToClientNotification', (
tester,
) async {
final notification = ChosenPlanStartedToClientNotification(
type: NotificationType.chosenPlanStartedToClient,
planName: 'Test Plan',
@@ -83,7 +89,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanTomorrowReminderToClientNotification', (tester) async {
testWidgets('displays ChosenPlanTomorrowReminderToClientNotification', (
tester,
) async {
final notification = ChosenPlanTomorrowReminderToClientNotification(
type: NotificationType.chosenPlanTomorrowReminderToClient,
planName: 'Test Plan',
@@ -131,7 +139,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PersonalizedDietIsDefinedNotification', (tester) async {
testWidgets('displays PersonalizedDietIsDefinedNotification', (
tester,
) async {
final notification = PersonalizedDietIsDefinedNotification(
type: NotificationType.personalizedDietIsDefined,
isRead: false,
@@ -144,7 +154,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanFinishedToClientNotification', (tester) async {
testWidgets('displays ChosenPlanFinishedToClientNotification', (
tester,
) async {
final notification = ChosenPlanFinishedToClientNotification(
type: NotificationType.chosenPlanFinishedToClient,
planName: 'Test Plan',
@@ -163,7 +175,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PlanSubscriptionCanceledToCustomerNotification', (tester) async {
testWidgets('displays PlanSubscriptionCanceledToCustomerNotification', (
tester,
) async {
final notification = PlanSubscriptionCanceledToCustomerNotification(
type: NotificationType.planSubscriptionCanceledToCustomer,
planName: 'Test Plan',
@@ -182,7 +196,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanStartedToConsultantNotification', (tester) async {
testWidgets('displays ChosenPlanStartedToConsultantNotification', (
tester,
) async {
final notification = ChosenPlanStartedToConsultantNotification(
type: NotificationType.chosenPlanStartedToConsultant,
planName: 'Test Plan',
@@ -201,7 +217,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanTomorrowReminderToConsultantNotification', (tester) async {
testWidgets('displays ChosenPlanTomorrowReminderToConsultantNotification', (
tester,
) async {
final notification = ChosenPlanTomorrowReminderToConsultantNotification(
type: NotificationType.chosenPlanTomorrowReminderToConsultant,
planName: 'Test Plan',
@@ -217,7 +235,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PlanSubscriptionPaidToAuthorNotification', (tester) async {
testWidgets('displays PlanSubscriptionPaidToAuthorNotification', (
tester,
) async {
final notification = PlanSubscriptionPaidToAuthorNotification(
type: NotificationType.planSubscriptionPaidToAuthor,
planName: 'Test Plan',
@@ -236,24 +256,86 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanFinishedToConsultantNotification with button', (tester) async {
final notification = ChosenPlanFinishedToConsultantNotification(
type: NotificationType.chosenPlanFinishedToConsultant,
planName: 'Test Plan',
customerName: 'Test Customer',
planId: 1,
chosenPlanId: 1,
clientPhotoPath: '',
clientId: 0,
isRead: false,
createdAt: testDate,
);
testWidgets(
'displays ChosenPlanFinishedToConsultantNotification with button',
(tester) async {
final notification = ChosenPlanFinishedToConsultantNotification(
type: NotificationType.chosenPlanFinishedToConsultant,
planName: 'Test Plan',
customerName: 'Test Customer',
planId: 1,
chosenPlanId: 1,
clientPhotoPath: '',
clientId: 0,
isRead: false,
createdAt: testDate,
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(find.byType(NotificationWidget), findsOneWidget);
});
expect(find.byType(NotificationWidget), findsOneWidget);
expect(find.text('Получить выплату'), findsOneWidget);
},
);
testWidgets(
'collect charge from finished notification uses chosenPlanId, not planId',
(tester) async {
int? capturedChosenPlanId;
final notification = ChosenPlanFinishedToConsultantNotification(
type: NotificationType.chosenPlanFinishedToConsultant,
planName: 'Test Plan',
customerName: 'Test Customer',
planId: 99,
chosenPlanId: 42,
clientPhotoPath: '',
clientId: 0,
isRead: false,
createdAt: testDate,
);
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => Scaffold(
body: NotificationWidget(notification: notification),
),
),
GoRoute(
path: CardSettingsScreen.path,
builder: (context, state) {
capturedChosenPlanId = state.extra as int?;
return const Scaffold(body: Text('card-settings'));
},
),
],
);
await tester.pumpWidget(
MaterialApp.router(
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru'), Locale('en')],
locale: const Locale('ru'),
theme: ThemeHelper().themeData(),
routerConfig: router,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Получить выплату'));
await tester.pumpAndSettle();
expect(find.text('card-settings'), findsOneWidget);
expect(capturedChosenPlanId, 42);
},
);
testWidgets('displays ChosenPlanLastDayFinishedForUser', (tester) async {
final notification = ChosenPlanLastDayFinishedForUser(
@@ -587,7 +669,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PersonalDietCreationNeededNotification', (tester) async {
testWidgets('displays PersonalDietCreationNeededNotification', (
tester,
) async {
final notification = PersonalDietCreationNeededNotification(
type: NotificationType.personalDietCreationNeeded,
userName: 'Test Client',
@@ -603,7 +687,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanClientLastDayFinishedNotification', (tester) async {
testWidgets('displays ChosenPlanClientLastDayFinishedNotification', (
tester,
) async {
final notification = ChosenPlanClientLastDayFinishedNotification(
type: NotificationType.chosenPlanClientLastDayFinished,
planName: 'Test Plan',
@@ -620,7 +706,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ChosenPlanClientLastDayStartedNotification', (tester) async {
testWidgets('displays ChosenPlanClientLastDayStartedNotification', (
tester,
) async {
final notification = ChosenPlanClientLastDayStartedNotification(
type: NotificationType.chosenPlanClientLastDayStarted,
planName: 'Test Plan',
@@ -653,47 +741,58 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ClientStartPlanSubscriptionToSubscriberNotification', (tester) async {
final notification = ClientStartPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientStartPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
chatId: '',
startDate: '',
isRead: false,
createdAt: testDate,
);
testWidgets(
'displays ClientStartPlanSubscriptionToSubscriberNotification',
(tester) async {
final notification =
ClientStartPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientStartPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
chatId: '',
startDate: '',
isRead: false,
createdAt: testDate,
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(find.byType(NotificationWidget), findsOneWidget);
});
expect(find.byType(NotificationWidget), findsOneWidget);
},
);
testWidgets('displays ConsultantConfirmPlanSubscriptionToSubscriberNotification', (tester) async {
final notification = ConsultantConfirmPlanSubscriptionToSubscriberNotification(
type: NotificationType.consultantConfirmPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
consultantName: 'Test Consultant',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
testWidgets(
'displays ConsultantConfirmPlanSubscriptionToSubscriberNotification',
(tester) async {
final notification =
ConsultantConfirmPlanSubscriptionToSubscriberNotification(
type: NotificationType
.consultantConfirmPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
consultantName: 'Test Consultant',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(find.byType(NotificationWidget), findsOneWidget);
});
expect(find.byType(NotificationWidget), findsOneWidget);
},
);
testWidgets('displays ClientPaidPlanSubscriptionToAuthorNotification', (tester) async {
testWidgets('displays ClientPaidPlanSubscriptionToAuthorNotification', (
tester,
) async {
final notification = ClientPaidPlanSubscriptionToAuthorNotification(
type: NotificationType.clientPaidPlanSubscriptionToAuthor,
planName: 'Test Plan',
@@ -712,7 +811,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ConsultantLeftDiaryCommentNotification', (tester) async {
testWidgets('displays ConsultantLeftDiaryCommentNotification', (
tester,
) async {
final notification = ConsultantLeftDiaryCommentNotification(
type: NotificationType.consultantLeftDiaryComment,
chosenPlanId: 1,
@@ -731,26 +832,32 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ConsultantConfirmPlanSubscriptionToAuthorNotification', (tester) async {
final notification = ConsultantConfirmPlanSubscriptionToAuthorNotification(
type: NotificationType.consultantConfirmPlanSubscriptionToAuthor,
planName: 'Test Plan',
planId: 1,
clientName: 'Test Consultant',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
testWidgets(
'displays ConsultantConfirmPlanSubscriptionToAuthorNotification',
(tester) async {
final notification =
ConsultantConfirmPlanSubscriptionToAuthorNotification(
type: NotificationType.consultantConfirmPlanSubscriptionToAuthor,
planName: 'Test Plan',
planId: 1,
clientName: 'Test Consultant',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(find.byType(NotificationWidget), findsOneWidget);
});
expect(find.byType(NotificationWidget), findsOneWidget);
},
);
testWidgets('displays ClientPaidPlanSubscriptionToSubscriberNotification', (tester) async {
testWidgets('displays ClientPaidPlanSubscriptionToSubscriberNotification', (
tester,
) async {
final notification = ClientPaidPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientPaidPlanSubscriptionToSubscriber,
planName: 'Test Plan',
@@ -769,7 +876,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ClientStartPlanSubscriptionToAuthorNotification', (tester) async {
testWidgets('displays ClientStartPlanSubscriptionToAuthorNotification', (
tester,
) async {
final notification = ClientStartPlanSubscriptionToAuthorNotification(
type: NotificationType.clientStartPlanSubscriptionToAuthor,
planName: 'Test Plan',
@@ -789,7 +898,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ClientCancelPlanSubscriptionToAuthorNotification', (tester) async {
testWidgets('displays ClientCancelPlanSubscriptionToAuthorNotification', (
tester,
) async {
final notification = ClientCancelPlanSubscriptionToAuthorNotification(
type: NotificationType.clientCancelPlanSubscriptionToAuthor,
planName: 'Test Plan',
@@ -808,26 +919,32 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ClientCancelPlanSubscriptionToSubscriberNotification', (tester) async {
final notification = ClientCancelPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientCancelPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
testWidgets(
'displays ClientCancelPlanSubscriptionToSubscriberNotification',
(tester) async {
final notification =
ClientCancelPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientCancelPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
await tester.pumpWidget(createTestWidget(notification));
await tester.pumpAndSettle();
expect(find.byType(NotificationWidget), findsOneWidget);
});
expect(find.byType(NotificationWidget), findsOneWidget);
},
);
testWidgets('displays UserDailyReportCompletedNotification', (tester) async {
testWidgets('displays UserDailyReportCompletedNotification', (
tester,
) async {
final notification = UserDailyReportCompletedNotification(
type: NotificationType.userDailyReportCompleted,
chosenPlanId: 1,
@@ -924,7 +1041,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ConsultantRequestStartedNotification', (tester) async {
testWidgets('displays ConsultantRequestStartedNotification', (
tester,
) async {
final notification = ConsultantRequestStartedNotification(
type: NotificationType.consultantRequestStarted,
consultantName: 'Test Consultant',
@@ -938,7 +1057,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ClientCloseComplaintSuccessNotification', (tester) async {
testWidgets('displays ClientCloseComplaintSuccessNotification', (
tester,
) async {
final notification = ClientCloseComplaintSuccessNotification(
type: NotificationType.clientCloseComplaintSuccess,
planName: 'Test Plan',
@@ -1032,7 +1153,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays ProductStatusUpdatedForAuthorNotification', (tester) async {
testWidgets('displays ProductStatusUpdatedForAuthorNotification', (
tester,
) async {
final notification = ProductStatusUpdatedForAuthorNotification(
type: NotificationType.productStatusUpdatedForAuthor,
productName: 'Test Product',
@@ -1065,7 +1188,9 @@ void main() {
expect(find.byType(NotificationWidget), findsOneWidget);
});
testWidgets('displays PersonalDietIsDefinedToCustomerNotification', (tester) async {
testWidgets('displays PersonalDietIsDefinedToCustomerNotification', (
tester,
) async {
final notification = PersonalDietIsDefinedToCustomerNotification(
type: NotificationType.personalDietIsDefinedToCustomer,
isRead: false,
@@ -1128,35 +1253,3 @@ void main() {
});
});
}

View File

@@ -7,8 +7,10 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -29,15 +31,24 @@ void main() {
late OrderInfoCubit orderInfoCubit;
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>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
mockChatRepository = MockChatRepository();
mockProfileRepository = MockProfileRepository();
orderInfoCubit = OrderInfoCubit(chatRepository: mockChatRepository, profileRepository: mockProfileRepository);
orderInfoCubit = OrderInfoCubit(
chatRepository: mockChatRepository,
profileRepository: mockProfileRepository,
);
sl.registerLazySingleton<OrderInfoCubit>(() => orderInfoCubit);
});
@@ -45,7 +56,11 @@ void main() {
orderInfoCubit.close();
});
Widget createTestWidget({required int planId, bool? successfulPayment, String? customerSlug}) {
Widget createTestWidget({
required int planId,
bool? successfulPayment,
String? customerSlug,
}) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
@@ -55,12 +70,20 @@ void main() {
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment, customerSlug: customerSlug),
home: InfoByOrderScreen(
planId: planId,
successfulPayment: successfulPayment,
customerSlug: customerSlug,
),
);
}
testWidgets('displays screen with loading state initially', (WidgetTester tester) async {
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
testWidgets('displays screen with loading state initially', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenThrow(Exception('Not implemented'));
await tester.pumpWidget(createTestWidget(planId: 1));
await tester.pump();
@@ -69,8 +92,12 @@ void main() {
expect(find.byType(Scaffold), findsOneWidget);
});
testWidgets('calls getChosenPlan when screen is created', (WidgetTester tester) async {
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
testWidgets('calls getChosenPlan when screen is created', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenThrow(Exception('Not implemented'));
await tester.pumpWidget(createTestWidget(planId: 123));
await tester.pump();
@@ -78,18 +105,28 @@ void main() {
verify(mockChatRepository.getChosenPlan(id: 123)).called(1);
});
testWidgets('calls getChosenPlan when customerSlug is provided', (WidgetTester tester) async {
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
testWidgets('calls getChosenPlan when customerSlug is provided', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenThrow(Exception('Not implemented'));
await tester.pumpWidget(createTestWidget(planId: 123, customerSlug: 'test-user'));
await tester.pumpWidget(
createTestWidget(planId: 123, customerSlug: 'test-user'),
);
await tester.pump();
verify(mockChatRepository.getChosenPlan(id: 123)).called(1);
// Note: getProfileBySlug is only called if getChosenPlan succeeds
});
testWidgets('displays screen with back button', (WidgetTester tester) async {
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
testWidgets('displays screen with back button', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenThrow(Exception('Not implemented'));
await tester.pumpWidget(createTestWidget(planId: 1));
await tester.pump();
@@ -98,7 +135,9 @@ void main() {
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('displays screen with loaded plan', (WidgetTester tester) async {
testWidgets('displays screen with loaded plan', (
WidgetTester tester,
) async {
final chosenPlan = ChosenPlanModel(
id: 1,
optionType: 'options1',
@@ -113,10 +152,20 @@ void main() {
image: 'https://example.com/image.jpg',
duration: 30,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 3,
author: Author(authorSlug: 'author', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
@@ -127,7 +176,9 @@ void main() {
),
);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => chosenPlan);
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenAnswer((_) async => chosenPlan);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(createTestWidget(planId: 1));
@@ -137,7 +188,9 @@ void main() {
expect(find.byType(InfoByOrderScreen), findsOneWidget);
});
testWidgets('displays screen with successful payment', (WidgetTester tester) async {
testWidgets('displays screen with successful payment', (
WidgetTester tester,
) async {
final chosenPlan = ChosenPlanModel(
id: 1,
optionType: 'options1',
@@ -152,10 +205,20 @@ void main() {
image: 'https://example.com/image.jpg',
duration: 30,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 3,
author: Author(authorSlug: 'author', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
@@ -166,14 +229,114 @@ void main() {
),
);
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => chosenPlan);
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenAnswer((_) async => chosenPlan);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(createTestWidget(planId: 1, successfulPayment: true));
await tester.pumpWidget(
createTestWidget(planId: 1, successfulPayment: true),
);
await tester.pumpAndSettle();
});
expect(find.byType(InfoByOrderScreen), findsOneWidget);
});
ChosenPlanModel donePlan() {
return ChosenPlanModel(
id: 1,
optionType: 'options1',
startDate: DateTime(2024, 1, 1),
chosenPlanState: ChosenPlanStateEnum.stateDone,
chatId: 'chat-1',
consultantId: 1,
plan: const PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'https://example.com/image.jpg',
duration: 30,
state: 'active',
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 3,
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 1000,
maxPriceOfOptions: 2000,
wasLikedByMe: false,
),
);
}
testWidgets(
'shows collect charge banner for consultant when plan is done',
(WidgetTester tester) async {
const profile = ProfileModel(
id: 2,
name: 'Customer',
email: 'customer@example.com',
phoneConfirmationState: 'confirmed',
isPublicProfile: true,
isBlockedByMe: false,
isBlockedByAuthor: false,
isBlockedByUser: false,
isEntitySubscribedByMe: false,
slug: 'test-user',
profilePhotoUrl: '/test.jpg',
roles: ['user'],
badges: [],
subscriptions: [],
subscribers: [],
);
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenAnswer((_) async => donePlan());
when(
mockProfileRepository.getProfileBySlug('test-user'),
).thenAnswer((_) async => profile);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
createTestWidget(planId: 1, customerSlug: 'test-user'),
);
await tester.pumpAndSettle();
});
expect(find.byType(CollectChargeBanner), findsOneWidget);
expect(find.text('Получить выплату'), findsOneWidget);
},
);
testWidgets('hides collect charge banner for client when plan is done', (
WidgetTester tester,
) async {
when(
mockChatRepository.getChosenPlan(id: anyNamed('id')),
).thenAnswer((_) async => donePlan());
await mockNetworkImagesFor(() async {
await tester.pumpWidget(createTestWidget(planId: 1));
await tester.pumpAndSettle();
});
expect(find.byType(CollectChargeBanner), findsNothing);
expect(find.text('Получить выплату'), findsNothing);
});
});
}

View File

@@ -6,8 +6,10 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart' show PlanModel, PlanType;
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart' show Author;
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'
show PlanModel, PlanType;
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'
show Author;
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -20,8 +22,14 @@ void main() {
group('CollectChargeBanner', () {
setUp(() async {
await sl.reset();
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
dotenv.loadFromString(
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
);
sl.registerLazySingleton<AppConfig>(
() =>
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
});
Widget createTestWidget(Widget child) {
@@ -42,12 +50,76 @@ void main() {
child: Scaffold(body: SingleChildScrollView(child: child)),
),
),
GoRoute(
path: '/card-settings-screen',
builder: (context, state) =>
Scaffold(body: Text('chosenPlanId:${state.extra}')),
),
],
),
);
}
testWidgets('should display collect charge banner with plan name and price', (WidgetTester tester) async {
testWidgets(
'should display collect charge banner with plan name and price',
(WidgetTester tester) async {
// Arrange
final chosenPlan = ChosenPlanModel(
id: 1,
optionType: 'standard',
startDate: DateTime.now(),
chosenPlanState: ChosenPlanStateEnum.stateDone,
chatId: 'chat-1',
consultantId: 1,
plan: const PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'test.jpg',
duration: 30,
state: 'active',
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 1,
author: Author(
authorSlug: '',
authorName: '',
authorProfileIcon: '',
authorIsMe: false,
),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
isFavorite: false,
minPriceOfOptions: 100.0,
maxPriceOfOptions: 500.0,
wasLikedByMe: false,
),
);
const price = 1000.0;
final widget = CollectChargeBanner(
chosenPlan: chosenPlan,
price: price,
);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
// Assert
expect(find.byType(CollectChargeBanner), findsOneWidget);
expect(find.byType(CustomOutlinedButton), findsOneWidget);
},
);
testWidgets('should have button that navigates to card settings', (
WidgetTester tester,
) async {
// Arrange
final chosenPlan = ChosenPlanModel(
id: 1,
@@ -63,10 +135,20 @@ void main() {
image: 'test.jpg',
duration: 30,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 1,
author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
author: Author(
authorSlug: '',
authorName: '',
authorProfileIcon: '',
authorIsMe: false,
),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
@@ -83,50 +165,10 @@ void main() {
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
// Assert
expect(find.byType(CollectChargeBanner), findsOneWidget);
expect(find.byType(CustomOutlinedButton), findsOneWidget);
});
await tester.tap(find.byType(CustomOutlinedButton));
await tester.pumpAndSettle();
testWidgets('should have button that navigates to card settings', (WidgetTester tester) async {
// Arrange
final chosenPlan = ChosenPlanModel(
id: 1,
optionType: 'standard',
startDate: DateTime.now(),
chosenPlanState: ChosenPlanStateEnum.stateDone,
chatId: 'chat-1',
consultantId: 1,
plan: const PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'test.jpg',
duration: 30,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
details: 'Test details',
packetsCount: 1,
author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
totalLikes: 0,
totalViews: 0,
totalComments: 0,
isFavorite: false,
minPriceOfOptions: 100.0,
maxPriceOfOptions: 500.0,
wasLikedByMe: false,
),
);
const price = 1000.0;
final widget = CollectChargeBanner(chosenPlan: chosenPlan, price: price);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
// Assert - проверяем, что кнопка существует
expect(find.byType(CustomOutlinedButton), findsOneWidget);
expect(find.text('chosenPlanId:1'), findsOneWidget);
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
@@ -6,22 +7,36 @@ import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'about_plan_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<PlanRepository>()])
@GenerateNiceMocks([MockSpec<PlanRepository>(), MockSpec<ProfileRepository>(), MockSpec<DietRepository>()])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AboutPlanCubit', () {
AboutPlanCubit? aboutPlanCubit;
late MockPlanRepository mockPlanRepository;
late MockProfileRepository mockProfileRepository;
late MockDietRepository mockDietRepository;
AboutPlanCubit createCubit() {
return AboutPlanCubit(
planRepository: mockPlanRepository,
profileRepository: mockProfileRepository,
dietRepository: mockDietRepository,
);
}
setUp(() {
mockPlanRepository = MockPlanRepository();
mockProfileRepository = MockProfileRepository();
mockDietRepository = MockDietRepository();
});
tearDown(() async {
@@ -32,7 +47,7 @@ void main() {
});
test('начальное состояние - initial', () {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
expect(aboutPlanCubit!.state, isA<AboutPlanState>());
expect(aboutPlanCubit!.state.isLoading, isFalse);
@@ -40,7 +55,7 @@ void main() {
});
test('getPlan - успешная загрузка плана', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -79,7 +94,7 @@ void main() {
});
test('getPlanWithLoading - устанавливает loading состояние', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -117,7 +132,7 @@ void main() {
});
test('choseOption - изменяет выбранную опцию', () {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
aboutPlanCubit!.choseOption(2);
@@ -125,7 +140,7 @@ void main() {
});
test('likePlan - лайк плана', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -166,7 +181,7 @@ void main() {
});
test('getPlan - обработка ошибки', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки'));
@@ -177,7 +192,7 @@ void main() {
});
test('getPlanWithLoading - обработка ошибки', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки'));
@@ -188,7 +203,7 @@ void main() {
});
test('likePlan - убирает лайк если уже лайкнуто', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -231,7 +246,7 @@ void main() {
});
test('addToFavorite - добавляет план в избранное', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -273,7 +288,7 @@ void main() {
});
test('removeFromFavorite - удаляет план из избранного', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -315,7 +330,7 @@ void main() {
});
test('chosePlan - успешно выбирает план', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -368,7 +383,7 @@ void main() {
});
test('chosePlan - возвращает ошибку hasPlan при 400 статусе', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -415,7 +430,7 @@ void main() {
});
test('likePlan - лайк плана с index', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -458,7 +473,7 @@ void main() {
});
test('likePlan - убирает лайк с index', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -501,7 +516,7 @@ void main() {
});
test('likePlan - обрабатывает ошибку', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -542,7 +557,7 @@ void main() {
});
test('likePlan - не делает ничего если план не загружен', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan');
@@ -551,7 +566,7 @@ void main() {
});
test('addToFavorite - добавляет план в избранное с index', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -592,7 +607,7 @@ void main() {
});
test('addToFavorite - обрабатывает ошибку', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -632,7 +647,7 @@ void main() {
});
test('addToFavorite - не делает ничего если план не загружен', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: null);
@@ -641,7 +656,7 @@ void main() {
});
test('removeFromFavorite - удаляет план из избранного с index', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -682,7 +697,7 @@ void main() {
});
test('removeFromFavorite - обрабатывает ошибку', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -722,7 +737,7 @@ void main() {
});
test('removeFromFavorite - не делает ничего если план не загружен', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: null);
@@ -731,7 +746,7 @@ void main() {
});
test('chosePlan - выбирает план с optionIndex 1', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -784,7 +799,7 @@ void main() {
});
test('chosePlan - выбирает план с optionIndex 2', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -837,7 +852,7 @@ void main() {
});
test('chosePlan - возвращает пустой результат когда slug == null', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
final result = await aboutPlanCubit!.chosePlan();
@@ -847,7 +862,7 @@ void main() {
});
test('chosePlan - обрабатывает DioException с другим статусом', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -894,7 +909,7 @@ void main() {
});
test('chosePlan - обрабатывает общее исключение', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -936,7 +951,7 @@ void main() {
});
test('getPlanWithLoading - устанавливает isLoading в true перед загрузкой', () async {
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
aboutPlanCubit = createCubit();
const testPlan = PlanModel(
id: 1,
@@ -977,5 +992,76 @@ void main() {
await future;
expect(aboutPlanCubit!.state.isLoading, isFalse);
});
test('getActivePublicDiet - возвращает активную публичную диету', () async {
aboutPlanCubit = createCubit();
final publicDiet = ChosenDietModel(
chosenDietID: 1,
chosenPlanId: 0,
state: 'started',
startDate: DateTime(2024, 1, 1),
dietId: 10,
);
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => [publicDiet]);
final result = await aboutPlanCubit!.getActivePublicDiet();
expect(result, isNotNull);
expect(result!.dietId, 10);
expect(result.chosenPlanId, 0);
});
test('getActivePublicDiet - игнорирует диету, привязанную к плану', () async {
aboutPlanCubit = createCubit();
final personalDiet = ChosenDietModel(
chosenDietID: 1,
chosenPlanId: 5,
state: 'started',
startDate: DateTime(2024, 1, 1),
dietId: 10,
);
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => [personalDiet]);
final result = await aboutPlanCubit!.getActivePublicDiet();
expect(result, isNull);
});
test('getActivePublicDiet - возвращает null если нет выбранных диет', () async {
aboutPlanCubit = createCubit();
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => []);
final result = await aboutPlanCubit!.getActivePublicDiet();
expect(result, isNull);
});
test('getActivePublicDiet - возвращает null при ошибке', () async {
aboutPlanCubit = createCubit();
when(mockProfileRepository.getChosenDiets()).thenThrow(Exception('error'));
final result = await aboutPlanCubit!.getActivePublicDiet();
expect(result, isNull);
});
test('cancelPublicDiet - успешно отменяет диету', () async {
aboutPlanCubit = createCubit();
when(mockDietRepository.cancelDiet(10)).thenAnswer((_) async {});
final result = await aboutPlanCubit!.cancelPublicDiet(10);
expect(result, isTrue);
verify(mockDietRepository.cancelDiet(10)).called(1);
});
test('cancelPublicDiet - возвращает false при ошибке', () async {
aboutPlanCubit = createCubit();
when(mockDietRepository.cancelDiet(10)).thenThrow(Exception('error'));
final result = await aboutPlanCubit!.cancelPublicDiet(10);
expect(result, isFalse);
});
});
}

View File

@@ -11,6 +11,8 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/presentation/pages/about_plan_screen.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -301,5 +303,82 @@ void main() {
expect(find.byType(AboutPlanScreen), findsOneWidget);
expect(find.text('Test Plan'), findsOneWidget);
});
testWidgets('order with public diet shows start plan confirmation', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null);
final publicDiet = ChosenDietModel(
chosenDietID: 1,
chosenPlanId: 0,
state: 'started',
startDate: DateTime(2024, 1, 1),
dietId: 10,
);
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(planCubit.getActivePublicDiet()).thenAnswer((_) async => publicDiet);
when(feedCubit.viewPlan(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(planCubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
await tester.pump();
});
await tester.tap(find.text('Заказать'));
await tester.pump();
await tester.pump();
expect(find.byType(ConfirmStartPlanBottomSheet), findsOneWidget);
expect(find.text('Вы уверены, что хотите начать прохождение плана?'), findsOneWidget);
verifyNever(planCubit.chosePlan());
});
testWidgets('order without public diet calls chosePlan', (tester) async {
final planCubit = MockAboutPlanCubit();
final feedCubit = MockFeedCubit();
final commentsCubit = MockCommentsCubit();
const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null);
when(planCubit.state).thenReturn(state);
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(planCubit.getActivePublicDiet()).thenAnswer((_) async => null);
when(planCubit.chosePlan()).thenAnswer((_) async => (null, ''));
when(feedCubit.viewPlan(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(planCubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
await tester.pump();
});
await tester.tap(find.text('Заказать'));
await tester.pump();
await tester.pump();
expect(find.byType(ConfirmStartPlanBottomSheet), findsNothing);
verify(planCubit.chosePlan()).called(1);
});
});
}

View File

@@ -0,0 +1,56 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ConfirmStartPlanBottomSheet', () {
Widget createTestWidget(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: child),
);
}
testWidgets('показывает текст подтверждения начала плана', (tester) async {
await tester.pumpWidget(
createTestWidget(
ConfirmStartPlanBottomSheet(onConfirm: () async {}),
),
);
await tester.pump();
expect(find.text('Вы уверены, что хотите начать прохождение плана?'), findsOneWidget);
expect(find.byType(CustomOutlinedButton), findsOneWidget);
});
testWidgets('по нажатию подтверждения вызывает onConfirm', (tester) async {
var confirmed = false;
await tester.pumpWidget(
createTestWidget(
ConfirmStartPlanBottomSheet(
onConfirm: () async {
confirmed = true;
},
),
),
);
await tester.pump();
await tester.tap(find.byType(CustomOutlinedButton));
await tester.pump();
expect(confirmed, isTrue);
});
});
}

View File

@@ -3,10 +3,12 @@ import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/animated_option_button.dart';
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -18,17 +20,25 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'widgets_test.mocks.dart';
@GenerateNiceMocks([MockSpec<PlanRepository>()])
@GenerateNiceMocks([MockSpec<PlanRepository>(), MockSpec<ProfileRepository>(), MockSpec<DietRepository>()])
void main() {
group('Plans Widgets Tests', () {
late MockPlanRepository mockPlanRepository;
late MockProfileRepository mockProfileRepository;
late MockDietRepository mockDietRepository;
late AboutPlanCubit aboutPlanCubit;
setUp(() async {
await sl.reset();
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
mockPlanRepository = MockPlanRepository();
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
mockProfileRepository = MockProfileRepository();
mockDietRepository = MockDietRepository();
aboutPlanCubit = AboutPlanCubit(
planRepository: mockPlanRepository,
profileRepository: mockProfileRepository,
dietRepository: mockDietRepository,
);
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
});

View File

@@ -100,6 +100,22 @@ void main() {
expect(model.dietId, 5);
});
test('creates ChosenDietModel from JSON with null chosen_plan_id as public diet', () {
final json = {
'id': 1,
'chosen_plan_id': null,
'state': 'started',
'start_date': '2023-01-01T00:00:00Z',
'diet': {'id': 5},
};
final model = ChosenDietModel.fromJson(json);
expect(model.chosenDietID, 1);
expect(model.chosenPlanId, 0);
expect(model.dietId, 5);
});
test('creates ChosenDietModel with null dietId', () {
final model = ChosenDietModel(
chosenDietID: 1,

View File

@@ -0,0 +1,84 @@
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ExpandablePageView', () {
testWidgets(
'sizes itself to a child taller than the old fixed 781 height',
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: ExpandablePageView(
itemCount: 1,
itemBuilder: _tallPlanCard,
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Питание для диабетиков'), findsOneWidget);
expect(find.text('Показать ещё'), findsOneWidget);
final viewSize = tester.getSize(find.byType(ExpandablePageView));
expect(viewSize.height, greaterThan(781));
},
);
testWidgets('does not overflow a vertical Column inside a bounded parent', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
height: 781,
width: 402,
child: SingleChildScrollView(
child: ExpandablePageView(
itemCount: 1,
itemBuilder: _tallPlanCard,
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Показать ещё'), findsOneWidget);
});
});
}
Widget _tallPlanCard(BuildContext context, int index) {
return const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 402, height: 426),
SizedBox(height: 12),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Питание для диабетиков'),
),
SizedBox(height: 12),
SizedBox(width: 370, height: 156),
Padding(
padding: EdgeInsets.only(left: 16, top: 8),
child: Text('Показать ещё'),
),
SizedBox(height: 16),
SizedBox(height: 72),
SizedBox(height: 16),
SizedBox(height: 32),
SizedBox(height: 12),
],
);
}

View File

@@ -257,6 +257,8 @@ void main() {
expect(find.byType(BlogPostMediaContent), findsOneWidget);
expect(find.textContaining('Галерея и видео'), findsWidgets);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.text(post.author.authorName), findsNothing);
});
testWidgets('renders gallery-only media content', (tester) async {

View File

@@ -165,6 +165,11 @@ class FakePlatformNavigationDelegate extends PlatformNavigationDelegate {
// No-op implementation for testing
}
@override
Future<void> setOnProgress(dynamic params) async {
// No-op implementation for testing
}
@override
Future<void> setOnWebResourceError(dynamic params) async {
// No-op implementation for testing