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