forked from eatvio/eatvio_app
fix: issue-372, issue-227, issue-406
This commit is contained in:
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
|
||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||
String inlineSvgCss(String svg) {
|
||||
final styleBlockPattern = RegExp(
|
||||
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final classProps = <String, Map<String, String>>{};
|
||||
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||
final css = match.group(1) ?? '';
|
||||
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||
for (final rule in rulePattern.allMatches(css)) {
|
||||
final className = rule.group(1)!;
|
||||
final body = rule.group(2)!;
|
||||
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||
for (final declaration in body.split(';')) {
|
||||
final colon = declaration.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = declaration.substring(0, colon).trim();
|
||||
final value = declaration.substring(colon + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = svg.replaceAll(styleBlockPattern, '');
|
||||
if (classProps.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||
Match match,
|
||||
) {
|
||||
final classes = match
|
||||
.group(2)!
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((name) => name.isNotEmpty);
|
||||
final attrs = <String, String>{};
|
||||
for (final className in classes) {
|
||||
final props = classProps[className];
|
||||
if (props != null) {
|
||||
attrs.addAll(props);
|
||||
}
|
||||
}
|
||||
if (attrs.isEmpty) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final attrString = attrs.entries
|
||||
.map((entry) => '${entry.key}="${entry.value}"')
|
||||
.join(' ');
|
||||
return ' $attrString';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||
|
||||
class NetworkSvgPicture extends StatefulWidget {
|
||||
const NetworkSvgPicture({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||
}
|
||||
|
||||
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||
late final Future<String> _svgFuture = _load(widget.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _svgFuture,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(width: widget.width, height: widget.height);
|
||||
}
|
||||
return SvgPicture.string(
|
||||
snapshot.data!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _load(String url) async {
|
||||
final cached = _inlinedSvgCache[url];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to load SVG from $url');
|
||||
}
|
||||
final processed = inlineSvgCss(response.body);
|
||||
_inlinedSvgCache[url] = processed;
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -763,7 +763,6 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
|
||||
@@ -26,16 +26,47 @@ class PostFeedMapper {
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
|
||||
return '';
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${sl<AppConfig>().baseUrl}$url';
|
||||
}
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
|
||||
static bool isPlaceholderMediaUrl(String url) {
|
||||
return url.toLowerCase().contains('element-image-placeholder');
|
||||
}
|
||||
|
||||
static bool hasRenderableImage(String? url) {
|
||||
if (url == null || url.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !isPlaceholderMediaUrl(url);
|
||||
}
|
||||
|
||||
static bool hasVisibleMedia({
|
||||
required String image,
|
||||
required List<FeedGalleryImage> images,
|
||||
String? videoEmbedUrl,
|
||||
}) {
|
||||
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (hasRenderableImage(image)) {
|
||||
return true;
|
||||
}
|
||||
return images.any((image) => hasRenderableImage(image.url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
|
||||
final image = _photo(widget.imageUrl, width: width);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
|
||||
@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'feed-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
_BlogPostActionsRow(
|
||||
post: post,
|
||||
isLiked: isLiked,
|
||||
@@ -213,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool includeBottomSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -259,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -92,7 +93,7 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
? NetworkSvgPicture(url: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
|
||||
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
||||
],
|
||||
|
||||
@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
|
||||
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
|
||||
child: InfoByOrderScreenContent(
|
||||
successfulPayment: successfulPayment,
|
||||
planId: planId,
|
||||
customerSlug: customerSlug,
|
||||
isConsultantView: isConsultantView,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfoByOrderScreenContent extends StatefulWidget {
|
||||
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
|
||||
const InfoByOrderScreenContent({
|
||||
super.key,
|
||||
required this.planId,
|
||||
required this.successfulPayment,
|
||||
this.customerSlug,
|
||||
this.isConsultantView = false,
|
||||
});
|
||||
final int planId;
|
||||
final bool? successfulPayment;
|
||||
final String? customerSlug;
|
||||
final bool isConsultantView;
|
||||
@override
|
||||
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
||||
}
|
||||
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
|
||||
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
|
||||
],
|
||||
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
|
||||
const SizedBox(height: 8),
|
||||
AuthorCard(
|
||||
|
||||
@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
final PlanRepository planRepository;
|
||||
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
|
||||
final ProfileRepository profileRepository;
|
||||
final DietRepository dietRepository;
|
||||
|
||||
AboutPlanCubit({
|
||||
required this.planRepository,
|
||||
required this.profileRepository,
|
||||
required this.dietRepository,
|
||||
}) : super(AboutPlanState.initial());
|
||||
|
||||
Future<void> getPlan({required String slug, int? index}) async {
|
||||
try {
|
||||
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
return (null, '');
|
||||
}
|
||||
|
||||
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
|
||||
Future<ChosenDietModel?> getActivePublicDiet() async {
|
||||
try {
|
||||
final chosenDiets = await profileRepository.getChosenDiets();
|
||||
final lastChosenDiet = chosenDiets.firstOrNull;
|
||||
if (lastChosenDiet == null) {
|
||||
return null;
|
||||
}
|
||||
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
|
||||
final isPublic = lastChosenDiet.chosenPlanId == 0;
|
||||
if (isActive && isPublic && lastChosenDiet.dietId != null) {
|
||||
return lastChosenDiet;
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> cancelPublicDiet(int dietId) async {
|
||||
try {
|
||||
await dietRepository.cancelDiet(dietId);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _getOptionNameFromIndex(int index) => switch (index) {
|
||||
1 => 'options2',
|
||||
2 => 'options3',
|
||||
|
||||
@@ -17,8 +17,10 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
|
||||
Future<void> _onOrderPressed(BuildContext context) async {
|
||||
final cubit = context.read<AboutPlanCubit>();
|
||||
final publicDiet = await cubit.getActivePublicDiet();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (publicDiet != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => ConfirmStartPlanBottomSheet(
|
||||
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _submitOrder(context, cubit);
|
||||
}
|
||||
|
||||
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
|
||||
final s = S.of(context);
|
||||
if (publicDietId != null) {
|
||||
final cancelled = await cubit.cancelPublicDiet(publicDietId);
|
||||
if (!cancelled) {
|
||||
showErrorToast('Не удалось отменить диету');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final (chosenPlan, error) = await cubit.chosePlan();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -183,20 +230,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: appTheme.greenA700,
|
||||
onPressed: () async {
|
||||
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
},
|
||||
onPressed: () => _onOrderPressed(context),
|
||||
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfirmStartPlanBottomSheet extends StatelessWidget {
|
||||
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
|
||||
|
||||
final Future<void> Function() onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
'Вы уверены, что хотите начать прохождение плана?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).maybePop();
|
||||
await onConfirm();
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
|
||||
abstract class ChosenDietModel with _$ChosenDietModel {
|
||||
factory ChosenDietModel({
|
||||
@JsonKey(name: 'id') required int chosenDietID,
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
|
||||
@JsonKey() required String state,
|
||||
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
||||
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
||||
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return json['id'] as int?;
|
||||
}
|
||||
|
||||
int _parseChosenPlanId(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is num) return json.toInt();
|
||||
return int.tryParse(json.toString()) ?? 0;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||
@@ -382,19 +384,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 781,
|
||||
child: PageView.builder(
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PlanWidget(plan: state.plansList[index], index: index);
|
||||
},
|
||||
onPageChanged: (bullet) {
|
||||
setState(() {
|
||||
_currentBullet = bullet;
|
||||
});
|
||||
},
|
||||
itemCount: state.plansList.length,
|
||||
),
|
||||
ExpandablePageView(
|
||||
itemCount: state.plansList.length,
|
||||
onPageChanged: (bullet) {
|
||||
setState(() {
|
||||
_currentBullet = bullet;
|
||||
});
|
||||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PlanWidget(plan: state.plansList[index], index: index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_bullets(_currentBullet, state.plansList.length),
|
||||
@@ -779,7 +778,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)],
|
||||
),
|
||||
child: SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40),
|
||||
child: NetworkSvgPicture(
|
||||
url: '${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
)
|
||||
: Tooltip(
|
||||
preferBelow: true,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpandablePageView extends StatefulWidget {
|
||||
const ExpandablePageView({
|
||||
super.key,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
final int itemCount;
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
@override
|
||||
State<ExpandablePageView> createState() => _ExpandablePageViewState();
|
||||
}
|
||||
|
||||
class _ExpandablePageViewState extends State<ExpandablePageView> {
|
||||
late List<double> _heights;
|
||||
int _currentPage = 0;
|
||||
|
||||
double get _currentHeight {
|
||||
if (_heights.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final page = _currentPage.clamp(0, _heights.length - 1);
|
||||
return _heights[page];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.itemCount != oldWidget.itemCount) {
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
if (widget.itemCount == 0) {
|
||||
_currentPage = 0;
|
||||
} else {
|
||||
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: _currentHeight,
|
||||
child: PageView.builder(
|
||||
itemCount: widget.itemCount,
|
||||
onPageChanged: (int index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
widget.onPageChanged?.call(index);
|
||||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: double.infinity,
|
||||
child: _SizeReportingWidget(
|
||||
onSizeChange: (Size size) {
|
||||
if (index >= _heights.length ||
|
||||
_heights[index] == size.height) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_heights[index] = size.height;
|
||||
});
|
||||
},
|
||||
child: widget.itemBuilder(context, index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SizeReportingWidget extends StatefulWidget {
|
||||
const _SizeReportingWidget({required this.child, required this.onSizeChange});
|
||||
|
||||
final Widget child;
|
||||
final ValueChanged<Size> onSizeChange;
|
||||
|
||||
@override
|
||||
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
|
||||
}
|
||||
|
||||
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
|
||||
Size? _oldSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
void _notifySize() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final size = context.size;
|
||||
if (size != null && _oldSize != size) {
|
||||
_oldSize = size;
|
||||
widget.onSizeChange(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
totalLikes = post.totalLikes;
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -79,40 +84,12 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
post.author.authorProfileIcon.contains('https')
|
||||
? post.author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl +
|
||||
post.author.authorProfileIcon,
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
@@ -167,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
|
||||
Reference in New Issue
Block a user