From 10af279d87cf247e557914e2bdea5fcf24eb55c8 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 28 Aug 2026 10:05:40 +0300 Subject: [PATCH 1/4] fix: issue-942, issue-967, fixed images positioning for blog posts --- lib/core/widgets/custom_popup.dart | 33 ++-- .../presentation/pages/blog_post_screen.dart | 1 + .../presentation/pages/about_diet_screen.dart | 1 + .../presentation/widgets/more_options.dart | 75 ++++---- .../feed/models/diets_pagination_model.dart | 5 + .../widgets/blog_post_media_content.dart | 166 ++++++++++++++---- .../widgets/blog_post_widget.dart | 1 + .../presentation/widgets/diets_widget.dart | 1 + .../presentation/widgets/plan_widget.dart | 1 + .../presentation/widgets/recipes_widget.dart | 1 + .../presentation/pages/glossary_screen.dart | 50 +++++- .../presentation/pages/about_plan_screen.dart | 2 + .../presentation/pages/profile_screen.dart | 2 +- .../widgets/profile_blog_post_widget.dart | 1 + .../pages/about_recipe_screen.dart | 1 + pubspec.yaml | 2 +- test/core/widgets/custom_popup_test.dart | 23 +++ .../widgets/more_options_test.dart | 20 ++- .../models/diets_pagination_model_test.dart | 27 +++ .../widgets/blog_post_media_content_test.dart | 88 ++++++++++ .../pages/glossary_screen_test.dart | 62 +++++++ test/helpers/webview_test_helper.dart | 5 + 22 files changed, 484 insertions(+), 84 deletions(-) create mode 100644 test/features/feed/presentation/widgets/blog_post_media_content_test.dart diff --git a/lib/core/widgets/custom_popup.dart b/lib/core/widgets/custom_popup.dart index 51f6f4ff..1ec7184d 100644 --- a/lib/core/widgets/custom_popup.dart +++ b/lib/core/widgets/custom_popup.dart @@ -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', 'Заблокировать автора', diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 915a61fb..0c235eb6 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -198,6 +198,7 @@ class _BlogPostScreenState extends State { 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, diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 5498be61..1eb81593 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -296,6 +296,7 @@ class _AboutDietScreenState extends State { actions: [ CustomPopup( isAuthor: dietState.diet.author.authorIsMe, + isAuthorAdmin: dietState.diet.author.isAdmin, path: 'diet/${dietState.diet.slug}', complainedContentType: ComplainedContent.diet, id: dietState.diet.id, diff --git a/lib/features/diets/presentation/widgets/more_options.dart b/lib/features/diets/presentation/widgets/more_options.dart index 36ca0eba..cb3e33b2 100644 --- a/lib/features/diets/presentation/widgets/more_options.dart +++ b/lib/features/diets/presentation/widgets/more_options.dart @@ -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), + ], + ), ), - ), + ], ], ), ), diff --git a/lib/features/feed/models/diets_pagination_model.dart b/lib/features/feed/models/diets_pagination_model.dart index aec36295..dfc927b9 100644 --- a/lib/features/feed/models/diets_pagination_model.dart +++ b/lib/features/feed/models/diets_pagination_model.dart @@ -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 authorRoles, }) = _Author; factory Author.fromJson(Map json) => _$AuthorFromJson(json); + + bool get isAdmin => authorRoles.contains('admin'); } @freezed diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index e0e87f31..e19ca883 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget { class _BlogPostMediaContentState extends State { 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 _aspectByUrl = {}; + final Map _aspectStreams = {}; + final Map _aspectListeners = {}; + bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; @@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State { 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 { 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 { 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 { viewportFraction: 1, height: height, enableInfiniteScroll: false, + disableCenter: true, + padEnds: false, onPageChanged: (index, _) => widget.onGalleryChanged(index), ), ), @@ -200,29 +279,51 @@ class _BlogPostMediaContentState extends State { @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) { @@ -230,12 +331,7 @@ class _BlogPostMediaContentState extends State { } if (widget.imageUrl.isNotEmpty) { - final image = CachedNetworkImage( - fit: BoxFit.fitWidth, - width: width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ); + final image = _photo(widget.imageUrl, width: width); return GestureDetector( onTap: () => widget.onOpenGallery([widget.imageUrl], 0), child: widget.heroTag == null diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index ac467501..631cffb5 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -199,6 +199,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, diff --git a/lib/features/feed/presentation/widgets/diets_widget.dart b/lib/features/feed/presentation/widgets/diets_widget.dart index 6de38564..93268f27 100644 --- a/lib/features/feed/presentation/widgets/diets_widget.dart +++ b/lib/features/feed/presentation/widgets/diets_widget.dart @@ -80,6 +80,7 @@ class _DietsWidgetState extends State { 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, diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 96e2acff..5725a418 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -85,6 +85,7 @@ class _PlanWidgetState extends State { 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, diff --git a/lib/features/feed/presentation/widgets/recipes_widget.dart b/lib/features/feed/presentation/widgets/recipes_widget.dart index 6d9e800a..01438f7d 100644 --- a/lib/features/feed/presentation/widgets/recipes_widget.dart +++ b/lib/features/feed/presentation/widgets/recipes_widget.dart @@ -115,6 +115,7 @@ class _RecipesWidgetState extends State { 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, diff --git a/lib/features/glossary/presentation/pages/glossary_screen.dart b/lib/features/glossary/presentation/pages/glossary_screen.dart index a1315953..5dd73cfd 100644 --- a/lib/features/glossary/presentation/pages/glossary_screen.dart +++ b/lib/features/glossary/presentation/pages/glossary_screen.dart @@ -1,8 +1,21 @@ 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/foundation.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 +25,8 @@ class GlossaryScreen extends StatefulWidget { class _GlossaryScreenState extends State { late final WebViewController _controller; + bool _isLoading = true; + String _currentUrl = ''; @override void initState() { @@ -19,14 +34,47 @@ class _GlossaryScreenState extends State { _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().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()), + ), + ], + ), ); } } diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 2dcbd91b..4d49f62f 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -147,6 +147,7 @@ class _AboutPlanScreenState extends State { 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 ?? '', @@ -339,6 +340,7 @@ class _AboutPlanScreenState extends State { 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 ?? '', diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index c7dae0f5..b3081735 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -1105,7 +1105,7 @@ class _ProfileScreenState extends State { ); }, ), - if (!state.isMyProfile) ...[ + if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[ _buildDivider(), _buildCompactItem( icon: 'assets/icons/complain.svg', diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index af70c9df..87706a63 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -117,6 +117,7 @@ class _ProfileBlogPostWidgetState extends State { 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, diff --git a/lib/features/recipe/presentation/pages/about_recipe_screen.dart b/lib/features/recipe/presentation/pages/about_recipe_screen.dart index ff9e4f0a..61148644 100644 --- a/lib/features/recipe/presentation/pages/about_recipe_screen.dart +++ b/lib/features/recipe/presentation/pages/about_recipe_screen.dart @@ -223,6 +223,7 @@ class _AboutRecipeScreenState extends State { actions: [ CustomPopup( isAuthor: recipeState.recipe.author.authorIsMe, + isAuthorAdmin: recipeState.recipe.author.isAdmin, path: 'recipe/${widget.recipeSlug}', complainedContentType: ComplainedContent.recipe, slug: widget.recipeSlug, diff --git a/pubspec.yaml b/pubspec.yaml index 21e2b73f..bb2a3ad7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.43+132 +version: 1.3.44+133 publish_to: 'none' environment: diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 21e5b020..0f7b3553 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -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( diff --git a/test/features/diets/presentation/widgets/more_options_test.dart b/test/features/diets/presentation/widgets/more_options_test.dart index 8b3faaf6..01a608f5 100644 --- a/test/features/diets/presentation/widgets/more_options_test.dart +++ b/test/features/diets/presentation/widgets/more_options_test.dart @@ -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 { diff --git a/test/features/feed/models/diets_pagination_model_test.dart b/test/features/feed/models/diets_pagination_model_test.dart index 6b1b5cd2..a5c292ad 100644 --- a/test/features/feed/models/diets_pagination_model_test.dart +++ b/test/features/feed/models/diets_pagination_model_test.dart @@ -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'); diff --git a/test/features/feed/presentation/widgets/blog_post_media_content_test.dart b/test/features/feed/presentation/widgets/blog_post_media_content_test.dart new file mode 100644 index 00000000..8a4c570f --- /dev/null +++ b/test/features/feed/presentation/widgets/blog_post_media_content_test.dart @@ -0,0 +1,88 @@ +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 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( + 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( + 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( + find.byType(CachedNetworkImage), + ); + expect(image.fit, BoxFit.fitWidth); + }); + }); +} diff --git a/test/features/glossary/presentation/pages/glossary_screen_test.dart b/test/features/glossary/presentation/pages/glossary_screen_test.dart index bb9dd4e7..a6e33666 100644 --- a/test/features/glossary/presentation/pages/glossary_screen_test.dart +++ b/test/features/glossary/presentation/pages/glossary_screen_test.dart @@ -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, + ); + }); }); } diff --git a/test/helpers/webview_test_helper.dart b/test/helpers/webview_test_helper.dart index 0630eea8..ef55f6c2 100644 --- a/test/helpers/webview_test_helper.dart +++ b/test/helpers/webview_test_helper.dart @@ -165,6 +165,11 @@ class FakePlatformNavigationDelegate extends PlatformNavigationDelegate { // No-op implementation for testing } + @override + Future setOnProgress(dynamic params) async { + // No-op implementation for testing + } + @override Future setOnWebResourceError(dynamic params) async { // No-op implementation for testing From c6ea1d414dff2ac4b06fcae38ff174170e5911bb Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 2 Sep 2026 09:32:14 +0300 Subject: [PATCH 2/4] fix: issue-372, issue-227, issue-406 --- lib/core/di/cubit_registrations.dart | 2 +- lib/core/utils/inline_svg_css.dart | 61 +++ lib/core/widgets/network_svg_picture.dart | 57 +++ .../pages/blog_post_create_screen.dart | 1 - .../feed/models/post_feed_mapper.dart | 39 +- .../widgets/blog_post_media_content.dart | 2 +- .../widgets/blog_post_widget.dart | 53 +-- .../widgets/consultant_widget.dart | 3 +- .../widgets/notification_widget.dart | 2 +- .../presentation/info_by_order_screen.dart | 20 +- .../presentation/cubit/about_plan_cubit.dart | 43 +- .../presentation/pages/about_plan_screen.dart | 62 ++- .../confirm_start_plan_bottom_sheet.dart | 50 +++ .../models/chosen_diets_pagination_model.dart | 8 +- .../presentation/pages/profile_screen.dart | 31 +- .../widgets/expandable_page_view.dart | 115 ++++++ .../widgets/profile_blog_post_widget.dart | 92 ++--- pubspec.yaml | 2 +- test/core/utils/inline_svg_css_test.dart | 36 ++ .../pages/blog_post_create_screen_test.dart | 9 + .../widgets/chat_misc_widgets_smoke_test.dart | 87 +++- .../consultation_card_widget_test.dart | 84 +++- .../feed/models/post_feed_mapper_test.dart | 9 + .../widgets/blog_post_media_content_test.dart | 23 ++ .../widgets/notification_widget_test.dart | 373 +++++++++++------- .../info_by_order_screen_test.dart | 209 ++++++++-- .../widgets/collect_charge_banner_test.dart | 142 ++++--- .../cubit/about_plan_cubit_test.dart | 144 +++++-- .../pages/about_plan_screen_smoke_test.dart | 79 ++++ .../confirm_start_plan_bottom_sheet_test.dart | 56 +++ .../presentation/widgets/widgets_test.dart | 14 +- .../chosen_diets_pagination_model_test.dart | 16 + .../widgets/expandable_page_view_test.dart | 84 ++++ .../profile_blog_post_widget_smoke_test.dart | 2 + 34 files changed, 1623 insertions(+), 387 deletions(-) create mode 100644 lib/core/utils/inline_svg_css.dart create mode 100644 lib/core/widgets/network_svg_picture.dart create mode 100644 lib/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart create mode 100644 lib/features/profile/presentation/widgets/expandable_page_view.dart create mode 100644 test/core/utils/inline_svg_css_test.dart create mode 100644 test/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet_test.dart create mode 100644 test/features/profile/presentation/widgets/expandable_page_view_test.dart diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index d3886779..e58aa77c 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -122,7 +122,7 @@ class CubitRegistrations { // Content viewing cubits sl.registerFactory(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl())); - sl.registerFactory(() => AboutPlanCubit(planRepository: sl())); + sl.registerFactory(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl())); sl.registerFactory(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl())); sl.registerFactory(() => BlogPostCubit(sl())); diff --git a/lib/core/utils/inline_svg_css.dart b/lib/core/utils/inline_svg_css.dart new file mode 100644 index 00000000..aaccac36 --- /dev/null +++ b/lib/core/utils/inline_svg_css.dart @@ -0,0 +1,61 @@ +/// flutter_svg / vector_graphics ignore `', + caseSensitive: false, + ); + + final classProps = >{}; + 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, () => {}); + 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 = {}; + 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; +} diff --git a/lib/core/widgets/network_svg_picture.dart b/lib/core/widgets/network_svg_picture.dart new file mode 100644 index 00000000..fecd2748 --- /dev/null +++ b/lib/core/widgets/network_svg_picture.dart @@ -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 _inlinedSvgCache = {}; + +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 createState() => _NetworkSvgPictureState(); +} + +class _NetworkSvgPictureState extends State { + late final Future _svgFuture = _load(widget.url); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _svgFuture, + builder: (BuildContext context, AsyncSnapshot 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 _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; + } +} diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index 432117dd..d06fc227 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -763,7 +763,6 @@ class _BlogPostCreateScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), const Text('Заголовок'), const SizedBox(height: 8), CustomTextFormField( diff --git a/lib/features/feed/models/post_feed_mapper.dart b/lib/features/feed/models/post_feed_mapper.dart index f88e01ed..8b003de0 100644 --- a/lib/features/feed/models/post_feed_mapper.dart +++ b/lib/features/feed/models/post_feed_mapper.dart @@ -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().baseUrl}$url'; + if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) { + return ''; + } + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + if (url.startsWith('/')) { + return '${sl().baseUrl}$url'; + } return '${sl().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 images, + String? videoEmbedUrl, + }) { + if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) { + return true; + } + if (hasRenderableImage(image)) { + return true; + } + return images.any((image) => hasRenderableImage(image.url)); + } } diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index e19ca883..03dd10a4 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -330,7 +330,7 @@ class _BlogPostMediaContentState extends State { return SizedBox(height: mediaHeight, width: width, child: _videoSlide()); } - if (widget.imageUrl.isNotEmpty) { + if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) { final image = _photo(widget.imageUrl, width: width); return GestureDetector( onTap: () => widget.onOpenGallery([widget.imageUrl], 0), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 631cffb5..98de8abc 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State { 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 { 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, @@ -213,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) { @@ -259,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), ], ); diff --git a/lib/features/feed/presentation/widgets/consultant_widget.dart b/lib/features/feed/presentation/widgets/consultant_widget.dart index 4f0709b0..cec6d24a 100644 --- a/lib/features/feed/presentation/widgets/consultant_widget.dart +++ b/lib/features/feed/presentation/widgets/consultant_widget.dart @@ -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().baseUrl}/storage/${badges[i].image}', width: 28, height: 28) + ? NetworkSvgPicture(url: '${sl().baseUrl}/storage/${badges[i].image}', width: 28, height: 28) : CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${badges[i].image}', width: 28, height: 28), i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(), ], diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index a8b2ab4e..26714425 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -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); }, ), ], diff --git a/lib/features/order_info/presentation/info_by_order_screen.dart b/lib/features/order_info/presentation/info_by_order_screen.dart index 15ad28dd..c37913f5 100644 --- a/lib/features/order_info/presentation/info_by_order_screen.dart +++ b/lib/features/order_info/presentation/info_by_order_screen.dart @@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => sl()..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 createState() => _InfoByOrderScreenContentState(); } @@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State { 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( diff --git a/lib/features/plans/presentation/cubit/about_plan_cubit.dart b/lib/features/plans/presentation/cubit/about_plan_cubit.dart index 6d43871d..2c5b6b72 100644 --- a/lib/features/plans/presentation/cubit/about_plan_cubit.dart +++ b/lib/features/plans/presentation/cubit/about_plan_cubit.dart @@ -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 { 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 getPlan({required String slug, int? index}) async { try { @@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit { return (null, ''); } + /// Активная публичная диета (без привязки к плану) в статусах inited/started. + Future 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 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', diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 4d49f62f..d10561ef 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -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 { scrollController.jumpTo(scrollController.position.maxScrollExtent); } + Future _onOrderPressed(BuildContext context) async { + final cubit = context.read(); + 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 _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); @@ -183,20 +230,7 @@ class _AboutPlanScreenState extends State { focusElevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), backgroundColor: appTheme.greenA700, - onPressed: () async { - final (chosenPlan, error) = await context.read().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)), ), ), diff --git a/lib/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart b/lib/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart new file mode 100644 index 00000000..f7ae5a64 --- /dev/null +++ b/lib/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart @@ -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 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/models/chosen_diets_pagination_model.dart b/lib/features/profile/models/chosen_diets_pagination_model.dart index 323ed660..5fed1d53 100644 --- a/lib/features/profile/models/chosen_diets_pagination_model.dart +++ b/lib/features/profile/models/chosen_diets_pagination_model.dart @@ -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; +} diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index b3081735..5875bd17 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -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 { ), ), 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 { borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)], ), - child: SvgPicture.network('${sl().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40), + child: NetworkSvgPicture( + url: '${sl().baseUrl}/storage/${state.profile.badges[i].image}', + width: 40, + height: 40, + ), ) : Tooltip( preferBelow: true, diff --git a/lib/features/profile/presentation/widgets/expandable_page_view.dart b/lib/features/profile/presentation/widgets/expandable_page_view.dart new file mode 100644 index 00000000..99c9ef90 --- /dev/null +++ b/lib/features/profile/presentation/widgets/expandable_page_view.dart @@ -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? onPageChanged; + + @override + State createState() => _ExpandablePageViewState(); +} + +class _ExpandablePageViewState extends State { + late List _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.filled(widget.itemCount, 0, growable: true); + } + + @override + void didUpdateWidget(covariant ExpandablePageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.itemCount != oldWidget.itemCount) { + _heights = List.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 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); + } + } +} diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index 87706a63..a6d3fa3a 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State { 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,40 +84,12 @@ class _ProfileBlogPostWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - CircleAvatar( - radius: 20, - backgroundColor: Colors.black12, - foregroundImage: NetworkImage( - post.author.authorProfileIcon.contains('https') - ? post.author.authorProfileIcon - : sl().baseUrl + - post.author.authorProfileIcon, + 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( @@ -167,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State { ), ), ), - 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( diff --git a/pubspec.yaml b/pubspec.yaml index bb2a3ad7..7daeeb8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.44+133 +version: 1.3.45+134 publish_to: 'none' environment: diff --git a/test/core/utils/inline_svg_css_test.dart b/test/core/utils/inline_svg_css_test.dart new file mode 100644 index 00000000..1b82e158 --- /dev/null +++ b/test/core/utils/inline_svg_css_test.dart @@ -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 = ''' + + + + + +'''; + + final result = inlineSvgCss(svg); + + expect(result, isNot(contains(''; + + expect(inlineSvgCss(svg), svg); + }); + }); +} diff --git a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart index f429bf80..1983335a 100644 --- a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart +++ b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart @@ -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 { diff --git a/test/features/chat/presentation/widgets/chat_misc_widgets_smoke_test.dart b/test/features/chat/presentation/widgets/chat_misc_widgets_smoke_test.dart index 98b71536..4b6a29b4 100644 --- a/test/features/chat/presentation/widgets/chat_misc_widgets_smoke_test.dart +++ b/test/features/chat/presentation/widgets/chat_misc_widgets_smoke_test.dart @@ -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); + }); }); } diff --git a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart index d6d42689..c897d8e2 100644 --- a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart @@ -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(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + 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); + }); + }, + ); }); } diff --git a/test/features/feed/models/post_feed_mapper_test.dart b/test/features/feed/models/post_feed_mapper_test.dart index 2873aa0c..64326bb8 100644 --- a/test/features/feed/models/post_feed_mapper_test.dart +++ b/test/features/feed/models/post_feed_mapper_test.dart @@ -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, + ); + }); }); } diff --git a/test/features/feed/presentation/widgets/blog_post_media_content_test.dart b/test/features/feed/presentation/widgets/blog_post_media_content_test.dart index 8a4c570f..17a19a8e 100644 --- a/test/features/feed/presentation/widgets/blog_post_media_content_test.dart +++ b/test/features/feed/presentation/widgets/blog_post_media_content_test.dart @@ -85,4 +85,27 @@ void main() { 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); + }); } diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index d8f56164..2127155b 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -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() { }); }); } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/features/order_info/presentation/info_by_order_screen_test.dart b/test/features/order_info/presentation/info_by_order_screen_test.dart index bb8b9ac6..ea426152 100644 --- a/test/features/order_info/presentation/info_by_order_screen_test.dart +++ b/test/features/order_info/presentation/info_by_order_screen_test.dart @@ -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(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + 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); }); @@ -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); + }); }); } diff --git a/test/features/order_info/presentation/widgets/collect_charge_banner_test.dart b/test/features/order_info/presentation/widgets/collect_charge_banner_test.dart index 046e4c41..28610415 100644 --- a/test/features/order_info/presentation/widgets/collect_charge_banner_test.dart +++ b/test/features/order_info/presentation/widgets/collect_charge_banner_test.dart @@ -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(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); + sl.registerLazySingleton( + () => + 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); }); }); } - diff --git a/test/features/plans/presentation/cubit/about_plan_cubit_test.dart b/test/features/plans/presentation/cubit/about_plan_cubit_test.dart index ccc1b39a..7dff7d42 100644 --- a/test/features/plans/presentation/cubit/about_plan_cubit_test.dart +++ b/test/features/plans/presentation/cubit/about_plan_cubit_test.dart @@ -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()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) 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()); 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); + }); }); } diff --git a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart index 040271d1..b47e1d2b 100644 --- a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart +++ b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart @@ -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.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(planCubit.getActivePublicDiet()).thenAnswer((_) async => publicDiet); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.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.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(planCubit.getActivePublicDiet()).thenAnswer((_) async => null); + when(planCubit.chosePlan()).thenAnswer((_) async => (null, '')); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.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); + }); }); } diff --git a/test/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet_test.dart b/test/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet_test.dart new file mode 100644 index 00000000..44e358c1 --- /dev/null +++ b/test/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet_test.dart @@ -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); + }); + }); +} diff --git a/test/features/plans/presentation/widgets/widgets_test.dart b/test/features/plans/presentation/widgets/widgets_test.dart index 2c6de953..aaecf30a 100644 --- a/test/features/plans/presentation/widgets/widgets_test.dart +++ b/test/features/plans/presentation/widgets/widgets_test.dart @@ -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()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) 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(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); }); diff --git a/test/features/profile/models/chosen_diets_pagination_model_test.dart b/test/features/profile/models/chosen_diets_pagination_model_test.dart index 2e8de153..383876a9 100644 --- a/test/features/profile/models/chosen_diets_pagination_model_test.dart +++ b/test/features/profile/models/chosen_diets_pagination_model_test.dart @@ -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, diff --git a/test/features/profile/presentation/widgets/expandable_page_view_test.dart b/test/features/profile/presentation/widgets/expandable_page_view_test.dart new file mode 100644 index 00000000..057e1de4 --- /dev/null +++ b/test/features/profile/presentation/widgets/expandable_page_view_test.dart @@ -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), + ], + ); +} diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 4102ca44..e1a5411f 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -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 { From 745e77f94802cb20f08eb0bb3126e841f7cd3a8b Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 2 Sep 2026 09:34:25 +0300 Subject: [PATCH 3/4] fix: analyer --- lib/features/glossary/presentation/pages/glossary_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/glossary/presentation/pages/glossary_screen.dart b/lib/features/glossary/presentation/pages/glossary_screen.dart index 5dd73cfd..3df941cb 100644 --- a/lib/features/glossary/presentation/pages/glossary_screen.dart +++ b/lib/features/glossary/presentation/pages/glossary_screen.dart @@ -1,7 +1,6 @@ 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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; From b040c6e020c68ffb21f5e03c73f53e92bb9862eb Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 2 Sep 2026 15:42:17 +0300 Subject: [PATCH 4/4] fix: tests fix --- pubspec.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index 7daeeb8e..cf78db1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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