feat(my_diets): fixed draft, active and personal diets creation, updating

This commit is contained in:
misha
2026-02-09 10:39:51 +03:00
parent 400cc004ef
commit c91e83adaf
12 changed files with 221 additions and 110 deletions

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);
@@ -163,15 +163,16 @@ class OtherRoutes {
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

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

@@ -32,21 +32,21 @@ class CreateDietScreen extends StatelessWidget {
const CreateDietScreen({super.key, this.diet, this.isEditing});
final Diet? diet;
final bool? isEditing;
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();
}
@@ -88,7 +88,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),
);
});
}
@@ -182,14 +182,21 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
children: [
CustomOutlinedButton(
onPressed: () async {
context.read<CreateDietCubit>().setDietState(DietState.draft);
if (widget.diet != null) {
if (widget.diet != null && widget.isEditing == true) {
if (state.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}');
}
} 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;
@@ -197,15 +204,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
}
}
},
// onPressed: state.isDietPublishing
// ? () {}
// : () async {
// final diet = await context.read<CreateDietCubit>().getDietForPreview();
// if (diet == null) return;
// if (!context.mounted) return;
// GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet);
// },
text: "В черновик",
text: state.dietState == DietState.draft && widget.isEditing == true ? s.publish : "В черновик",
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
buttonStyle: ButtonStyle(
@@ -221,7 +220,9 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
final diet = await context.read<CreateDietCubit>().getDietForPreview();
if (diet == null) return;
if (!context.mounted) return;
GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet);
GoRouter.of(
context,
).pushNamed(DietPreviewScreen.name, extra: {'diet': diet, 'isImageInBase64': state.dietData.imageUrl == null});
},
text: s.preview,
height: 52,
@@ -237,13 +238,21 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
else
CustomOutlinedButton(
onPressed: () async {
if (widget.diet != null) {
final result = await context.read<CreateDietCubit>().updateDiet(
widget.diet!.id,
);
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();
@@ -253,7 +262,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
}
}
},
text: widget.diet != null ? s.save : s.publish,
text: widget.diet != null && widget.isEditing == true ? s.save : s.publish,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(

View File

@@ -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,8 +63,7 @@ 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)));
@@ -196,7 +201,6 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
Future<Diet?> getDietForPreview() async {
if (!_isFromValid()) return null;
final authorProfile = await profileRepository.getMyProfile();
final imageBytes = (await state.dietData.image?.readAsBytes()) ?? <int>[];
String image = base64.encode(imageBytes);
@@ -305,7 +309,10 @@ 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';
}
@@ -324,12 +331,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
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 ?? [],
);
@@ -343,7 +350,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
Future<String?> updateDiet(int dietId) async {
// if (!_isFromValid()) return 'error';
if ([DietState.active, DietState.personal].contains(state.dietState) && !_isFromValid()) {
return 'error';
} else if (!_isFormValidForDraft()) {
return 'error';
}
try {
File? image;
if (state.dietData.image != null) {
@@ -359,7 +371,7 @@ 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,
);
return null;
@@ -369,6 +381,24 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
}
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

@@ -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);
}
@@ -35,7 +39,7 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
@override
void initState() {
super.initState();
if (widget.diet.image != null) {
if (widget.diet.image != null && widget.isImageInBase64) {
imageData = base64.decode(widget.diet.image!);
}
}
@@ -109,6 +113,54 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
),
),
],
)
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(

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

@@ -11,44 +11,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,
}) {
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);
switch (state) {
case DietState.active:
return createDietService.createPublishedDiet(formData: formData);
@@ -133,12 +145,7 @@ 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);

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

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

View File

@@ -234,7 +234,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
2 => 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

@@ -107,7 +107,7 @@ class MyDietsScreenContent extends StatelessWidget {
),
),
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),
],
),

View File

@@ -251,7 +251,7 @@ class _MyDietWidgetState extends State<MyDietWidget> {
Expanded(
child: CustomOutlinedButton(
onPressed: () async {
GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: widget.diet);
GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: {'diet': widget.diet, 'isImageInBase64': false});
},
text: 'Превью',
height: 52,
@@ -263,6 +263,22 @@ class _MyDietWidgetState extends State<MyDietWidget> {
),
),
),
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: '',
),
],
),
],