forked from eatvio/eatvio_app
fix: issue-942, issue-967, fixed images positioning for blog posts
This commit is contained in:
@@ -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',
|
||||||
'Заблокировать автора',
|
'Заблокировать автора',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user