diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 31ef8af3..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 038474ca..7c16bf74 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -71,16 +71,16 @@ class OtherRoutes { try { final profileRepository = sl(); final profile = await profileRepository.getMyProfile(); - + final firebaseApi = sl(); - await firebaseApi.registerPushToken(); + await firebaseApi.registerPushToken(); if (profile.emailVerifiedAt != null || (prefs.getBool(PrefsKeys.isEmailVerificationSkipped) ?? false)) { return RouteConstants.feedScreen; } return RouteConstants.verifyEmailScreen; } catch (e) { - return '/auth-screen'; + return '/auth-screen'; } } else { return '/auth-screen'; @@ -111,12 +111,12 @@ class OtherRoutes { // При любой ошибке пропускаем проверку версии return null; }); - + if (versions == null) { // Если проверка не удалась, пропускаем экран обновления return RouteConstants.welcomeScreen; } - + PackageInfo packageInfo = await PackageInfo.fromPlatform(); String version = packageInfo.version; Version currentVersion = Version.parse(version); @@ -159,16 +159,20 @@ class OtherRoutes { path: CreateDietScreen.path, name: CreateDietScreen.name, builder: (context, state) { - final diet = state.extra as Diet?; - return CreateDietScreen(diet: diet); + final data = state.extra as Map?; + final diet = data?['diet'] as Diet?; + final isEditing = data?['isEditing'] as bool?; + return CreateDietScreen(diet: diet, isEditing: isEditing); }, ), GoRoute( path: DietPreviewScreen.path, name: DietPreviewScreen.name, builder: (context, state) { - final data = state.extra as Diet; - return DietPreviewScreen(diet: data); + final data = state.extra as Map; + final diet = data['diet'] as Diet; + final isImageInBase64 = data['isImageInBase64'] as bool; + return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64); }, ), GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()), diff --git a/lib/core/widgets/info_row.dart b/lib/core/widgets/info_row.dart index 115dedc0..fbf0b8bf 100644 --- a/lib/core/widgets/info_row.dart +++ b/lib/core/widgets/info_row.dart @@ -12,15 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class InfoRow extends StatelessWidget { - const InfoRow({ - required this.type, - super.key, - this.diet, - this.itemsBackgroundColor, - this.disableDescriptionOfTypes = false, - this.plan, - this.recipe, - }); + const InfoRow({required this.type, super.key, this.diet, this.itemsBackgroundColor, this.disableDescriptionOfTypes = false, this.plan, this.recipe}); final ProductType type; final Color? itemsBackgroundColor; final Diet? diet; @@ -50,44 +42,13 @@ class InfoRow extends StatelessWidget { InfoBouble( backgroundColor: itemsBackgroundColor, children: [ - SvgPicture.asset( - 'assets/icons/time2.svg', - width: 13.3, - height: 13.3, - ), + SvgPicture.asset('assets/icons/time2.svg', width: 13.3, height: 13.3), const SizedBox(width: 4), - Text( - switch (type) { - ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}', - ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}', - ProductType.recipe => timeTitle ?? '', - }, - style: TextStyles.textXSSemibold.copyWith(color: appTheme.black), - ), - ], - ), - const SizedBox(width: 12), - InfoBouble( - backgroundColor: itemsBackgroundColor, - children: [ - SvgPicture.asset( - switch (type) { - ProductType.diet => 'assets/icons/people.svg', - ProductType.plan => 'assets/icons/people.svg', - ProductType.recipe => 'assets/icons/calories.svg', - }, - width: 13.3, - height: 13.3, - ), - const SizedBox(width: 4), - Text( - switch (type) { - ProductType.diet => '80 человек прошли', - ProductType.plan => '80 человек прошли', - ProductType.recipe => '${recipe?.calories.toStringAsFixed(0) ?? '0'} ккал', - }, - style: TextStyles.textXSSemibold.copyWith(color: appTheme.black), - ), + Text(switch (type) { + ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}', + ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}', + ProductType.recipe => timeTitle ?? '', + }, style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), ], ), const SizedBox(width: 12), @@ -95,11 +56,17 @@ class InfoRow extends StatelessWidget { InfoBouble( backgroundColor: itemsBackgroundColor, children: [ - SvgPicture.asset( - 'assets/icons/steps.svg', - width: 13.3, - height: 13.3, - ), + SvgPicture.asset('assets/icons/calories.svg', width: 13.3, height: 13.3), + const SizedBox(width: 4), + Text('${(recipe?.calories ?? 0).toStringAsFixed(0)} ккал', style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + const SizedBox(width: 12), + if (type == ProductType.recipe) + InfoBouble( + backgroundColor: itemsBackgroundColor, + children: [ + SvgPicture.asset('assets/icons/steps.svg', width: 13.3, height: 13.3), const SizedBox(width: 4), Text( '${recipe?.steps?.length ?? 0} ${getStepForm(recipe?.steps?.length ?? 0)}', @@ -110,13 +77,7 @@ class InfoRow extends StatelessWidget { if (plan?.type.hasAtLeastOneType() == true || diet?.type.hasAtLeastOneType() == true) InfoBouble( backgroundColor: itemsBackgroundColor, - children: [ - TypeBadges( - type: diet?.type, - planType: plan?.type, - disableDescriptionOfTypes: disableDescriptionOfTypes, - ), - ], + children: [TypeBadges(type: diet?.type, planType: plan?.type, disableDescriptionOfTypes: disableDescriptionOfTypes)], ), ], ), diff --git a/lib/core/widgets/success_published_diet_bottom_sheet.dart b/lib/core/widgets/success_published_diet_bottom_sheet.dart index 223ed220..5b5625b7 100644 --- a/lib/core/widgets/success_published_diet_bottom_sheet.dart +++ b/lib/core/widgets/success_published_diet_bottom_sheet.dart @@ -8,32 +8,33 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget { - const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false}); + const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false, this.isDrafted, this.isUpdated}); final String title; final bool isPlan; + final bool? isDrafted; + final bool? isUpdated; @override Widget build(BuildContext context) { final s = S.of(context); + final String text; + if (isDrafted == true) { + text = 'cохранена в черновиках'; + } else if (isUpdated == true) { + text = 'успешно обновлена'; + } else if (isPlan == true) { + text = 'опубликован'; + } else { + text = 'опубликованна'; + } + return Column( children: [ - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), const BottomSheetHandlerBar(), - const SizedBox( - height: 20, - ), - Text( - title, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - Text( - isPlan ? 'опубликован' : 'опубликованна', - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), + Text(title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + Text(text, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: CustomOutlinedButton( @@ -43,15 +44,10 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget { text: s.ok, height: 52, buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), ), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), ], ); } diff --git a/lib/features/create_diet/presentation/create_diet_screen.dart b/lib/features/create_diet/presentation/create_diet_screen.dart index 2497b633..5d95e783 100644 --- a/lib/features/create_diet/presentation/create_diet_screen.dart +++ b/lib/features/create_diet/presentation/create_diet_screen.dart @@ -29,24 +29,24 @@ class CreateDietScreen extends StatelessWidget { static const name = 'createDietScreen'; static const path = '/create-diet-screen'; - const CreateDietScreen({super.key, this.diet}); + const CreateDietScreen({super.key, this.diet, this.isEditing}); final Diet? diet; - + final bool? isEditing; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => sl()..setInitialData(diet), - child: CreateDietScreenContent(diet: diet), + child: CreateDietScreenContent(diet: diet, isEditing: isEditing), ); } } class CreateDietScreenContent extends StatefulWidget { - const CreateDietScreenContent({super.key, this.diet}); + const CreateDietScreenContent({super.key, this.diet, this.isEditing}); final Diet? diet; - + final bool? isEditing; @override State createState() => _CreateDietScreenContentState(); } @@ -82,12 +82,7 @@ class _CreateDietScreenContentState extends State { } if (key != null && key.currentContext != null) { - Scrollable.ensureVisible( - key.currentContext!, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - alignment: 0.1, - ); + Scrollable.ensureVisible(key.currentContext!, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, alignment: 0.1); } } @@ -121,7 +116,7 @@ class _CreateDietScreenContentState extends State { context.pop(); showBottomSheetWithBlur( context: context, - child: SuccessPublishedDietOrPlanBottomSheet(title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках'), + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), ); }); } @@ -213,54 +208,131 @@ class _CreateDietScreenContentState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - CustomOutlinedButton( - onPressed: state.isDietPublishing - ? () {} - : () async { - final diet = await context.read().getDietForPreview(); - if (diet == null) { - // ignore: use_build_context_synchronously - _scrollToFirstError(context.read().state); - return; + child: state.isDietBeingEdited || state.isDietPublishing + ? const Loader() + : Column( + children: [ + CustomOutlinedButton( + onPressed: () async { + if (state.isDietPublishing) return; + if (widget.diet != null && widget.isEditing == true) { + final dietState = state.dietState; + if (dietState == DietState.draft) { + context.read().setDietState(DietState.active); + } else { + context.read().setDietState(DietState.draft); + } + final result = await context.read().updateDiet(widget.diet!.id); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}'); + return; + } + if (dietState == DietState.draft) { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета'), + ); + }); + } else { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), + ); + }); + } + } else { + if (state.dietState != DietState.draft) { + context.read().setDietState(DietState.draft); + } + final result = await context.read().createDiet(); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); + } } - if (!context.mounted) return; - GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet); }, - text: s.preview, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + text: state.dietState == DietState.draft && widget.isEditing == true ? s.publish : "В черновик", + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + ), + ), + const SizedBox(height: 12), + CustomOutlinedButton( + onPressed: state.isDietPublishing || state.isDietBeingEdited + ? () {} + : () async { + final diet = await context.read().getDietForPreview(); + if (diet == null) { + if (!context.mounted) return; + _scrollToFirstError(context.read().state); + return; + } + if (!context.mounted) return; + GoRouter.of( + context, + ).pushNamed(DietPreviewScreen.name, extra: {'diet': diet, 'isImageInBase64': state.dietData.imageUrl == null}); + }, + text: s.preview, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + ), + ), + const SizedBox(height: 12), + + CustomOutlinedButton( + onPressed: () async { + if (state.isDietBeingEdited) return; + if (widget.diet != null && widget.isEditing == true) { + final result = await context.read().updateDiet(widget.diet!.id); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}'); + } else { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isUpdated: true), + ); + }); + } + } else { + final result = await context.read().createDiet(); + if (result == 'validation') { + if (!context.mounted) return; + _scrollToFirstError(context.read().state); + } else if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); + } + } + }, + text: widget.diet != null && widget.isEditing == true ? s.save : s.publish, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + ), + ], ), - ), - const SizedBox(height: 12), - if (state.isDietPublishing) - const Loader() - else - CustomOutlinedButton( - onPressed: () async { - final result = await context.read().createDiet(); - if (result == 'validation') { - // ignore: use_build_context_synchronously - _scrollToFirstError(context.read().state); - } else if (result == 'error') { - if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); - } - }, - text: s.publish, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), - ), - ], - ), ), const SizedBox(height: 20), ], diff --git a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart index f8eb5a73..4b7315ab 100644 --- a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart +++ b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart @@ -32,7 +32,7 @@ class CreateDietCubit extends Cubit { emit(state.copyWith(isLoading: true, isDietBeingEdited: true)); dietId = diet.id; final dailyScheduleWithNutrition = await createDietRepository.getDietPortionsNutrition(diet.id); - final countOfDays = dailyScheduleWithNutrition.length; + final countOfDays = diet.duration; final List>> dailySchedule = List.generate(countOfDays, (index) => [[], [], [], [], []]); for (final dayEntry in dailyScheduleWithNutrition.entries) { @@ -49,6 +49,12 @@ class CreateDietCubit extends Cubit { type['carb'] = diet.type.carb ?? false; type['light'] = diet.type.light ?? false; final appConfig = sl(); + String? imageUrl; + if (diet.image?.startsWith('https') == true) { + imageUrl = diet.image; + } else if (diet.image != null && diet.image?.isNotEmpty == true) { + imageUrl = '${appConfig.baseUrl}/storage/${diet.image}'; + } emit( state.copyWith.dietData( dailySchedule: dailySchedule, @@ -57,12 +63,10 @@ class CreateDietCubit extends Cubit { name: diet.name, duration: diet.duration, type: type, - // Avoid network image URLs during tests to prevent HTTP fetch attempts - imageUrl: appConfig.buildType == AppBuildType.tests ? null : '${appConfig.baseUrl}/storage/${diet.image}', + imageUrl: appConfig.buildType == AppBuildType.tests ? null : imageUrl, ), ); - emit(state.copyWith(dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration))); - emit(state.copyWith(isLoading: true)); + emit(state.copyWith(isLoading: false, isDietBeingEdited: false, dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration))); } void setDetails(String text) { @@ -195,11 +199,20 @@ class CreateDietCubit extends Cubit { } } + + static const int _maxImageBytesForBase64 = 5 * 1024 * 1024; + Future getDietForPreview() async { - if (!_isFromValid()) return null; + if (!_isFormValidForDraft()) return null; final authorProfile = await profileRepository.getMyProfile(); final imageBytes = (await state.dietData.image?.readAsBytes()) ?? []; - final imageInBase64 = base64.encode(imageBytes); + String image = ''; + if (imageBytes.isNotEmpty && imageBytes.length <= _maxImageBytesForBase64) { + image = base64.encode(imageBytes); + } + if (image.isEmpty && state.dietData.imageUrl != null) { + image = state.dietData.imageUrl ?? ''; + } final carbs = state.dietData.dailySchedule?.map((element) { return element.fold(0, (prev, element) { return prev + @@ -259,7 +272,7 @@ class CreateDietCubit extends Cubit { id: 0, name: state.dietData.name ?? '', slug: '', - image: imageInBase64, + image: image.isNotEmpty ? image : null, duration: state.dietData.duration ?? 0, state: state.dietData.state ?? DietState.active, type: DietType( @@ -302,11 +315,19 @@ class CreateDietCubit extends Cubit { Future createDiet() async { emit(state.copyWith(isDietPublishing: true)); - if (!_isFromValid()) { + if ([DietState.active, DietState.personal].contains(state.dietState) && !_isFromValid()) { + emit(state.copyWith(isDietPublishing: false)); + return 'validation'; + } else if (!_isFormValidForDraft()) { emit(state.copyWith(isDietPublishing: false)); return 'validation'; } try { + final dietState = state.dietData.state; + if (dietState == null) { + emit(state.copyWith(isDietPublishing: false, dietStateError: 'Статус диеты не выбран')); + return 'validation'; + } File? file; if (state.dietData.imageUrl != null) { file = await downloadImageAsFile(state.dietData.imageUrl!); @@ -315,18 +336,17 @@ class CreateDietCubit extends Cubit { if (state.dietData.image?.path != null) { image = await ImageCompressUtility.compressFile(File(state.dietData.image!.path)); } - final dietState = state.dietData.state!; final dailySchedule = state.dietData.dailySchedule; await createDietRepository.createDiet( name: state.dietData.name ?? '', duration: state.dietData.duration ?? 7, state: dietState, - image: image ?? file!, + image: image ?? file, meatType: state.dietData.type['meat'] == true ? '1' : '0', carbType: state.dietData.type['carb'] == true ? '1' : '0', vegType: state.dietData.type['veg'] == true ? '1' : '0', lightType: state.dietData.type['light'] == true ? '1' : '0', - details: state.dietData.details ?? '', + details: state.dietData.details, dailySchedule: dailySchedule ?? [], ); @@ -341,7 +361,15 @@ class CreateDietCubit extends Cubit { } Future updateDiet(int dietId) async { - if (!_isFromValid()) return 'error'; + emit(state.copyWith(isDietBeingEdited: true)); + if ([DietState.active, DietState.personal].contains(state.dietState) && !_isFromValid()) { + emit(state.copyWith(isDietBeingEdited: false)); + return 'error'; + } else if (!_isFormValidForDraft()) { + emit(state.copyWith(isDietBeingEdited: false)); + return 'error'; + } + try { File? image; if (state.dietData.image != null) { @@ -357,17 +385,37 @@ class CreateDietCubit extends Cubit { carbType: state.dietData.type['carb'] == true ? '1' : '0', vegType: state.dietData.type['veg'] == true ? '1' : '0', lightType: state.dietData.type['light'] == true ? '1' : '0', - details: state.dietData.details, + details: state.dietData.details?.isEmpty ?? true ? null : state.dietData.details, dailySchedule: state.dietData.dailySchedule, ); + emit(state.copyWith(isDietBeingEdited: false)); return null; } catch (e, st) { await Sentry.captureException(e, stackTrace: st); log(e.toString(), stackTrace: st); + emit(state.copyWith(isDietBeingEdited: false)); return 'error'; } } + bool _isFormValidForDraft() { + bool result = true; + if ((state.dietData.name?.length ?? 0) < 3) { + result = false; + emit(state.copyWith(dietNameError: 'Длинна названия не может быть меньше 3 символов')); + } + if (state.dietData.name == null || state.dietData.name == '') { + result = false; + emit(state.copyWith(dietNameError: 'Поле не заполнено')); + } + + if (state.dietData.details?.length != null && (state.dietData.details?.isNotEmpty ?? false) && (state.dietData.details?.length ?? 4) < 3) { + result = false; + emit(state.copyWith(dietDescriptionError: 'Длинна описания не может быть меньше 3 символов')); + } + return result; + } + bool _isFromValid() { bool result = true; if ((state.dietData.name?.length ?? 0) < 3) { diff --git a/lib/features/create_diet/presentation/cubit/create_diet_state.dart b/lib/features/create_diet/presentation/cubit/create_diet_state.dart index dc280baa..c55f6b91 100644 --- a/lib/features/create_diet/presentation/cubit/create_diet_state.dart +++ b/lib/features/create_diet/presentation/cubit/create_diet_state.dart @@ -27,12 +27,11 @@ abstract class CreateDietState with _$CreateDietState { }) = _CreateDietState; factory CreateDietState.initial() => CreateDietState( - dailyScheduleErrors: List.filled(21, ''), - isDietPublishing: false, - isLoading: false, - errorMessage: null, - dietData: CreateDietModel( - type: Map.fromIterable(dietTypeValues, value: (element) => false), - ), - ); + dailyScheduleErrors: List.filled(21, ''), + isDietPublishing: false, + isLoading: false, + errorMessage: null, + isDietBeingEdited: false, + dietData: CreateDietModel(type: Map.fromIterable(dietTypeValues, value: (element) => false)), + ); } diff --git a/lib/features/create_diet/presentation/diet_preview_screen.dart b/lib/features/create_diet/presentation/diet_preview_screen.dart index f57548f0..fd5a2f3b 100644 --- a/lib/features/create_diet/presentation/diet_preview_screen.dart +++ b/lib/features/create_diet/presentation/diet_preview_screen.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/product_type.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; @@ -18,10 +21,11 @@ import 'package:flutter_svg/svg.dart'; class DietPreviewScreen extends StatefulWidget { final Diet diet; + final bool isImageInBase64; late final List selectedIndexes; static const name = 'createDietScreenPreview'; static const path = '/create-diet-screen/preview'; - DietPreviewScreen({super.key, required this.diet}) { + DietPreviewScreen({super.key, required this.diet, required this.isImageInBase64}) { selectedIndexes = List.generate(diet.dailySchedule.length, (_) => 0); } @@ -31,11 +35,19 @@ class DietPreviewScreen extends StatefulWidget { class _DietPreviewScreenState extends State { final ScrollController scrollController = ScrollController(); - late final Uint8List imageData; + Uint8List? imageData; @override void initState() { super.initState(); - imageData = base64.decode(widget.diet.image); + if (widget.diet.image != null && widget.isImageInBase64) { + imageData = base64.decode(widget.diet.image!); + } + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); } @override @@ -58,7 +70,7 @@ class _DietPreviewScreenState extends State { ), body: SingleChildScrollView( controller: scrollController, - padding: const EdgeInsets.only(bottom: 160), + padding: const EdgeInsets.only(bottom: 40), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,49 +79,95 @@ class _DietPreviewScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - Image.memory(imageData, height: 300, width: MediaQuery.sizeOf(context).width, fit: BoxFit.fill), - // CachedNetworkImage( - // imageUrl: baseUrl + '/storage/' + widget.diet.image, - // ), - Positioned( - bottom: 16, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - InfoBouble( - backgroundColor: appTheme.whiteA700, - children: [ - SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), - const SizedBox(width: 8), - Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), - ], - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () {}, - child: InfoBouble( + if (imageData != null) + Stack( + children: [ + Image.memory(imageData!, height: 300, width: MediaQuery.sizeOf(context).width, fit: BoxFit.fill), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InfoBouble( backgroundColor: appTheme.whiteA700, children: [ - SvgPicture.asset( - 'assets/icons/heart.svg', - width: 14, - height: 12, - colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, - ), + SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), const SizedBox(width: 8), - Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), ], ), - ), - ], + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset( + 'assets/icons/heart.svg', + width: 14, + height: 12, + colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, + ), + const SizedBox(width: 8), + Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + ), + ], + ), ), - ), - ], - ), + ], + ) + else if (!widget.isImageInBase64 && widget.diet.image != null && (widget.diet.image?.isNotEmpty ?? false)) + Stack( + children: [ + CachedNetworkImage( + imageUrl: widget.diet.image?.startsWith('https') == true + ? widget.diet.image! + : '${sl().baseUrl}/storage/${widget.diet.image}', + height: 300, + width: MediaQuery.sizeOf(context).width, + fit: BoxFit.fill, + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), + const SizedBox(width: 8), + Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset( + 'assets/icons/heart.svg', + width: 14, + height: 12, + colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, + ), + const SizedBox(width: 8), + Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + ), + ], + ), + ), + ], + ), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -148,14 +206,6 @@ class _DietPreviewScreenState extends State { }, itemCount: widget.diet.dailySchedule.length, ), - const MainBackgroundBouble( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - children: [ - SizedBox(height: 16), - ], - ), - ), ], ), ), diff --git a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart index ececed87..a8eb36d5 100644 --- a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart +++ b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart @@ -185,7 +185,7 @@ class _AddMealWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: glossary.name, - productNutritionalValueId: glossary.id.toString(), + productNutritionalValueId: glossary.id, portion: measurementUnits, ); if (portionIndex == null) { @@ -212,7 +212,7 @@ class _AddMealWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: newGlossary.name, - productNutritionalValueId: newGlossary.id.toString(), + productNutritionalValueId: newGlossary.id, portion: measurementUnits, ); if (portionIndex == null) { diff --git a/lib/features/create_diet/repositories/create_diet_repository.dart b/lib/features/create_diet/repositories/create_diet_repository.dart index 07586d6a..feb37496 100644 --- a/lib/features/create_diet/repositories/create_diet_repository.dart +++ b/lib/features/create_diet/repositories/create_diet_repository.dart @@ -12,44 +12,56 @@ class CreateDietRepository { required String name, required int duration, required DietState state, - required String meatType, - required String carbType, - required String vegType, - required String lightType, - required String details, - required File image, - required List>> dailySchedule, + required String? meatType, + required String? carbType, + required String? vegType, + required String? lightType, + required String? details, + required File? image, + required List>>? dailySchedule, }) async { final dietMap = {}; - for (final (dayIndex, day) in dailySchedule.indexed) { - for (final (mealIndex, meal) in day.indexed) { - for (final (productIndex, product) in meal.indexed) { - final jsonProduct = product.toJson(); - dietMap.addAll({ - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][name]': jsonProduct['name'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][portion]': jsonProduct['portion'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][quantity]': jsonProduct['quantity'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][product_nutritional_value_id]': jsonProduct['product_nutritional_value_id'], - }); + if (dailySchedule != null) { + for (final (dayIndex, day) in dailySchedule.indexed) { + for (final (mealIndex, meal) in day.indexed) { + for (final (productIndex, product) in meal.indexed) { + final jsonProduct = product.toJson(); + dietMap.addAll({ + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][name]': jsonProduct['name'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][portion]': jsonProduct['portion'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][quantity]': jsonProduct['quantity'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][product_nutritional_value_id]': jsonProduct['product_nutritional_value_id'], + }); + } } } } + if (details != null) { + dietMap.addAll({'description_md': details}); + } - dietMap.addAll({ - 'name': name, - 'description_md': details, - 'duration': duration, - 'type[meat]': meatType, - 'type[carb]': carbType, - 'type[veg]': vegType, - 'type[light]': lightType, - 'image': MultipartFile.fromFileSync( - image.path, - filename: image.path.split(Platform.pathSeparator).last, - ), - }); + if (meatType != null) { + dietMap.addAll({'type[meat]': meatType}); + } - final formData = FormData.fromMap(dietMap); + if (carbType != null) { + dietMap.addAll({'type[carb]': carbType}); + } + + if (vegType != null) { + dietMap.addAll({'type[veg]': vegType}); + } + + if (lightType != null) { + dietMap.addAll({'type[light]': lightType}); + } + if (image != null) { + dietMap.addAll({'image': MultipartFile.fromFileSync(image.path, filename: image.path.split(Platform.pathSeparator).last)}); + } + + dietMap.addAll({'name': name, 'duration': duration}); + + final formData = FormData.fromMap(dietMap); try { switch (state) { case DietState.active: @@ -76,7 +88,12 @@ class CreateDietRepository { return MapEntry( int.parse(key), (value as Map).map((key, value) { - return MapEntry(int.parse(key), DailySchedule.fromJson(value as Map)); + final portionMap = Map.from(value as Map); + final pnvid = portionMap['product_nutritional_value_id']; + if (pnvid is String) { + portionMap['product_nutritional_value_id'] = int.tryParse(pnvid); + } + return MapEntry(int.parse(key), DailySchedule.fromJson(portionMap)); }), ); }), @@ -89,7 +106,7 @@ class CreateDietRepository { } } - Future updateDiet({ + Future updateDiet({ required int dietId, required DietState state, String? name, @@ -140,24 +157,24 @@ class CreateDietRepository { dietMap.addAll({'type[light]': lightType}); } if (image != null) { - dietMap.addAll({ - 'image': MultipartFile.fromFileSync( - image.path, - filename: image.path.split(Platform.pathSeparator).last, - ), - }); + dietMap.addAll({'image': MultipartFile.fromFileSync(image.path, filename: image.path.split(Platform.pathSeparator).last)}); } final formData = FormData.fromMap(dietMap); try { switch (state) { case DietState.active: - return await createDietService.updatePublished(diet: dietId, formData: formData); + final response = await createDietService.updatePublished(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; case DietState.draft: - return await createDietService.updateDrafted(diet: dietId, formData: formData); + final response = await createDietService.updateDrafted(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; case DietState.personal: - return await createDietService.updatePersonal(diet: dietId, formData: formData); + final response = await createDietService.updatePersonal(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; } + } on DioException catch (e) { + return e.response?.statusCode ?? 0; } catch (e, s) { await Sentry.captureException(e, stackTrace: s); rethrow; diff --git a/lib/features/create_diet/services/create_diet_service.dart b/lib/features/create_diet/services/create_diet_service.dart index 3c0d8923..cc010293 100644 --- a/lib/features/create_diet/services/create_diet_service.dart +++ b/lib/features/create_diet/services/create_diet_service.dart @@ -28,21 +28,21 @@ abstract class CreateDietService { @POST('/api/diets/update/published/{diet}') @MultiPart() - Future updatePublished({ + Future updatePublished({ @Path() required int diet, @Body() required FormData formData, }); @POST('/api/diets/update/drafted/{diet}') @MultiPart() - Future updateDrafted({ + Future updateDrafted({ @Path() required int diet, @Body() required FormData formData, }); @POST('/api/diets/update/personalized/{diet}') @MultiPart() - Future updatePersonal({ + Future updatePersonal({ @Path() required int diet, @Body() required FormData formData, }); diff --git a/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart b/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart index 597bc09b..9fd0a25d 100644 --- a/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart @@ -182,7 +182,7 @@ class _AddPortionWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: glossary.name, - productNutritionalValueId: glossary.id.toString(), + productNutritionalValueId: glossary.id, portion: measurementUnits, ); if (portionIndex == null) { @@ -212,7 +212,7 @@ class _AddPortionWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: newGlossary.name, - productNutritionalValueId: newGlossary.id.toString(), + productNutritionalValueId: newGlossary.id, portion: measurementUnits, ); if (portionIndex == null) { diff --git a/lib/features/feed/models/diets_pagination_model.dart b/lib/features/feed/models/diets_pagination_model.dart index 5da0bf7e..aec36295 100644 --- a/lib/features/feed/models/diets_pagination_model.dart +++ b/lib/features/feed/models/diets_pagination_model.dart @@ -22,12 +22,12 @@ abstract class Diet with _$Diet { @JsonKey(name: "id") required int id, @JsonKey(name: "slug") required String slug, @JsonKey(name: "name") required String name, - @JsonKey(name: "image") required String image, + @JsonKey(name: "image") required String? image, @JsonKey(name: "duration") required int duration, @JsonKey(name: "state") required DietState state, - @JsonKey(name: "type") required DietType type, - @JsonKey(name: "description_md") required String details, - @JsonKey(name: "daily_schedule") required Map>> dailySchedule, + @JsonKey(name: "type", fromJson: parseDietTypeFromJson) required DietType type, + @JsonKey(name: "description_md", defaultValue: '') required String details, + @JsonKey(name: "daily_schedule", defaultValue: {}) required Map>> dailySchedule, @JsonKey(name: "author") required Author author, @JsonKey(name: "total_likes") required int totalLikes, @JsonKey(name: "total_views") required int totalViews, @@ -40,12 +40,20 @@ abstract class Diet with _$Diet { factory Diet.fromJson(Map json) => _$DietFromJson(json); } +DietType parseDietTypeFromJson(Map? json) { + if (json == null) { + return const DietType(carb: false, light: false, meat: false, veg: false); + } + + return DietType.fromJson(json); +} + enum DietState { - @JsonKey(name: 'active') + @JsonValue('active') active, - @JsonKey(name: 'drafted') + @JsonValue('drafted') draft, - @JsonKey(name: 'personal') + @JsonValue('personal') personal; @override @@ -63,9 +71,7 @@ enum DietState { @freezed abstract class CreatedDiet with _$CreatedDiet { - const factory CreatedDiet({ - @JsonKey(name: "id") required int id, - }) = _CreatedDiet; + const factory CreatedDiet({@JsonKey(name: "id") required int id}) = _CreatedDiet; factory CreatedDiet.fromJson(Map json) => _$CreatedDietFromJson(json); } @@ -99,7 +105,7 @@ abstract class DailySchedule with _$DailySchedule { @JsonKey(name: "portion") required Portion portion, @JsonKey(name: "quantity", fromJson: _parseQuantity) required int quantity, @JsonKey(name: 'nutrition', includeToJson: false) DailyNutrition? dailyNutrition, - @JsonKey(name: 'product_nutritional_value_id', includeToJson: true, includeFromJson: false, includeIfNull: false) String? productNutritionalValueId, + @JsonKey(name: 'product_nutritional_value_id', includeToJson: true, includeFromJson: true, includeIfNull: false) int? productNutritionalValueId, }) = _DailySchedule; factory DailySchedule.fromJson(Map json) => _$DailyScheduleFromJson(json); @@ -135,10 +141,10 @@ enum Portion { } String getValue() => switch (this) { - Portion.grams => 'gr', - Portion.milliliters => 'milliliters', - Portion.pcs => 'pcs', - }; + Portion.grams => 'gr', + Portion.milliliters => 'milliliters', + Portion.pcs => 'pcs', + }; } @freezed diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index cdeac843..dfc3e9d4 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -248,7 +248,7 @@ class _FeedScreenContentState extends State { 3 when hidePlans => AddItemButton( onTap: state.profile?.roles.contains('consultant') ?? false ? () async { - await GoRouter.of(context).pushNamed(CreateDietScreen.name); + await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}); } : null, ), diff --git a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart index 8a93ec17..398fdb63 100644 --- a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart +++ b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart @@ -291,16 +291,16 @@ class MyDietsCubit extends Cubit { } } - Future publishDiet(int dietId) async { + Future publishDiet(int dietId) async { try { - await createDietRepository.updateDiet( + final statusCode = await createDietRepository.updateDiet( dietId: dietId, state: DietState.active, ); - return true; + return statusCode; } catch (e, st) { log(e.toString(), stackTrace: st); - return false; + return 0; } } diff --git a/lib/features/my_diets/presentation/my_diets_screen.dart b/lib/features/my_diets/presentation/my_diets_screen.dart index 99d39b07..1c5bad54 100644 --- a/lib/features/my_diets/presentation/my_diets_screen.dart +++ b/lib/features/my_diets/presentation/my_diets_screen.dart @@ -60,15 +60,14 @@ class MyDietsScreenContent extends StatelessWidget { builder: (context, state) { return NestedScrollView( controller: switch (state.selectedStateIndex) { - 1 => personalDietsScrollController, - _ => activeDietsScrollController + 1 => draftDietsScrollController, + 2 => personalDietsScrollController, + _ => activeDietsScrollController, }, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverAppBar( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))), backgroundColor: appTheme.gray100, foregroundColor: appTheme.gray100, automaticallyImplyLeading: false, @@ -78,20 +77,14 @@ class MyDietsScreenContent extends StatelessWidget { snap: true, scrolledUnderElevation: 0, bottom: PreferredSize( - preferredSize: Size( - MediaQuery.sizeOf(context).width, - 64, - ), + preferredSize: Size(MediaQuery.sizeOf(context).width, 64), child: Padding( padding: const EdgeInsets.only(bottom: 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: MyDietsTab(), - ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 4), child: MyDietsTab()), const SizedBox(height: 8), Row( children: [ @@ -102,24 +95,19 @@ class MyDietsScreenContent extends StatelessWidget { backgroundColor: appTheme.whiteA700, borderRadius: 100, searchController: switch (state.selectedStateIndex) { - 1 => personalDietsSearchController, - _ => activeDietsSearchController + 1 => draftDietsSearchController, + 2 => personalDietsSearchController, + _ => activeDietsSearchController, }, onChanged: (query) { - EasyDebounce.debounce( - 'searchDiets', - const Duration(milliseconds: 500), - () { - context.read().loadCurrentDiets(query: query == '' ? null : query); - }, - ); + EasyDebounce.debounce('searchDiets', const Duration(milliseconds: 500), () { + context.read().loadCurrentDiets(query: query == '' ? null : query); + }); }, ), ), const SizedBox(width: 12), - AddItemButton( - onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name), - ), + AddItemButton(onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false})), const SizedBox(width: 4), ], ), @@ -136,15 +124,14 @@ class MyDietsScreenContent extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, MyDietsState state) { if (state.isLoading) { - return Scaffold( - backgroundColor: Colors.grey.shade200, - body: const Loader(), - ); + return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); } else if (state.error != null) { return DataErrorWidget(onReload: () => context.read().loadAllDiets()); } else { - final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 || - state.personalDietsError != null && state.selectedStateIndex == 1; + final gotDietLoadingError = + state.activeDietsError != null && state.selectedStateIndex == 0 || + state.draftDietsError != null && state.selectedStateIndex == 1 || + state.personalDietsError != null && state.selectedStateIndex == 2; if (gotDietLoadingError) { return DataErrorWidget(onReload: () => context.read().loadCurrentDiets()); @@ -155,7 +142,8 @@ class MyDietsScreenContent extends StatelessWidget { } final diets = switch (state.selectedStateIndex) { - 1 => state.personalDiets, + 2 => state.personalDiets, + 1 => state.draftDiets, _ => state.activeDiets, }; @@ -166,10 +154,7 @@ class MyDietsScreenContent extends StatelessWidget { shrinkWrap: true, physics: const BouncingScrollPhysics(), itemBuilder: (context, index) { - return MyDietWidget( - diet: diets[index], - key: ValueKey(diets[index].id), - ); + return MyDietWidget(diet: diets[index], key: ValueKey(diets[index].id)); }, separatorBuilder: (context, index) { return const SizedBox(height: 20); @@ -179,11 +164,7 @@ class MyDietsScreenContent extends StatelessWidget { Positioned( right: 0, bottom: 74, - child: FilterBadge( - filterBottomSheet: DietFilterBottomSheet( - myDietsCubit: context.read(), - ), - ), + child: FilterBadge(filterBottomSheet: DietFilterBottomSheet(myDietsCubit: context.read())), ), ], ); diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index f2fccd5c..0799fc77 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -1,19 +1,22 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/enums/product_type.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart'; import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart'; +import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_state.dart'; import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_deleted_bottom_sheet.dart'; +import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_drafetd_bottom_sheet.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -33,6 +36,73 @@ class MyDietWidget extends StatefulWidget { } class _MyDietWidgetState extends State { + + Future _editDiet(Diet diet, BuildContext context) async{ + await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': true}); + if (!context.mounted) return; + await context.read().loadAllDiets(); + } + + void _suggestDiet(Diet diet) { + GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: diet.id); + } + + Future _shareDiet(int dietId) async { + await SharePlus.instance.share(ShareParams(uri: Uri.parse('${sl().baseUrl}/ru/diet/$dietId'))); + } + + Future _publishDiet(Diet diet, BuildContext context) async { + final statusCode = await context.read().publishDiet(widget.diet.id); + switch (statusCode) { + case 201: + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + break; + case 422: + showErrorToast('Пожалуйста, заполните все обязательные поля для публикации'); + break; + default: + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + } + } + + void _unpublishDiet(Diet diet, BuildContext context) async { + final result = await context.read().unpublishDiet(widget.diet.id); + if (result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + height: 250, + child: DietDraftedBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + } else { + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + } + } + + void _createBasedOnChosen(Diet diet, BuildContext context) async { + await showErrorDialog(context, title: 'Предупреждение', description: 'Вы переходите к созданию новой диеты на основе скопированной'); + if (!context.mounted) return; + GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': false}); + } + + void _deleteDiet(Diet diet, BuildContext context) async { + final result = await context.read().deleteDiet(widget.diet.id); + if (result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + child: DietDeletedBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + } + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -42,28 +112,34 @@ class _MyDietWidgetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - InkWell( - onTap: () => GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'navigateToComments': false}), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: CachedNetworkImage( - imageUrl: '${sl().baseUrl}/storage/${widget.diet.image}', - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, + if (widget.diet.image != null && (widget.diet.image?.isNotEmpty ?? false)) + Stack( + children: [ + InkWell( + onTap: () { + if (widget.diet.state == DietState.draft) { + return; + } + GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'navigateToComments': false}); + }, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: CachedNetworkImage( + imageUrl: '${sl().baseUrl}/storage/${widget.diet.image}', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + ), ), ), - ), - Positioned( - bottom: 16, - child: SizedBox( - width: MediaQuery.sizeOf(context).width, - child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet), + Positioned( + bottom: 16, + child: SizedBox( + width: MediaQuery.sizeOf(context).width, + child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet), + ), ), - ), - ], - ), + ], + ), const SizedBox(height: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -78,19 +154,20 @@ class _MyDietWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Builder( builder: (context) { - String details = widget.diet.details.length > 200 - ? '${widget.diet.details.substring(0, 200)}...' - : widget.diet.details; + String details = widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200)}...' : widget.diet.details; - return MarkdownHtmlWidget( - markdownText: details, - fontSize: TextStyles.textSMedium.fontSize ?? 14, - textColor: appTheme.black, - ); + return MarkdownHtmlWidget(markdownText: details, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black); }, ), ), ], + if (widget.diet.image == null || (widget.diet.image?.isEmpty ?? true)) ...[ + const SizedBox(height: 12), + SizedBox( + width: MediaQuery.sizeOf(context).width, + child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.dividerColor, disableDescriptionOfTypes: true, type: ProductType.diet), + ), + ], ], ), const SizedBox(height: 20), @@ -100,67 +177,38 @@ class _MyDietWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ - // Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных) Row( children: [ - // Кнопка удаления (слева) - CustomOutlinedButton( - onPressed: () async { - final result = await context.read().deleteDiet(widget.diet.id); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - context: context, - child: DietDeletedBottomSheet(title: widget.diet.name), - ); - await context.read().loadAllDiets(); - } - }, - height: 52, - width: 52, - leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), - side: const WidgetStatePropertyAll(BorderSide.none), - ), - text: '', - ), - const SizedBox(width: 6), - // Кнопка редактирования (центр) Expanded( child: CustomOutlinedButton( onPressed: () async { if (state.selectedStateIndex == 1) { - await showErrorDialog(context, title: 'Предупреждение', description: 'Вы можете создать новую диету на основе существующей.'); - AppRouter.navigatorKey.currentContext?.pushNamed(CreateDietScreen.name, extra: widget.diet); + _publishDiet(widget.diet, context); + } else if (state.selectedStateIndex == 2) { + _suggestDiet(widget.diet); } else { - GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet); - } + _editDiet(widget.diet, context); + } + }, + text: switch (state.selectedStateIndex) { + 1 => s.publish, + 2 => s.toOffer, + _ => s.edit, }, - text: s.edit, height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonStyle: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), side: const WidgetStatePropertyAll(BorderSide.none), ), ), ), - // Кнопка поделиться (справа) для опубликованных if (state.selectedStateIndex == 0) ...[ const SizedBox(width: 6), CustomOutlinedButton( onPressed: () async { - final box = context.findRenderObject() as RenderBox?; - await SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/diet/${widget.diet.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); + await _shareDiet(widget.diet.id); }, height: 52, width: 52, @@ -179,26 +227,125 @@ class _MyDietWidgetState extends State { text: '', ), ], + if (state.selectedStateIndex == 2) ...[ + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + _createBasedOnChosen(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: SvgPicture.asset( + 'assets/icons/copy2.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode(appTheme.whiteA700, BlendMode.srcIn), + ), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], ], ), - // Нижний ряд: Предложить (только для персональных) if (state.selectedStateIndex == 1) ...[ const SizedBox(height: 8), - CustomOutlinedButton( - onPressed: () { - GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id); - }, - text: s.toOffer, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - side: const WidgetStatePropertyAll(BorderSide.none), - ), + Row( + children: [ + Expanded( + child: CustomOutlinedButton( + onPressed: () async { + GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: {'diet': widget.diet, 'isImageInBase64': false}); + }, + text: 'Превью', + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + ), + ), + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + _deleteDiet(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], ), ], const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: CustomOutlinedButton( + onPressed: () async { + if (state.selectedStateIndex == 0) { + _unpublishDiet(widget.diet, context); + } else { + _editDiet(widget.diet, context); + } + }, + text: switch (state.selectedStateIndex) { + 0 => s.removeFromActive, + _ => s.edit, + }, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + ), + ), + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + if (state.selectedStateIndex == 1) { + _createBasedOnChosen(widget.diet, context); + return; + } + _deleteDiet(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: switch (state.selectedStateIndex) { + 1 => SvgPicture.asset( + 'assets/icons/copy2.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode(appTheme.gray, BlendMode.srcIn), + ), + _ => SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), + }, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], + ), + const SizedBox(height: 8), ], ), ); @@ -209,4 +356,3 @@ class _MyDietWidgetState extends State { ); } } - diff --git a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart index be6a0d95..a653441c 100644 --- a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart +++ b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart @@ -21,6 +21,7 @@ class _MyDietsTabState extends State { final s = S.of(context); if (tabTitles.isEmpty) { tabTitles.add(s.published); + tabTitles.add(s.drafted); tabTitles.add(s.personal); } super.didChangeDependencies(); @@ -30,29 +31,37 @@ class _MyDietsTabState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Container( - height: 48, - decoration: BoxDecoration( - color: appTheme.whiteA700, - borderRadius: BorderRadius.circular(100), - ), - padding: const EdgeInsets.all(4), + return Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, child: Row( children: [ - ...List.generate( - 2, - (index) => Expanded( - child: AnimatedOptionButton( - textStyle: TextStyles.textSMedium, - isSelected: state.selectedStateIndex == index, - text: tabTitles[index], - onTap: () { - widget.additionalOnTap?.call(); - context.read().changeSelectedStateIndex(index); - }, - ), + Container( + width: 484, + height: 48, + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(100), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: [ + ...List.generate( + 3, + (index) => Expanded( + child: AnimatedOptionButton( + textStyle: TextStyles.textSMedium, + isSelected: state.selectedStateIndex == index, + text: tabTitles[index], + onTap: () { + widget.additionalOnTap?.call(); + context.read().changeSelectedStateIndex(index); + }, + ), + ), + ), + ], ), ), ], diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index 0758ab9b..027ae523 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -7,6 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -159,5 +161,49 @@ void main() { expect(find.byType(BlogPostScreen), findsOneWidget); }); }); + + testWidgets('renders loaded state with blog post model', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedBlogPostModel( + id: 1, + title: 'Test Post Title', + description: 'Test post description', + image: 'https://example.com/image.jpg', + state: 'published', + author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 15), + updatedAt: DateTime(2024, 1, 15), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + wasLikedByMe: false, + ); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, const BlogPostScreen(id: 1))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostScreen), findsOneWidget); + expect(find.text('Test Post Title'), findsOneWidget); + }); + }); }); } diff --git a/test/features/create_diet/integration_widget_test.dart b/test/features/create_diet/integration_widget_test.dart index 541056f6..0d8c7b31 100644 --- a/test/features/create_diet/integration_widget_test.dart +++ b/test/features/create_diet/integration_widget_test.dart @@ -326,7 +326,7 @@ void main() { expect(find.byType(DietTypeChoiceWidget), findsOneWidget); expect(find.byType(DietImagePickerWidget), findsOneWidget); // Action buttons - expect(find.byType(CustomOutlinedButton), findsNWidgets(2)); + expect(find.byType(CustomOutlinedButton), findsNWidgets(3)); }); }); } diff --git a/test/features/create_diet/presentation/create_diet_screen_test.dart b/test/features/create_diet/presentation/create_diet_screen_test.dart index a9d5f44a..6f3de99c 100644 --- a/test/features/create_diet/presentation/create_diet_screen_test.dart +++ b/test/features/create_diet/presentation/create_diet_screen_test.dart @@ -67,5 +67,39 @@ void main() { expect(find.byType(CreateDietScreenContent), findsOneWidget); }); + + testWidgets('renders create diet screen with loading state', (tester) async { + final cubit = MockCreateDietCubit(); + final state = CreateDietState.initial().copyWith(isLoading: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.setInitialData(any)).thenAnswer((_) async => Future.value()); + + sl.registerLazySingleton(() => cubit); + + await tester.pumpWidget(wrap(const CreateDietScreenContent(), cubit)); + await tester.pump(); + + expect(find.byType(CreateDietScreenContent), findsOneWidget); + }); + + testWidgets('renders create diet screen with diet publishing state', (tester) async { + final cubit = MockCreateDietCubit(); + final state = CreateDietState.initial().copyWith(isDietPublishing: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.setInitialData(any)).thenAnswer((_) async => Future.value()); + + sl.registerLazySingleton(() => cubit); + + await tester.pumpWidget(wrap(const CreateDietScreenContent(), cubit)); + await tester.pump(); + + expect(find.byType(CreateDietScreenContent), findsOneWidget); + }); }); } diff --git a/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart b/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart index c5266123..bd8a2d34 100644 --- a/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart +++ b/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -6,13 +8,13 @@ import 'package:eatvio_app/features/create_diet/repositories/create_diet_reposit import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart'; +import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; - import 'create_diet_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) @@ -236,6 +238,51 @@ void main() { expect(result, null); }); + test('getDietForPreview returns Diet when form is valid', () async { + const profile = ProfileModel( + id: 1, + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + slug: 'test-user', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'https://example.com/photo.jpg', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + when(profileRepository.getMyProfile()).thenAnswer((_) async => profile); + + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); + cubit.setImage(null, imageUrl: 'https://example.com/cover.jpg'); + + final result = await cubit.getDietForPreview(); + + expect(result, isNotNull); + expect(result!.name, 'Test Diet'); + expect(result.details, 'Test Description'); + expect(result.duration, 7); + expect(result.author.authorName, 'Test User'); + expect(result.author.authorSlug, 'test-user'); + verify(profileRepository.getMyProfile()).called(1); + }); + test('createDiet returns validation error when form is invalid', () async { final result = await cubit.createDiet(); expect(result, 'validation'); @@ -257,7 +304,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); // Set image with non-existent file to trigger error in compression @@ -270,6 +317,88 @@ void main() { expect(result, 'error'); }); + test('updateDiet returns error and resets isDietBeingEdited when repository throws', () async { + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); + // No image so we reach repository.updateDiet without compression + + when(dietRepository.updateDiet( + dietId: 1, + state: anyNamed('state'), + name: anyNamed('name'), + duration: anyNamed('duration'), + meatType: anyNamed('meatType'), + carbType: anyNamed('carbType'), + vegType: anyNamed('vegType'), + lightType: anyNamed('lightType'), + details: anyNamed('details'), + image: anyNamed('image'), + dailySchedule: anyNamed('dailySchedule'), + )).thenThrow(Exception('network error')); + + final result = await cubit.updateDiet(1); + + expect(result, 'error'); + expect(cubit.state.isDietBeingEdited, false); + }); + + test('createDiet returns error when repository createDiet throws', () async { + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + for (var day = 0; day < 7; day++) { + cubit.addPortion(dayIndex: day, mealIndex: 0, portion: portion); + } + final tempDir = Directory.systemTemp; + final tempFile = File('${tempDir.path}/create_diet_test_image_${DateTime.now().millisecondsSinceEpoch}.png'); + final pngBytes = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, + 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + await tempFile.writeAsBytes(pngBytes); + cubit.setImage(XFile(tempFile.path)); + when(dietRepository.createDiet( + name: anyNamed('name'), + duration: anyNamed('duration'), + state: anyNamed('state'), + meatType: anyNamed('meatType'), + carbType: anyNamed('carbType'), + vegType: anyNamed('vegType'), + lightType: anyNamed('lightType'), + details: anyNamed('details'), + image: anyNamed('image'), + dailySchedule: anyNamed('dailySchedule'), + )).thenThrow(Exception('server error')); + + final result = await cubit.createDiet(); + + expect(result, 'error'); + expect(cubit.state.isDietPublishing, false); + if (await tempFile.exists()) await tempFile.delete(); + }); + test('createDiet validates name length', () async { cubit.setName('Te'); // Less than 3 characters cubit.setDetails('Test Description'); @@ -280,7 +409,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -302,7 +431,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -321,7 +450,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -336,7 +465,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -386,7 +515,7 @@ void main() { await cubit.setInitialData(testDiet); expect(cubit.dietId, 1); - expect(cubit.state.isDietBeingEdited, true); + expect(cubit.state.isDietBeingEdited, false); expect(cubit.state.dietData.name, 'Test Diet'); expect(cubit.state.dietData.details, 'Test details'); expect(cubit.state.dietData.duration, 7); @@ -405,7 +534,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -427,7 +556,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -448,7 +577,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -469,7 +598,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -491,7 +620,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -522,7 +651,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -546,7 +675,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); diff --git a/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart b/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart index 823b556c..b8b89a3b 100644 --- a/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart +++ b/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart @@ -63,7 +63,7 @@ void main() { dailyNutrition: [], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.byType(DietPreviewScreen), findsOneWidget); @@ -103,7 +103,7 @@ void main() { dailyNutrition: [const DailyNutrition(energy: 2000.0, proteins: 100.0, fats: 50.0, carbohydrates: 200.0)], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.byType(DietPreviewScreen), findsOneWidget); @@ -131,7 +131,7 @@ void main() { dailyNutrition: [], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.text('Test Diet Name'), findsOneWidget); @@ -139,5 +139,34 @@ void main() { expect(find.text('10'), findsOneWidget); // totalLikes }); }); + + testWidgets('renders diet preview with isImageInBase64 false (URL image)', (tester) async { + await mockNetworkImagesFor(() async { + const testDiet = Diet( + id: 1, + slug: 'test-diet', + name: 'Test Diet URL Image', + image: 'https://example.com/diet-cover.jpg', + duration: 7, + state: DietState.active, + type: DietType(veg: true, light: false, carb: false, meat: false), + details: 'Test diet details', + dailySchedule: {}, + author: Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'https://example.com/photo.jpg', authorIsMe: false), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + wasLikedByMe: false, + isFavorite: false, + dailyNutrition: [], + ); + + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: false))); + await tester.pumpAndSettle(); + + expect(find.byType(DietPreviewScreen), findsOneWidget); + expect(find.text('Test Diet URL Image'), findsOneWidget); + }); + }); }); } diff --git a/test/features/create_diet/repositories/create_diet_repository_test.dart b/test/features/create_diet/repositories/create_diet_repository_test.dart index 1b881380..f80d4eb5 100644 --- a/test/features/create_diet/repositories/create_diet_repository_test.dart +++ b/test/features/create_diet/repositories/create_diet_repository_test.dart @@ -6,10 +6,16 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:retrofit/dio.dart'; import 'package:retrofit/retrofit.dart'; import 'create_diet_repository_test.mocks.dart'; +HttpResponse _okResponse() => HttpResponse( + null, + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), + ); + @GenerateNiceMocks([MockSpec()]) void main() { group('CreateDietRepository', () { @@ -152,7 +158,7 @@ void main() { final testFile = File('${Directory.systemTemp.path}/test_file.jpg'); await testFile.writeAsString('test content'); - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.active, name: 'Updated Diet', duration: 14, details: 'Updated details', image: testFile); @@ -167,7 +173,7 @@ void main() { test('updateDiet - обновляет черновик диеты (draft)', () async { const dietId = 1; - when(mockService.updateDrafted(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updateDrafted(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.draft, name: 'Updated Diet'); @@ -177,7 +183,7 @@ void main() { test('updateDiet - обновляет персональную диету (personal)', () async { const dietId = 1; - when(mockService.updatePersonal(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePersonal(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.personal, name: 'Updated Diet'); @@ -196,7 +202,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], @@ -236,13 +242,13 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], ]; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -257,7 +263,7 @@ void main() { test('updateDiet - обновляет диету без image', () async { const dietId = 1; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -273,7 +279,7 @@ void main() { test('updateDiet - обновляет диету только с типами', () async { const dietId = 1; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -299,13 +305,13 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], ]; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, diff --git a/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart b/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart index 66526ea4..ef4abc80 100644 --- a/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart +++ b/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart @@ -269,11 +269,11 @@ void main() { when(mockCreateDietRepository.updateDiet( dietId: 1, state: DietState.active, - )).thenAnswer((_) async => {}); + )).thenAnswer((_) async => 201); final result = await myDietsCubit!.publishDiet(1); - expect(result, true); + expect(result, 201); verify(mockCreateDietRepository.updateDiet(dietId: 1, state: DietState.active)).called(1); }); @@ -290,7 +290,7 @@ void main() { final result = await myDietsCubit!.publishDiet(1); - expect(result, false); + expect(result, 0); }); test('unpublishDiet - снимает диету с публикации', () async { @@ -302,7 +302,7 @@ void main() { when(mockCreateDietRepository.updateDiet( dietId: 1, state: DietState.draft, - )).thenAnswer((_) async => {}); + )).thenAnswer((_) async => 200); final result = await myDietsCubit!.unpublishDiet(1); diff --git a/test/features/my_diets/presentation/widgets/basic_widget_test.dart b/test/features/my_diets/presentation/widgets/basic_widget_test.dart index ef674d0f..9afb6818 100644 --- a/test/features/my_diets/presentation/widgets/basic_widget_test.dart +++ b/test/features/my_diets/presentation/widgets/basic_widget_test.dart @@ -106,7 +106,7 @@ void main() { // Assert expect(find.byType(MyDietsTab), findsOneWidget); - expect(find.byType(AnimatedOptionButton), findsNWidgets(2)); + expect(find.byType(AnimatedOptionButton), findsNWidgets(3)); }); testWidgets('should handle tab selection in MyDietsTab', (WidgetTester tester) async { @@ -119,7 +119,7 @@ void main() { // Act - Find and tap the second tab final tabButtons = find.byType(AnimatedOptionButton); - expect(tabButtons, findsNWidgets(2)); + expect(tabButtons, findsNWidgets(3)); // Tap the second tab (index 1) await tester.tap(tabButtons.at(1)); @@ -221,7 +221,7 @@ void main() { // Assert expect(find.byType(MyDietsTab), findsOneWidget); - expect(find.byType(AnimatedOptionButton), findsNWidgets(2)); + expect(find.byType(AnimatedOptionButton), findsNWidgets(3)); }); testWidgets('should handle MyDietsTab additionalOnTap callback', (WidgetTester tester) async { diff --git a/test/features/my_diets/repositories/my_diets_repository_test.dart b/test/features/my_diets/repositories/my_diets_repository_test.dart index c31bb6aa..b5a23bb5 100644 --- a/test/features/my_diets/repositories/my_diets_repository_test.dart +++ b/test/features/my_diets/repositories/my_diets_repository_test.dart @@ -103,7 +103,7 @@ void main() { dietData.first['name'] = 'Another Diet'; dietData.first['image'] = 'https://someurl.com/img2.jpg'; dietData.first['duration'] = duration; - dietData.first['state'] = 'draft'; + dietData.first['state'] = 'drafted'; dietData.first['type'] = {'light': false, 'veg': true, 'carb': false, 'meat': false}; dietData.first['description_md'] = ''; dietData.first['author'] = {'author_slug': 'author-slug', 'author_name': 'Author Name', 'author_profile_icon': '', 'author_is_me': true, 'is_entity_subscribed_by_me': null, 'author_place_in_rating': null}; diff --git a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart index e57b7601..32f67e95 100644 --- a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart +++ b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart @@ -182,5 +182,34 @@ void main() { expect(find.byType(AboutPlanScreen), findsOneWidget); }); + + testWidgets('renders with navigateToComments true', (tester) async { + final planCubit = MockAboutPlanCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null); + + when(planCubit.state).thenReturn(state); + when(planCubit.stream).thenAnswer((_) => const Stream.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(planCubit, commentsCubit); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: true, index: 1))); + await tester.pump(); + await tester.pump(); + }); + + expect(find.byType(AboutPlanScreen), findsOneWidget); + expect(find.text('Test Plan'), findsOneWidget); + }); }); } diff --git a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart index 3008d3b3..9bc22016 100644 --- a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart +++ b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart @@ -193,5 +193,36 @@ void main() { expect(find.byType(AboutRecipeScreen), findsOneWidget); }); + + testWidgets('renders with navigateToComments true', (tester) async { + final recipeCubit = MockAboutRecipeCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final state = AboutRecipeState.loaded(recipe: testRecipe); + + when(recipeCubit.state).thenReturn(state); + when(recipeCubit.stream).thenAnswer((_) => const Stream.empty()); + when(recipeCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + when(commentsCubit.setReplyId(any)).thenReturn(null); + + registerMocks(recipeCubit, commentsCubit); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: 2, navigateToComments: true)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + }); + + expect(find.byType(AboutRecipeScreen), findsOneWidget); + }); }); } diff --git a/test/fixtures/create_diet/nutrition_response.json b/test/fixtures/create_diet/nutrition_response.json index 6dd2119e..61bf7807 100644 --- a/test/fixtures/create_diet/nutrition_response.json +++ b/test/fixtures/create_diet/nutrition_response.json @@ -3,11 +3,58 @@ "1": { "0": { "name": "Авокадо", - "portion": "grams", + "portion": "gr", "quantity": 100, - "product_nutritional_value_id": "501" + "product_nutritional_value_id": 501, + "nutrition": { + "energy": 160.0, + "proteins": 2.0, + "fats": 14.0, + "carbohydrates": 9.0 + } + }, + "1": { + "name": "Лосось", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 502, + "nutrition": { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + } + }, + "2": { + "0": { + "name": "Овсянка", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 503, + "nutrition": { + "energy": 195.0, + "proteins": 6.75, + "fats": 3.0, + "carbohydrates": 33.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 504, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } } } } } - diff --git a/test/fixtures/diets/diet_response.json b/test/fixtures/diets/diet_response.json index d4d770a9..41b52101 100644 --- a/test/fixtures/diets/diet_response.json +++ b/test/fixtures/diets/diet_response.json @@ -12,7 +12,66 @@ "meat": false }, "description_md": "Test details", - "daily_schedule": {}, + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Овсянка", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 101, + "nutrition": { + "energy": 195.0, + "proteins": 6.75, + "fats": 3.0, + "carbohydrates": 33.0 + } + }, + "1": { + "name": "Бананы", + "portion": "gr", + "quantity": 120, + "product_nutritional_value_id": 102, + "nutrition": { + "energy": 107.0, + "proteins": 1.3, + "fats": 0.4, + "carbohydrates": 27.0 + } + } + }, + "2": { + "0": { + "name": "Куриная грудка", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 201, + "nutrition": { + "energy": 330.0, + "proteins": 62.0, + "fats": 7.2, + "carbohydrates": 0.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 301, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + } + } + } + }, "author": { "author_slug": "author", "author_name": "Author Name", @@ -26,6 +85,18 @@ "total_comments": 5, "was_liked_by_me": false, "is_favorite": false, - "daily_nutrition": [] + "daily_nutrition": [ + { + "energy": 864.0, + "proteins": 101.05, + "fats": 19.6, + "carbohydrates": 63.4 + }, + { + "energy": 532.0, + "proteins": 93.3, + "fats": 16.2, + "carbohydrates": 30.4 + } + ] } - diff --git a/test/fixtures/doubles/create_diet_doubles.dart b/test/fixtures/doubles/create_diet_doubles.dart index 7cf7b3b9..52645ae5 100644 --- a/test/fixtures/doubles/create_diet_doubles.dart +++ b/test/fixtures/doubles/create_diet_doubles.dart @@ -53,11 +53,15 @@ class FixtureCreateDietRepository extends CreateDietRepository { portion = Portion.grams; } + final pnvid = portionData['product_nutritional_value_id']; + final nutritionJson = portionData['nutrition'] as Map?; + final dailyNutrition = nutritionJson != null ? DailyNutrition.fromJson(nutritionJson) : null; mealMap[portionIndex] = DailySchedule( name: portionData['name'] as String, portion: portion, - quantity: portionData['quantity'] as int, - productNutritionalValueId: portionData['product_nutritional_value_id'] as String?, + quantity: portionData['quantity'] is int ? portionData['quantity'] as int : int.parse(portionData['quantity'].toString()), + productNutritionalValueId: pnvid == null ? null : (pnvid is int ? pnvid : int.tryParse(pnvid.toString())), + dailyNutrition: dailyNutrition, ); } @@ -75,13 +79,13 @@ class FixtureCreateDietRepository extends CreateDietRepository { required String name, required int duration, required DietState state, - required String meatType, - required String carbType, - required String vegType, - required String lightType, - required String details, - required File image, - required List>> dailySchedule, + required String? meatType, + required String? carbType, + required String? vegType, + required String? lightType, + required String? details, + required File? image, + required List>>? dailySchedule, }) async { createRequests.add({ 'name': name, @@ -100,7 +104,7 @@ class FixtureCreateDietRepository extends CreateDietRepository { } @override - Future updateDiet({ + Future updateDiet({ required int dietId, required DietState state, String? name, @@ -127,6 +131,7 @@ class FixtureCreateDietRepository extends CreateDietRepository { if (lightType != null) 'light': lightType == '1', }, }); + return 201; } } @@ -162,17 +167,17 @@ class _NoopCreateDietService implements CreateDietService { } @override - Future updateDrafted({required int diet, required FormData formData}) { + Future updateDrafted({required int diet, required FormData formData}) { throw UnimplementedError(); } @override - Future updatePersonal({required int diet, required FormData formData}) { + Future updatePersonal({required int diet, required FormData formData}) { throw UnimplementedError(); } @override - Future updatePublished({required int diet, required FormData formData}) { + Future updatePublished({required int diet, required FormData formData}) { throw UnimplementedError(); } } diff --git a/test/fixtures/feed/diets_pagination_response.json b/test/fixtures/feed/diets_pagination_response.json index a15de8fb..8dd97a36 100644 --- a/test/fixtures/feed/diets_pagination_response.json +++ b/test/fixtures/feed/diets_pagination_response.json @@ -14,7 +14,54 @@ "meat": true }, "description_md": "Keto diet description", - "daily_schedule": {}, + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Яйца вареные", + "portion": "pcs", + "quantity": 2, + "product_nutritional_value_id": 401, + "nutrition": { + "energy": 155.0, + "proteins": 12.6, + "fats": 10.6, + "carbohydrates": 1.1 + } + } + }, + "2": { + "0": { + "name": "Авокадо", + "portion": "gr", + "quantity": 100, + "product_nutritional_value_id": 501, + "nutrition": { + "energy": 160.0, + "proteins": 2.0, + "fats": 14.0, + "carbohydrates": 9.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Лосось", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 502, + "nutrition": { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + } + } + } + }, "author": { "author_slug": "nutritionist-1", "author_name": "Nutritionist Name", @@ -28,7 +75,20 @@ "total_comments": 25, "was_liked_by_me": false, "is_favorite": false, - "daily_nutrition": [] + "daily_nutrition": [ + { + "energy": 627.0, + "proteins": 44.6, + "fats": 44.1, + "carbohydrates": 10.1 + }, + { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + ] } ], "links": { @@ -50,4 +110,3 @@ "total_max_price": null } } - diff --git a/test/fixtures/my_diets/my_diets_pagination_response.json b/test/fixtures/my_diets/my_diets_pagination_response.json new file mode 100644 index 00000000..a23af562 --- /dev/null +++ b/test/fixtures/my_diets/my_diets_pagination_response.json @@ -0,0 +1,112 @@ +{ + "data": [ + { + "id": 1, + "slug": "my-keto-diet", + "name": "My Keto Diet", + "image": "https://example.com/my-keto.jpg", + "duration": 14, + "state": "drafted", + "type": { + "light": true, + "veg": true, + "carb": false, + "meat": true + }, + "description_md": "My drafted keto diet for two weeks", + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Омлет", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 601, + "nutrition": { + "energy": 318.0, + "proteins": 21.2, + "fats": 24.2, + "carbohydrates": 2.0 + } + } + }, + "2": { + "0": { + "name": "Салат с курицей", + "portion": "gr", + "quantity": 300, + "product_nutritional_value_id": 602, + "nutrition": { + "energy": 285.0, + "proteins": 35.0, + "fats": 12.0, + "carbohydrates": 8.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог 5%", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 603, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + } + } + } + }, + "author": { + "author_slug": "me", + "author_name": "My Name", + "author_profile_icon": "https://example.com/me.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": null, + "author_place_in_rating": null + }, + "total_likes": 0, + "total_views": 0, + "total_comments": 0, + "was_liked_by_me": false, + "is_favorite": false, + "daily_nutrition": [ + { + "energy": 603.0, + "proteins": 56.2, + "fats": 36.2, + "carbohydrates": 10.0 + }, + { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + ] + } + ], + "links": { + "first": "http://localhost/api/my-diets?page=1", + "last": "http://localhost/api/my-diets?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [], + "path": "http://localhost/api/my-diets", + "per_page": 10, + "to": 1, + "total": 1, + "total_min_price": null, + "total_max_price": null + } +}