fix: issue-942, issue-967, fixed images positioning for blog posts

This commit is contained in:
misha
2026-08-28 10:05:40 +03:00
parent 8f933118a3
commit 10af279d87
22 changed files with 484 additions and 84 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
class _BlogPostMediaContentState extends State<BlogPostMediaContent> { class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1); 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; static const _horizontalAspectRatio = 16 / 9;
/// Portrait slide aspect (width / height).
static const _verticalAspectRatio = 3 / 4; static const _verticalAspectRatio = 3 / 4;
bool _isCounterVisible = false; bool _isCounterVisible = false;
Timer? _counterHideTimer; Timer? _counterHideTimer;
final Map<String, double> _aspectByUrl = {};
final Map<String, ImageStream> _aspectStreams = {};
final Map<String, ImageStreamListener> _aspectListeners = {};
bool get _hasVideo => bool get _hasVideo =>
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
/// Height is fixed for the whole post from the first gallery image (web-like).
String get _postOrientation { String get _postOrientation {
if (_gallery.isNotEmpty) { if (_gallery.isNotEmpty) {
return _gallery.first.orientation; return _gallery.first.orientation;
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return 'horizontal'; return 'horizontal';
} }
double _heightForAspect(double aspectRatio, double width) {
if (aspectRatio <= 0) {
return width / _horizontalAspectRatio;
}
return width / aspectRatio;
}
double _heightForOrientation(String orientation, double width) { double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical' final aspectRatio = orientation == 'vertical'
? _verticalAspectRatio ? _verticalAspectRatio
: _horizontalAspectRatio; : _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7; return _heightForAspect(aspectRatio, width);
return (width / aspectRatio).clamp(180.0, maxHeight); }
/// 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 @override
void dispose() { void dispose() {
_counterHideTimer?.cancel(); _counterHideTimer?.cancel();
for (final url in _aspectStreams.keys) {
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
}
super.dispose(); super.dispose();
} }
@override @override
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) { void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
_syncAspectListeners();
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
_scheduleCounterHideOnVideo(); _scheduleCounterHideOnVideo();
} }
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return false; 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( return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex), onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
child: CachedNetworkImage( child: _photo(itemUrl, width: width, height: height),
fit: BoxFit.cover,
width: MediaQuery.sizeOf(context).width,
height: height,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
); );
} }
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
viewportFraction: 1, viewportFraction: 1,
height: height, height: height,
enableInfiniteScroll: false, enableInfiniteScroll: false,
disableCenter: true,
padEnds: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index), onPageChanged: (index, _) => widget.onGalleryChanged(index),
), ),
), ),
@@ -200,29 +279,51 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
@override @override
Widget build(BuildContext context) { 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 gallery = _gallery;
final width = MediaQuery.sizeOf(context).width; final mediaHeight = _mediaHeight(width);
final mediaHeight = _heightForOrientation(_postOrientation, width);
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) { if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
final itemCount = _slideCount; final itemCount = _slideCount;
return _carousel( return AnimatedSize(
itemCount: itemCount, duration: const Duration(milliseconds: 200),
height: mediaHeight, curve: Curves.easeInOut,
itemBuilder: (context, itemIndex) { child: _carousel(
if (itemIndex < gallery.length) { itemCount: itemCount,
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight); height: mediaHeight,
} itemBuilder: (context, itemIndex) {
if (_hasVideo) { if (itemIndex < gallery.length) {
return _videoSlide(); return _imageSlide(
} gallery[itemIndex].url,
return const SizedBox.shrink(); itemIndex,
}, width: width,
height: mediaHeight,
);
}
if (_hasVideo) {
return _videoSlide();
}
return const SizedBox.shrink();
},
),
); );
} }
if (gallery.length == 1) { 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) { if (_hasVideo) {
@@ -230,12 +331,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
} }
if (widget.imageUrl.isNotEmpty) { if (widget.imageUrl.isNotEmpty) {
final image = CachedNetworkImage( final image = _photo(widget.imageUrl, width: width);
fit: BoxFit.fitWidth,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
);
return GestureDetector( return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0), onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: widget.heroTag == null child: widget.heroTag == null

View File

@@ -199,6 +199,7 @@ class _BlogPostHeader extends StatelessWidget {
), ),
CustomPopup( CustomPopup(
isAuthor: post.author.authorIsMe, isAuthor: post.author.authorIsMe,
isAuthorAdmin: post.author.isAdmin,
complainedContentType: ComplainedContent.posts, complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}', path: 'blog_post/${post.id}',
id: post.id, id: post.id,

View File

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

View File

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

View File

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

View File

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

View File

@@ -147,6 +147,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [ actions: [
CustomPopup( CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false, isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}', path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan, complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '', userSlug: state.planModel?.author.authorSlug ?? '',
@@ -339,6 +340,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
actions: [ actions: [
CustomPopup( CustomPopup(
isAuthor: state.planModel?.author.authorIsMe ?? false, isAuthor: state.planModel?.author.authorIsMe ?? false,
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
path: 'plan/${widget.slug}', path: 'plan/${widget.slug}',
complainedContentType: ComplainedContent.plan, complainedContentType: ComplainedContent.plan,
userSlug: state.planModel?.author.authorSlug ?? '', userSlug: state.planModel?.author.authorSlug ?? '',

View File

@@ -1105,7 +1105,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
); );
}, },
), ),
if (!state.isMyProfile) ...[ if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[
_buildDivider(), _buildDivider(),
_buildCompactItem( _buildCompactItem(
icon: 'assets/icons/complain.svg', icon: 'assets/icons/complain.svg',

View File

@@ -117,6 +117,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
const Spacer(), const Spacer(),
CustomPopup( CustomPopup(
isAuthor: post.author.authorIsMe, isAuthor: post.author.authorIsMe,
isAuthorAdmin: post.author.isAdmin || state.profile.roles.contains('admin'),
complainedContentType: ComplainedContent.posts, complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}', path: 'blog_post/${post.id}',
id: post.id, id: post.id,

View File

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

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.43+132 version: 1.3.44+133
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -120,6 +120,29 @@ void main() {
expect(find.text('Заблокировать автора'), findsOneWidget); 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 { testWidgets('CustomPopup for author recipe does not show edit action', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
wrap( wrap(

View File

@@ -24,6 +24,7 @@ void main() {
String? slug, String? slug,
int? id, int? id,
bool? disableActions, bool? disableActions,
bool isAuthorAdmin = false,
}) { }) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
@@ -42,6 +43,7 @@ void main() {
slug: slug, slug: slug,
id: id, id: id,
disableActions: disableActions, disableActions: disableActions,
isAuthorAdmin: isAuthorAdmin,
), ),
), ),
); );
@@ -71,7 +73,23 @@ void main() {
); );
await tester.pumpAndSettle(); 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 { testWidgets('disables actions when disableActions is true', (tester) async {

View File

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

View File

@@ -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<FeedGalleryImage> images = gallery,
String imageUrl = '',
}) {
return BlogPostMediaContent(
galleryImages: images,
imageUrl: imageUrl,
videoEmbedUrl: null,
videoUrlThumb: null,
currentGalleryIndex: 0,
onOpenGallery: (_, _) {},
onGalleryChanged: (_) {},
);
}
testWidgets('gallery slides use fitWidth so photos fill the card width', (
tester,
) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(media()));
await tester.pump();
final images = tester.widgetList<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(images, isNotEmpty);
for (final image in images) {
expect(image.fit, BoxFit.fitWidth);
}
});
});
testWidgets('single gallery image uses fitWidth without a forced height', (
tester,
) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(media(images: [gallery.first])));
await tester.pump();
final image = tester.widget<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(image.fit, BoxFit.fitWidth);
expect(image.height, isNull);
});
});
testWidgets('cover-only image keeps fitWidth', (tester) async {
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
media(images: const [], imageUrl: 'https://example.com/cover.jpg'),
),
);
await tester.pump();
final image = tester.widget<CachedNetworkImage>(
find.byType(CachedNetworkImage),
);
expect(image.fit, BoxFit.fitWidth);
});
});
}

View File

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

View File

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