Merge pull request 'feat(my_diets): draft diets fixes' (#901) from misha/eatvio_app:main into main

Reviewed-on: eatvio/eatvio_app#901
Reviewed-by: mso7nueh <mso7nueh@mail.ru>
This commit is contained in:
2026-02-24 07:52:31 +00:00
34 changed files with 1380 additions and 493 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -71,16 +71,16 @@ class OtherRoutes {
try {
final profileRepository = sl<ProfileRepository>();
final profile = await profileRepository.getMyProfile();
final firebaseApi = sl<FirebaseApi>();
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<String, dynamic>?;
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<String, dynamic>;
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()),

View File

@@ -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)],
),
],
),

View File

@@ -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),
],
);
}

View File

@@ -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<CreateDietCubit>()..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<CreateDietScreenContent> createState() => _CreateDietScreenContentState();
}
@@ -82,12 +82,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
}
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<CreateDietScreenContent> {
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<CreateDietScreenContent> {
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<CreateDietCubit>().getDietForPreview();
if (diet == null) {
// ignore: use_build_context_synchronously
_scrollToFirstError(context.read<CreateDietCubit>().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<CreateDietCubit>().setDietState(DietState.active);
} else {
context.read<CreateDietCubit>().setDietState(DietState.draft);
}
final result = await context.read<CreateDietCubit>().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<CreateDietCubit>().setDietState(DietState.draft);
}
final result = await context.read<CreateDietCubit>().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<CreateDietCubit>().getDietForPreview();
if (diet == null) {
if (!context.mounted) return;
_scrollToFirstError(context.read<CreateDietCubit>().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<CreateDietCubit>().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<CreateDietCubit>().createDiet();
if (result == 'validation') {
if (!context.mounted) return;
_scrollToFirstError(context.read<CreateDietCubit>().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<CreateDietCubit>().createDiet();
if (result == 'validation') {
// ignore: use_build_context_synchronously
_scrollToFirstError(context.read<CreateDietCubit>().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),
],

View File

@@ -32,7 +32,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
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<List<List<DailySchedule>>> dailySchedule = List.generate(countOfDays, (index) => [[], [], [], [], []]);
for (final dayEntry in dailyScheduleWithNutrition.entries) {
@@ -49,6 +49,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
type['carb'] = diet.type.carb ?? false;
type['light'] = diet.type.light ?? false;
final appConfig = sl<AppConfig>();
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<CreateDietState> {
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<CreateDietState> {
}
}
static const int _maxImageBytesForBase64 = 5 * 1024 * 1024;
Future<Diet?> getDietForPreview() async {
if (!_isFromValid()) return null;
if (!_isFormValidForDraft()) return null;
final authorProfile = await profileRepository.getMyProfile();
final imageBytes = (await state.dietData.image?.readAsBytes()) ?? <int>[];
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<double>(0, (prev, element) {
return prev +
@@ -259,7 +272,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
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<CreateDietState> {
Future<String?> 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<CreateDietState> {
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<CreateDietState> {
}
Future<String?> 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<CreateDietState> {
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) {

View File

@@ -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)),
);
}

View File

@@ -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<int> 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<DietPreviewScreen> {
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<DietPreviewScreen> {
),
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<DietPreviewScreen> {
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<AppConfig>().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<DietPreviewScreen> {
},
itemCount: widget.diet.dailySchedule.length,
),
const MainBackgroundBouble(
padding: EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
SizedBox(height: 16),
],
),
),
],
),
),

View File

@@ -185,7 +185,7 @@ class _AddMealWidgetState extends State<AddMealWidget> {
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<AddMealWidget> {
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) {

View File

@@ -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<List<List<DailySchedule>>> dailySchedule,
required String? meatType,
required String? carbType,
required String? vegType,
required String? lightType,
required String? details,
required File? image,
required List<List<List<DailySchedule>>>? dailySchedule,
}) async {
final dietMap = <String, dynamic>{};
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<String, dynamic>).map((key, value) {
return MapEntry(int.parse(key), DailySchedule.fromJson(value as Map<String, dynamic>));
final portionMap = Map<String, dynamic>.from(value as Map<String, dynamic>);
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<void> updateDiet({
Future<int> 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;

View File

@@ -28,21 +28,21 @@ abstract class CreateDietService {
@POST('/api/diets/update/published/{diet}')
@MultiPart()
Future<void> updatePublished({
Future<HttpResponse> updatePublished({
@Path() required int diet,
@Body() required FormData formData,
});
@POST('/api/diets/update/drafted/{diet}')
@MultiPart()
Future<void> updateDrafted({
Future<HttpResponse> updateDrafted({
@Path() required int diet,
@Body() required FormData formData,
});
@POST('/api/diets/update/personalized/{diet}')
@MultiPart()
Future<void> updatePersonal({
Future<HttpResponse> updatePersonal({
@Path() required int diet,
@Body() required FormData formData,
});

View File

@@ -182,7 +182,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
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<AddPortionWidget> {
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) {

View File

@@ -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<int, Map<int, Map<int, DailySchedule>>> dailySchedule,
@JsonKey(name: "type", fromJson: parseDietTypeFromJson) required DietType type,
@JsonKey(name: "description_md", defaultValue: '') required String details,
@JsonKey(name: "daily_schedule", defaultValue: {}) required Map<int, Map<int, Map<int, DailySchedule>>> 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<String, dynamic> json) => _$DietFromJson(json);
}
DietType parseDietTypeFromJson(Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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

View File

@@ -248,7 +248,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
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,
),

View File

@@ -291,16 +291,16 @@ class MyDietsCubit extends Cubit<MyDietsState> {
}
}
Future<bool> publishDiet(int dietId) async {
Future<int> 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;
}
}

View File

@@ -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<MyDietsCubit>().loadCurrentDiets(query: query == '' ? null : query);
},
);
EasyDebounce.debounce('searchDiets', const Duration(milliseconds: 500), () {
context.read<MyDietsCubit>().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<MyDietsCubit, MyDietsState>(
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<MyDietsCubit>().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<MyDietsCubit>().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<MyDietsCubit>(),
),
),
child: FilterBadge(filterBottomSheet: DietFilterBottomSheet(myDietsCubit: context.read<MyDietsCubit>())),
),
],
);

View File

@@ -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<MyDietWidget> {
Future<void> _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<MyDietsCubit>().loadAllDiets();
}
void _suggestDiet(Diet diet) {
GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: diet.id);
}
Future<void> _shareDiet(int dietId) async {
await SharePlus.instance.share(ShareParams(uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/$dietId')));
}
Future<void> _publishDiet(Diet diet, BuildContext context) async {
final statusCode = await context.read<MyDietsCubit>().publishDiet(widget.diet.id);
switch (statusCode) {
case 201:
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().loadAllDiets();
break;
case 422:
showErrorToast('Пожалуйста, заполните все обязательные поля для публикации');
break;
default:
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
}
}
void _unpublishDiet(Diet diet, BuildContext context) async {
final result = await context.read<MyDietsCubit>().unpublishDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
height: 250,
child: DietDraftedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().loadAllDiets();
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -42,28 +112,34 @@ class _MyDietWidgetState extends State<MyDietWidget> {
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<AppConfig>().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<AppConfig>().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<MyDietWidget> {
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<MyDietWidget> {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
// Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных)
Row(
children: [
// Кнопка удаления (слева)
CustomOutlinedButton(
onPressed: () async {
final result = await context.read<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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<AppConfig>().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<MyDietWidget> {
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<MyDietWidget> {
);
}
}

View File

@@ -21,6 +21,7 @@ class _MyDietsTabState extends State<MyDietsTab> {
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<MyDietsTab> {
Widget build(BuildContext context) {
return BlocBuilder<MyDietsCubit, MyDietsState>(
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<MyDietsCubit>().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<MyDietsCubit>().changeSelectedStateIndex(index);
},
),
),
),
],
),
),
],

View File

@@ -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<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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);
});
});
});
}

View File

@@ -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));
});
});
}

View File

@@ -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<void>.value());
when(cubit.setInitialData(any)).thenAnswer((_) async => Future<void>.value());
sl.registerLazySingleton<CreateDietCubit>(() => 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<void>.value());
when(cubit.setInitialData(any)).thenAnswer((_) async => Future<void>.value());
sl.registerLazySingleton<CreateDietCubit>(() => cubit);
await tester.pumpWidget(wrap(const CreateDietScreenContent(), cubit));
await tester.pump();
expect(find.byType(CreateDietScreenContent), findsOneWidget);
});
});
}

View File

@@ -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<CreateDietRepository>(), MockSpec<ProfileRepository>(), MockSpec<GlossaryRepository>()])
@@ -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);

View File

@@ -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);
});
});
});
}

View File

@@ -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<dynamic> _okResponse() => HttpResponse(
null,
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
);
@GenerateNiceMocks([MockSpec<CreateDietService>()])
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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.value());
when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse());
await repository.updateDiet(
dietId: dietId,

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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};

View File

@@ -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<AboutPlanState>.empty());
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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);
});
});
}

View File

@@ -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<AboutRecipeState>.empty());
when(recipeCubit.close()).thenAnswer((_) async => Future<void>.value());
when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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);
});
});
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
]
}

View File

@@ -53,11 +53,15 @@ class FixtureCreateDietRepository extends CreateDietRepository {
portion = Portion.grams;
}
final pnvid = portionData['product_nutritional_value_id'];
final nutritionJson = portionData['nutrition'] as Map<String, dynamic>?;
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<List<List<DailySchedule>>> dailySchedule,
required String? meatType,
required String? carbType,
required String? vegType,
required String? lightType,
required String? details,
required File? image,
required List<List<List<DailySchedule>>>? dailySchedule,
}) async {
createRequests.add(<String, dynamic>{
'name': name,
@@ -100,7 +104,7 @@ class FixtureCreateDietRepository extends CreateDietRepository {
}
@override
Future<void> updateDiet({
Future<int> 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<void> updateDrafted({required int diet, required FormData formData}) {
Future<HttpResponse> updateDrafted({required int diet, required FormData formData}) {
throw UnimplementedError();
}
@override
Future<void> updatePersonal({required int diet, required FormData formData}) {
Future<HttpResponse> updatePersonal({required int diet, required FormData formData}) {
throw UnimplementedError();
}
@override
Future<void> updatePublished({required int diet, required FormData formData}) {
Future<HttpResponse> updatePublished({required int diet, required FormData formData}) {
throw UnimplementedError();
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}