Merge pull request 'feat(recipe): edit recipe' (#452) from mso7nueh/eatvio_app:Issue-329 into main

Reviewed-on: eatvio/eatvio_app#452
This commit is contained in:
mso7nueh
2025-05-19 12:41:24 +00:00
18 changed files with 328 additions and 147 deletions

View File

@@ -196,7 +196,13 @@ class AppRouter {
return AboutRecipeScreen(recipe: data['recipe'] as Recipe, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
},
),
GoRoute(path: RecipeCreateScreen.path, name: RecipeCreateScreen.name, builder: (context, state) => const RecipeCreateScreen()),
GoRoute(
path: RecipeCreateScreen.path,
name: RecipeCreateScreen.name,
builder: (context, state) => RecipeCreateScreen(
recipe: state.extra != null ? state.extra as Recipe : null,
),
),
GoRoute(path: '/profile-screen', name: 'profileScreen', builder: (context, state) => ProfileScreen(author: state.extra as Author)),
GoRoute(
path: '/subs-screen/:slug',
@@ -224,7 +230,10 @@ class AppRouter {
final data = state.extra as Map<String, dynamic>;
final chatId = data['chatId'] as String;
final sendInitialMessage = data['sendInitialMessage'] as bool?;
return ChatScreen(chatId: chatId, sendInitialMessage: sendInitialMessage,);
return ChatScreen(
chatId: chatId,
sendInitialMessage: sendInitialMessage,
);
},
),
GoRoute(

View File

@@ -1,5 +1,5 @@
String baseUrl = 'https://eatvio.ru';
// String baseUrl = 'https://dev2.eatvio.com';
// String baseUrl = 'https://eatvio.ru';
String baseUrl = 'https://dev2.eatvio.com';
const List<int> durationValues = [7, 14, 21];

View File

@@ -6,9 +6,10 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.dart';
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
@@ -26,6 +27,7 @@ class CustomPopup extends StatelessWidget {
final String? slug;
final int? id;
final FeedBlogPostModel? feedBlogPostModel;
final Recipe? recipe;
const CustomPopup({
super.key,
@@ -35,6 +37,7 @@ class CustomPopup extends StatelessWidget {
this.id,
this.slug,
this.feedBlogPostModel,
this.recipe,
});
@override
@@ -94,6 +97,8 @@ class CustomPopup extends StatelessWidget {
addItem('assets/icons/edit_paint.svg', 'Редактировать', () {
if (complainedContentType == ComplainedContent.posts) {
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
} else if (complainedContentType == ComplainedContent.recipe) {
GoRouter.of(context).pushNamed(RecipeCreateScreen.name, extra: recipe);
} else {
showInfoToast('В разработке');
return;
@@ -103,8 +108,6 @@ class CustomPopup extends StatelessWidget {
addItem('assets/icons/remove_from_public.svg', 'Снять с публикации', () async {
if (complainedContentType == ComplainedContent.diet) {
final result = await sl<MyDietsCubit>().unpublishDiet(id!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;
@@ -112,8 +115,6 @@ class CustomPopup extends StatelessWidget {
showSuccessToast('Диета перенесена в архив, обновите ленту');
} else if (complainedContentType == ComplainedContent.plan) {
final result = await sl<MyPlansCubit>().unpublish(slug!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;
@@ -125,8 +126,6 @@ class CustomPopup extends StatelessWidget {
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
if (complainedContentType == ComplainedContent.posts) {
final result = await sl<BlogPostCreateCubit>().deleteBlogPost(id!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;
@@ -134,8 +133,6 @@ class CustomPopup extends StatelessWidget {
showSuccessToast('Пост удален, обновите ленту');
} else if (complainedContentType == ComplainedContent.recipe) {
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;
@@ -143,8 +140,6 @@ class CustomPopup extends StatelessWidget {
showSuccessToast('Рецепт удален, обновите ленту');
} else if (complainedContentType == ComplainedContent.diet) {
final result = await sl<DietsCubit>().deleteDiet(id!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;
@@ -152,8 +147,6 @@ class CustomPopup extends StatelessWidget {
showSuccessToast('Диета удалена, обновите ленту');
} else if (complainedContentType == ComplainedContent.plan) {
final result = await sl<MyPlansCubit>().deletePlan(slug!);
if (!context.mounted) return;
context.read<FeedCubit>().loadFeedBlogPostList();
if (!result) {
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return;

View File

@@ -55,6 +55,27 @@ enum TypeOfRecipe {
}
}
static TypeOfRecipe? fromApiType(String value) {
switch (value) {
case 'first_meals':
return TypeOfRecipe.firstMeals;
case 'second_meals':
return TypeOfRecipe.secondMeals;
case 'salads':
return TypeOfRecipe.salads;
case 'snacks':
return TypeOfRecipe.snacks;
case 'sweets':
return TypeOfRecipe.sweets;
case 'drinks':
return TypeOfRecipe.drinks;
case 'other':
return TypeOfRecipe.other;
default:
return null;
}
}
static TypeOfRecipe? fromString(String value) {
switch (value) {
case 'Первые блюда':
@@ -80,6 +101,7 @@ enum TypeOfRecipe {
@freezed
class RecipeCreateEntity with _$RecipeCreateEntity {
const RecipeCreateEntity._();
const factory RecipeCreateEntity({
String? title,
TypeOfRecipe? typeOfRecipe,
@@ -94,7 +116,7 @@ class RecipeCreateEntity with _$RecipeCreateEntity {
String? videoUrl,
required List<Ingredient> ingredients,
required List<StepEntity> steps,
required List<String> tags, // tags for search
required List<String> tags,
}) = _RecipeCreateEntity;
RecipeModel toModel() => RecipeModel(
@@ -118,6 +140,7 @@ class RecipeCreateEntity with _$RecipeCreateEntity {
@freezed
class StepEntity with _$StepEntity {
const StepEntity._();
const factory StepEntity({
String? description,
File? image,

View File

@@ -1,17 +1,24 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart';
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as i;
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
class CreateRecipeCubit extends Cubit<CreateRecipeState> {
final RecipeRepository recipeRepository;
CreateRecipeCubit({required this.recipeRepository})
: super(
CreateRecipeCubit({
required this.recipeRepository,
}) : super(
const CreateRecipeState(
loading: true,
entity: RecipeCreateEntity(
typeOfRecipe: TypeOfRecipe.firstMeals,
ingredients: [],
@@ -38,7 +45,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
emit(state.copyWith.entity(steps: steps));
}
addIngredient(Ingredient entity) {
addIngredient(i.Ingredient entity) {
emit(
state.copyWith.entity(
ingredients: [
@@ -50,14 +57,14 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
removeIngredient(int index) {
List<Ingredient> ingredients = [...state.entity.ingredients];
List<i.Ingredient> ingredients = [...state.entity.ingredients];
ingredients.removeAt(index);
emit(state.copyWith.entity(ingredients: ingredients));
}
replaceIngredientPosition(int oldIndex, int newIndex) {
List<Ingredient> ingredients = [...state.entity.ingredients];
List<i.Ingredient> ingredients = [...state.entity.ingredients];
final item = ingredients.removeAt(oldIndex);
ingredients.insert(newIndex, item);
emit(state.copyWith.entity(ingredients: ingredients));
@@ -141,10 +148,10 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
emit(state.copyWith.entity(steps: steps));
}
Future<bool> publishRecipe() async {
Future<bool> publishRecipe([bool update = false, String? slug]) async {
try {
final model = state.entity.toModel();
await recipeRepository.publishRecipe(model);
await recipeRepository.publishRecipe(model, update, slug);
return true;
} catch (e, st) {
log(e.toString(), stackTrace: st);
@@ -152,6 +159,57 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
}
Future<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode == 200) {
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/${imageUrl.split('/').last}';
final file = File(filePath);
return await file.writeAsBytes(response.bodyBytes);
} else {
throw Exception('Не удалось загрузить изображение');
}
}
disableLoading() {
emit(state.copyWith(loading: false));
}
Future<void> recipeToEntity(Recipe recipe) async {
final file = await downloadImageAsFile('$baseUrl/storage/${recipe.image}');
final List<StepEntity> steps = await Future.wait(
recipe.steps!.map(
(e) async => StepEntity(
description: e.description,
image: await downloadImageAsFile('$baseUrl/storage/${e.image}'),
),
),
);
emit(
CreateRecipeState(
entity: RecipeCreateEntity(
title: recipe.title,
typeOfRecipe: TypeOfRecipe.fromApiType(recipe.type!),
countOfPortions: recipe.servings,
energyInOnePortion: recipe.calories,
totalCookHours: recipe.cookingTime ~/ 60,
totalCookMinutes: recipe.cookingTime % 60,
activeCookHours: recipe.activeCookingTime ~/ 60,
activeCookMinutes: recipe.activeCookingTime % 60,
description: recipe.description,
image: file,
videoUrl: recipe.videoUrl,
ingredients: recipe.ingredients!.map((e) => i.Ingredient(name: e.name, unit: e.unit!, portion: e.portion)).toList(),
steps: steps,
tags: recipe.tags.map((e) => (e.name)).toList(),
),
loading: false,
),
);
}
bool validateAll() {
final isTitleValid = validateTitle();
final isDescValid = validateDescription();
@@ -239,4 +297,13 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
return true;
}
}
Future<bool> deleteRecipe(String slug) async {
try {
await recipeRepository.deleteRecipe(slug);
return true;
} catch (e) {
return false;
}
}
}

View File

@@ -7,6 +7,7 @@ part 'create_recipe_state.freezed.dart';
class CreateRecipeState with _$CreateRecipeState {
const factory CreateRecipeState({
required RecipeCreateEntity entity,
required bool loading,
String? titleValidationError,
String? descriptionValidationError,
String? cookingTimeValidationError,
@@ -15,5 +16,5 @@ class CreateRecipeState with _$CreateRecipeState {
String? energyPerServingValidationError,
String? imageValidationError,
}) = _CreateRecipeState;
}

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
@@ -8,13 +9,19 @@ import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recip
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class RecipeCreateScreen extends StatefulWidget {
const RecipeCreateScreen({super.key});
final Recipe? recipe;
const RecipeCreateScreen({
super.key,
this.recipe,
});
static const name = 'recipeCreateScreen';
static const path = '/recipe-create-screen';
@@ -24,85 +31,115 @@ class RecipeCreateScreen extends StatefulWidget {
}
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = sl<CreateRecipeCubit>();
if (widget.recipe != null) {
createRecipeCubit.recipeToEntity(widget.recipe!);
} else {
createRecipeCubit.disableLoading();
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<CreateRecipeCubit>(
create: (BuildContext context) => sl<CreateRecipeCubit>(),
child: Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: const Text('Добавление рецепта'),
centerTitle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: Column(
spacing: 16,
children: [
const InfoAboutRecipeWidget(),
const IngredientsWidget(),
const StepsWidget(),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe();
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const Text(
'Опубликовать',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
},
create: (BuildContext context) => createRecipeCubit,
child: BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (BuildContext context, CreateRecipeState state) {
if (state.loading) {
return const Scaffold(
body: Loader(),
);
} else {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
centerTitle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
),
],
),
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: Column(
spacing: 16,
children: [
InfoAboutRecipeWidget(
createRecipeCubit: createRecipeCubit,
),
const IngredientsWidget(),
StepsWidget(
createRecipeCubit: createRecipeCubit,
),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
},
),
],
),
),
);
}
},
),
);
}

View File

@@ -14,7 +14,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InfoAboutRecipeWidget extends StatefulWidget {
const InfoAboutRecipeWidget({super.key});
final CreateRecipeCubit createRecipeCubit;
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
@override
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
@@ -23,13 +25,19 @@ class InfoAboutRecipeWidget extends StatefulWidget {
class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
final List<SelectionPopupModel> items = TypeOfRecipe.values.map((e) => SelectionPopupModel(title: e.toString())).toList();
late SelectionPopupModel selectedType;
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
selectedType = items.first;
createRecipeCubit = context.read<CreateRecipeCubit>();
if (widget.createRecipeCubit.state.entity.typeOfRecipe != null) {
for (int i = 0; i < items.length; i++) {
if (items[i].title == widget.createRecipeCubit.state.entity.typeOfRecipe!.toString()) {
selectedType = items[i];
}
}
} else {
selectedType = items.first;
}
}
@override
@@ -44,6 +52,7 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
const Text('Название'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
builder: (context, state) {
return Column(
@@ -51,10 +60,11 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
CustomTextFormField(
hintText: 'Например: тар-тар из говядины',
hasError: state.titleValidationError != null,
initialValue: state.entity.title,
onChanged: (value) {
createRecipeCubit.replaceTitle(value);
widget.createRecipeCubit.replaceTitle(value);
if (state.titleValidationError != null) {
createRecipeCubit.validateTitle();
widget.createRecipeCubit.validateTitle();
}
},
),
@@ -78,7 +88,7 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
setState(() {
selectedType = newSelectedType;
});
createRecipeCubit.changeType(
widget.createRecipeCubit.changeType(
TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other,
);
},
@@ -100,12 +110,13 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
CustomTextFormField(
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
minLines: 5,
initialValue: state.entity.description,
maxLines: 5,
hasError: state.descriptionValidationError != null,
onChanged: (value) {
createRecipeCubit.replaceDescription(value);
widget.createRecipeCubit.replaceDescription(value);
if (state.descriptionValidationError != null) {
createRecipeCubit.validateDescription();
widget.createRecipeCubit.validateDescription();
}
},
),
@@ -148,8 +159,9 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Прикрепите ссылку на видео',
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
onChanged: (value) {
createRecipeCubit.replaceVideoUrl(value);
widget.createRecipeCubit.replaceVideoUrl(value);
},
),
],

View File

@@ -14,6 +14,7 @@ class PortionWidget extends StatefulWidget {
class _PortionWidgetState extends State<PortionWidget> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
@@ -45,6 +46,7 @@ class _PortionWidgetState extends State<PortionWidget> {
CustomTextFormField(
hintText: '1',
hasError: state.servingsValidationError != null,
initialValue: state.entity.countOfPortions?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
createRecipeCubit.replacePortionsCount(int.parse(value));
@@ -77,6 +79,7 @@ class _PortionWidgetState extends State<PortionWidget> {
CustomTextFormField(
hintText: '100',
hasError: state.energyPerServingValidationError != null,
initialValue: state.entity.energyInOnePortion?.toString(),
suffixText: 'Ккал',
textInputType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (value) {

View File

@@ -3,16 +3,16 @@ import 'dart:io';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
class StepImageWidget extends StatefulWidget {
final CreateRecipeCubit createRecipeCubit;
final int index;
const StepImageWidget({
super.key,
required this.index,
required this.index, required this.createRecipeCubit,
});
@override
@@ -21,12 +21,15 @@ class StepImageWidget extends StatefulWidget {
class _StepImageWidgetState extends State<StepImageWidget> {
XFile? image;
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
if (widget.createRecipeCubit.state.entity.steps.length > widget.index) {
if (widget.createRecipeCubit.state.entity.steps[widget.index].image != null) {
image = XFile(widget.createRecipeCubit.state.entity.steps[widget.index].image!.path);
}
}
}
@override
@@ -56,7 +59,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
setState(() {
image = null;
});
createRecipeCubit.replaceImageInStep(null, widget.index);
widget.createRecipeCubit.replaceImageInStep(null, widget.index);
},
child: Container(
padding: const EdgeInsets.all(4),
@@ -84,7 +87,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
image = await picker.pickImage(source: ImageSource.camera);
setState(() {});
if (image != null) {
createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
widget.createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
}
if (!context.mounted) return;
Navigator.of(context).pop();
@@ -103,7 +106,7 @@ class _StepImageWidgetState extends State<StepImageWidget> {
image = await picker.pickImage(source: ImageSource.gallery);
setState(() {});
if (image != null) {
createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
widget.createRecipeCubit.replaceImageInStep(File(image!.path), widget.index);
}
if (!context.mounted) return;
Navigator.of(context).pop();

View File

@@ -7,24 +7,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class StepsWidget extends StatefulWidget {
const StepsWidget({super.key});
final CreateRecipeCubit createRecipeCubit;
const StepsWidget({super.key, required this.createRecipeCubit});
@override
State<StepsWidget> createState() => _StepsWidgetState();
}
class _StepsWidgetState extends State<StepsWidget> {
late final CreateRecipeCubit createRecipeCubit;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
builder: (BuildContext context, CreateRecipeState state) {
return Material(
shape: RoundedRectangleBorder(
@@ -83,10 +78,11 @@ class _StepsWidgetState extends State<StepsWidget> {
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Например: подготовьте ингредиенты',
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
maxLines: 5,
minLines: 3,
onChanged: (value) {
createRecipeCubit.replaceDescriptionInStep(value, index);
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
},
),
const SizedBox(height: 12),
@@ -94,6 +90,7 @@ class _StepsWidgetState extends State<StepsWidget> {
const SizedBox(height: 4),
StepImageWidget(
index: index,
createRecipeCubit: widget.createRecipeCubit,
),
],
),

View File

@@ -1,6 +1,8 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class TagsWidget extends StatefulWidget {
const TagsWidget({super.key});
@@ -10,8 +12,16 @@ class TagsWidget extends StatefulWidget {
}
class _TagsWidgetState extends State<TagsWidget> {
late final CreateRecipeCubit createRecipeCubit;
final tagController = TextEditingController();
final List<String> tags = [];
late final List<String> tags;
@override
void initState() {
super.initState();
createRecipeCubit = context.read<CreateRecipeCubit>();
tags = createRecipeCubit.state.entity.tags;
}
@override
Widget build(BuildContext context) {

View File

@@ -71,6 +71,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
hintText: 'Часов',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookHours?.toString(),
onChanged: (value) {
final hours = int.tryParse(value);
if (hours == null) return;
@@ -97,6 +98,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
hintText: 'Минут',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookMinutes?.toString(),
onChanged: (value) {
final minutes = int.tryParse(value);
if (minutes == null) return;
@@ -156,6 +158,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
CustomTextFormField(
hintText: 'Часов',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookHours?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
final hours = int.tryParse(value);
@@ -183,6 +186,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
CustomTextFormField(
hintText: 'Минут',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookMinutes?.toString(),
textInputType: TextInputType.number,
onChanged: (value) {
final minutes = int.tryParse(value);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
@@ -11,7 +10,6 @@ import 'package:eatvio_app/features/create_recipe/models/product.dart';
class RecipeRepository {
final CreateRecipeService recipeService;
RecipeRepository({required this.recipeService});
@@ -23,7 +21,7 @@ class RecipeRepository {
}
}
Future<void> publishRecipe(RecipeModel recipe) async {
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async {
try {
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
return;
@@ -72,15 +70,11 @@ class RecipeRepository {
};
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
final formData = FormData.fromMap(formMap);
await recipeService.publishRecipe(formData: formData);
} catch (e) {
rethrow;
}
}
Future<RecipeModel> editRecipe(RecipeModel recipe, String id) async {
try {
return await recipeService.editRecipe(jsonDecode(jsonEncode(recipe)), id);
if (update) {
await recipeService.updateRecipe(slug: slug!, formData: formData);
} else {
await recipeService.publishRecipe(formData: formData);
}
} catch (e) {
rethrow;
}
@@ -100,5 +94,13 @@ class RecipeRepository {
} catch (e) {
rethrow;
}
}
}
Future<void> deleteRecipe(String slug) async {
try {
return await recipeService.deleteRecipe(slug: slug);
} catch (e) {
rethrow;
}
}
}

View File

@@ -37,11 +37,18 @@ abstract class CreateRecipeService {
@Body() required FormData formData,
});
@POST('/api/recipes/update/{id}')
Future<RecipeModel> editRecipe(@Part() Map<String, Object?> recipe, String id);
@GET('/api/recipes/tags')
Future<RecipeTagModel> getTags();
@DELETE('/api/recipes/delete/{slug}')
Future<void> deleteRecipe({
@Path() required String slug,
});
}
@POST('/api/recipes/update/{slug}')
@MultiPart()
Future<void> updateRecipe({
@Path() required String slug,
@Body() required FormData formData,
});
}

View File

@@ -25,14 +25,23 @@ class Recipe with _$Recipe {
@JsonKey(name: 'was_liked_by_me') required bool wasLikedByMe,
@JsonKey(name: 'is_favorite') required bool isFavorite,
@JsonKey(name: 'energy_per_serving') required double calories,
int? servings,
@JsonKey(name: 'total_likes') required int likesCount,
@JsonKey(name: 'total_views') required int viewsCount,
required List<Tag> tags,
required Author author,
}) = _Recipes;
factory Recipe.fromJson(Map<String, Object?> json) => _$RecipeFromJson(json);
}
@freezed
class Tag with _$Tag {
const factory Tag({required String name}) = _Tag;
factory Tag.fromJson(Map<String, Object?> json) => _$TagFromJson(json);
}
@freezed
class Ingredient with _$Ingredient {
const factory Ingredient({required String name, String? unit, required String portion}) = _Ingredient;

View File

@@ -127,6 +127,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
path: 'recipe/${widget.recipe.slug}',
slug: widget.recipe.slug,
complainedContentType: ComplainedContent.recipe,
recipe: widget.recipe,
),
),
),

View File

@@ -218,6 +218,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],
@@ -401,6 +402,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],
@@ -425,6 +427,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
path: 'recipe/${widget.recipe.slug}',
complainedContentType: ComplainedContent.recipe,
slug: widget.recipe.slug,
recipe: widget.recipe,
),
const SizedBox(width: 16),
],