forked from eatvio/eatvio_app
Merge remote-tracking branch 'upstream/main' into issue-914
This commit is contained in:
@@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ class AboutOrderButton extends StatelessWidget {
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray50,
|
||||
color: const Color(0xFFFFFDD8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -29,7 +29,7 @@ class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
|
||||
if (widget.hasPersonalDiet) {
|
||||
startDate = [DateTime.now().add(const Duration(days: 2))];
|
||||
} else {
|
||||
startDate = [DateTime.now()];
|
||||
startDate = [DateTime.now().add(const Duration(days: 1))];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
final s = S.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray50,
|
||||
color: const Color(0xFFFFFDD8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
@@ -172,6 +172,38 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveAsDraft([bool update = false, String? slug]) async {
|
||||
try {
|
||||
final model = state.entity.toModel();
|
||||
await recipeRepository.saveAsDraft(model, update, slug);
|
||||
emit(state.copyWith(drafted: true));
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool validateForDraft() {
|
||||
final isTitleValid = validateTitle();
|
||||
_clearNonTitleValidationErrors();
|
||||
return isTitleValid;
|
||||
}
|
||||
|
||||
void _clearNonTitleValidationErrors() {
|
||||
emit(state.copyWith(
|
||||
descriptionValidationError: null,
|
||||
cookingTimeValidationError: null,
|
||||
activeTimeValidationError: null,
|
||||
servingsValidationError: null,
|
||||
energyPerServingValidationError: null,
|
||||
imageValidationError: null,
|
||||
stepsValidationError: null,
|
||||
ingredientsValidationError: null,
|
||||
));
|
||||
}
|
||||
|
||||
Future<File> downloadImageAsFile(String imageUrl) async {
|
||||
final response = await http.get(Uri.parse(imageUrl));
|
||||
|
||||
@@ -239,6 +271,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
final isActiveCookTimeValid = validateActiveCookTime();
|
||||
final isEnergyPerServingsValid = validateEnergyPerServing();
|
||||
|
||||
final isIngredientsValid = validateIngredients();
|
||||
final isStepsValid = validateSteps();
|
||||
|
||||
return isTitleValid &&
|
||||
@@ -248,8 +281,19 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
isCookTimeValid &&
|
||||
isActiveCookTimeValid &&
|
||||
isEnergyPerServingsValid &&
|
||||
isIngredientsValid &&
|
||||
isStepsValid;
|
||||
}
|
||||
|
||||
bool validateIngredients() {
|
||||
if (state.entity.ingredients.isEmpty) {
|
||||
emit(state.copyWith(ingredientsValidationError: 'Добавьте хотя бы один ингредиент'));
|
||||
return false;
|
||||
} else {
|
||||
emit(state.copyWith(ingredientsValidationError: null));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool validateTitle() {
|
||||
if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) {
|
||||
@@ -343,6 +387,10 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
|
||||
bool validateSteps() {
|
||||
bool isValid = true;
|
||||
if (state.entity.steps.isEmpty) {
|
||||
emit(state.copyWith(stepsValidationError: 'Добавьте хотя бы один шаг'));
|
||||
return false;
|
||||
}
|
||||
for (var step in state.entity.steps) {
|
||||
if (step.description == null || step.description!.trim().isEmpty) {
|
||||
isValid = false;
|
||||
|
||||
@@ -8,6 +8,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
|
||||
const factory CreateRecipeState({
|
||||
required RecipeCreateEntity entity,
|
||||
required bool loading,
|
||||
@Default(false) bool drafted,
|
||||
String? titleValidationError,
|
||||
String? descriptionValidationError,
|
||||
String? cookingTimeValidationError,
|
||||
@@ -16,6 +17,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
|
||||
String? energyPerServingValidationError,
|
||||
String? imageValidationError,
|
||||
String? stepsValidationError,
|
||||
String? ingredientsValidationError,
|
||||
}) = _CreateRecipeState;
|
||||
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
class RecipeCreateScreen extends StatefulWidget {
|
||||
final Recipe? recipe;
|
||||
final bool autoValidate;
|
||||
|
||||
const RecipeCreateScreen({super.key, this.recipe});
|
||||
const RecipeCreateScreen({super.key, this.recipe, this.autoValidate = false});
|
||||
|
||||
static const name = 'recipeCreateScreen';
|
||||
static const path = '/recipe-create-screen';
|
||||
@@ -40,6 +41,11 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
createRecipeCubit = sl<CreateRecipeCubit>();
|
||||
if (widget.recipe != null) {
|
||||
createRecipeCubit.recipeToEntity(widget.recipe!);
|
||||
if (widget.autoValidate) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
createRecipeCubit.validateAll();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
createRecipeCubit.disableLoading();
|
||||
}
|
||||
@@ -81,55 +87,107 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
builder: (context, state) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return _isRecipeBeingPublished
|
||||
? const Center(child: Loader())
|
||||
: InkWell(
|
||||
key: AppKeys.recipeCreateScreenPublishButton,
|
||||
if (_isRecipeBeingPublished) {
|
||||
return const Center(child: Loader());
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
InkWell(
|
||||
key: AppKeys.recipeCreateScreenPublishButton,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = true;
|
||||
});
|
||||
if (context.read<CreateRecipeCubit>().validateAll()) {
|
||||
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
|
||||
widget.recipe == null ? false : true,
|
||||
widget.recipe?.slug,
|
||||
);
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = false;
|
||||
});
|
||||
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}');
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
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 && widget.recipe?.state != 'drafted') ? 'Обновить' : 'Опубликовать',
|
||||
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.recipe == null || widget.recipe?.state == 'drafted')
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = true;
|
||||
});
|
||||
if (context.read<CreateRecipeCubit>().validateAll()) {
|
||||
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
|
||||
widget.recipe == null ? false : true,
|
||||
widget.recipe?.slug,
|
||||
);
|
||||
if (context.read<CreateRecipeCubit>().validateForDraft()) {
|
||||
final isSuccess = await context.read<CreateRecipeCubit>().saveAsDraft(
|
||||
widget.recipe == null ? false : true,
|
||||
widget.recipe?.slug,
|
||||
);
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = false;
|
||||
});
|
||||
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 ?? 'Рецепт'),
|
||||
);
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
|
||||
showErrorDialog(context, title: 'Не удалось сохранить черновик');
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = false;
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
_isRecipeBeingPublished = false;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: appTheme.blueA400),
|
||||
),
|
||||
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),
|
||||
widget.recipe != null ? 'Обновить черновик' : 'Сохранить как черновик',
|
||||
style: TextStyle(fontSize: 16, color: appTheme.blueA400, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
|
||||
title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)),
|
||||
leading: Icon(Icons.add_rounded, color: appTheme.blueA400),
|
||||
),
|
||||
if (state.ingredientsValidationError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Text(
|
||||
state.ingredientsValidationError!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
@@ -23,55 +24,94 @@ class RecipeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async {
|
||||
try {
|
||||
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
|
||||
return;
|
||||
}
|
||||
final image = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
|
||||
final multiPartFile = await MultipartFile.fromFile(image.path);
|
||||
|
||||
final ingredientsMap = recipe.ingredients?.map((ingredient) {
|
||||
if (ingredient != null) {
|
||||
return ingredient.toJson();
|
||||
}
|
||||
}).toList() ??
|
||||
[];
|
||||
|
||||
final stepsMap = [];
|
||||
for (final Step? step in recipe.steps ?? []) {
|
||||
if (step != null) {
|
||||
if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) {
|
||||
stepsMap.add({
|
||||
'description_md': step.description ?? "",
|
||||
});
|
||||
} else if (step.image != null && (step.image?.isNotEmpty ?? false)) {
|
||||
final image = await ImageCompressUtility.compressFile(File(step.image ?? ''));
|
||||
final multipartFile = await MultipartFile.fromFile(image.path);
|
||||
stepsMap.add({
|
||||
'description_md': step.description ?? "",
|
||||
'image': multipartFile,
|
||||
});
|
||||
}
|
||||
Future<List> _buildStepsMap(List<Step?>? steps) async {
|
||||
final stepsMap = [];
|
||||
for (final Step? step in steps ?? []) {
|
||||
if (step != null) {
|
||||
if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) {
|
||||
stepsMap.add({'description_md': step.description ?? ''});
|
||||
} else if (step.image != null && (step.image?.isNotEmpty ?? false)) {
|
||||
final compressed = await ImageCompressUtility.compressFile(File(step.image ?? ''));
|
||||
final multipartFile = await MultipartFile.fromFile(compressed.path);
|
||||
stepsMap.add({
|
||||
'description_md': step.description ?? '',
|
||||
'image': multipartFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
final formMap = {
|
||||
'type': recipe.type,
|
||||
'state': 'active',
|
||||
'title': recipe.title,
|
||||
'tags[]': recipe.tags ?? [],
|
||||
'description_md': recipe.description,
|
||||
'video_url': recipe.videoUrl,
|
||||
'ingredients': ingredientsMap,
|
||||
'steps': stepsMap,
|
||||
'cooking_time': recipe.totalCookingTime,
|
||||
'active_time': recipe.activeCookTime,
|
||||
'image': multiPartFile,
|
||||
'servings': recipe.servings,
|
||||
'energy_per_serving': recipe.energyPerServing,
|
||||
};
|
||||
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
|
||||
final formData = FormData.fromMap(formMap);
|
||||
}
|
||||
return stepsMap;
|
||||
}
|
||||
|
||||
Future<FormData> _buildPublishFormData(RecipeModel recipe) async {
|
||||
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
|
||||
throw Exception('Recipe image is required');
|
||||
}
|
||||
final image = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
|
||||
final multiPartFile = await MultipartFile.fromFile(image.path);
|
||||
|
||||
final ingredientsMap = recipe.ingredients
|
||||
?.map((i) => i?.toJson())
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final stepsMap = await _buildStepsMap(recipe.steps);
|
||||
|
||||
final formMap = {
|
||||
'type': recipe.type,
|
||||
'title': recipe.title,
|
||||
'tags[]': recipe.tags ?? [],
|
||||
'state': 'active',
|
||||
'description_md': recipe.description,
|
||||
'video_url': recipe.videoUrl,
|
||||
'ingredients': ingredientsMap,
|
||||
'steps': stepsMap,
|
||||
'cooking_time': recipe.totalCookingTime,
|
||||
'active_time': recipe.activeCookTime,
|
||||
'image': multiPartFile,
|
||||
'servings': recipe.servings,
|
||||
'energy_per_serving': recipe.energyPerServing,
|
||||
};
|
||||
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
|
||||
return FormData.fromMap(formMap);
|
||||
}
|
||||
|
||||
Future<FormData> _buildDraftFormData(RecipeModel recipe) async {
|
||||
final ingredientsMap = recipe.ingredients
|
||||
?.map((i) => i?.toJson())
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final stepsMap = await _buildStepsMap(recipe.steps);
|
||||
|
||||
final formMap = <String, dynamic>{
|
||||
'type': recipe.type,
|
||||
'title': recipe.title,
|
||||
'tags[]': recipe.tags ?? [],
|
||||
'description_md': recipe.description,
|
||||
'video_url': recipe.videoUrl,
|
||||
'ingredients': ingredientsMap,
|
||||
'steps': stepsMap,
|
||||
'cooking_time': recipe.totalCookingTime,
|
||||
'active_time': recipe.activeCookTime,
|
||||
'servings': recipe.servings,
|
||||
'energy_per_serving': recipe.energyPerServing,
|
||||
};
|
||||
|
||||
if (recipe.image != null && (recipe.image?.isNotEmpty ?? false)) {
|
||||
final compressed = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
|
||||
formMap['image'] = await MultipartFile.fromFile(compressed.path);
|
||||
}
|
||||
|
||||
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
|
||||
return FormData.fromMap(formMap);
|
||||
}
|
||||
|
||||
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async {
|
||||
try {
|
||||
final formData = await _buildPublishFormData(recipe);
|
||||
if (update) {
|
||||
await recipeService.updateRecipe(slug: slug!, formData: formData);
|
||||
} else {
|
||||
@@ -83,6 +123,30 @@ class RecipeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveAsDraft(RecipeModel recipe, [bool update = false, String? slug]) async {
|
||||
try {
|
||||
final formData = await _buildDraftFormData(recipe);
|
||||
log(formData.fields.toString());
|
||||
if (update) {
|
||||
await recipeService.updateRecipe(slug: slug!, formData: formData);
|
||||
} else {
|
||||
await recipeService.createDraftedRecipe(formData: formData);
|
||||
}
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> draftExistingRecipe(int id) async {
|
||||
try {
|
||||
await recipeService.draftRecipe(id: id);
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RecipeModel> getRecipe(String recipeID) async {
|
||||
try {
|
||||
return await recipeService.getRecipe(recipeID);
|
||||
|
||||
@@ -33,7 +33,7 @@ abstract class CreateRecipeService {
|
||||
|
||||
@POST('/api/recipes/create')
|
||||
@MultiPart()
|
||||
Future<void> publishRecipe({
|
||||
Future<HttpResponse<dynamic>> publishRecipe({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
@@ -47,8 +47,19 @@ abstract class CreateRecipeService {
|
||||
|
||||
@POST('/api/recipes/update/{slug}')
|
||||
@MultiPart()
|
||||
Future<void> updateRecipe({
|
||||
Future<HttpResponse<dynamic>> updateRecipe({
|
||||
@Path() required String slug,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
@POST('/api/recipes/draft/{id}')
|
||||
Future<void> draftRecipe({
|
||||
@Path() required int id,
|
||||
});
|
||||
|
||||
@POST('/api/recipes/create/drafted')
|
||||
@MultiPart()
|
||||
Future<HttpResponse<dynamic>> createDraftedRecipe({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/models/step.dart' as cr_step;
|
||||
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as cr_ingredient;
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'my_recipe_model.g.dart';
|
||||
@@ -34,3 +38,72 @@ abstract class MyRecipeModel with _$MyRecipeModel {
|
||||
|
||||
factory MyRecipeModel.fromJson(Map<String, Object?> json) => _$MyRecipeModelFromJson(json);
|
||||
}
|
||||
|
||||
extension MyRecipeModelX on MyRecipeModel {
|
||||
RecipeModel toRecipeModel() {
|
||||
return RecipeModel(
|
||||
title: title,
|
||||
description: description,
|
||||
image: image,
|
||||
totalCookingTime: cookingTime,
|
||||
activeCookTime: activeCookingTime,
|
||||
energyPerServing: calories.toInt(),
|
||||
videoUrl: videoUrl,
|
||||
// The API expects 'active' to publish
|
||||
state: 'active',
|
||||
steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList().cast<cr_step.Step?>(),
|
||||
// For My Recipes we don't have ingredients directly accessible in this basic model,
|
||||
// but when it gets sent to publish, RecipeRepository merges properties correctly if available.
|
||||
ingredients: [],
|
||||
tags: [],
|
||||
);
|
||||
}
|
||||
|
||||
Recipe toRecipe() {
|
||||
return Recipe(
|
||||
id: id,
|
||||
image: image,
|
||||
state: state,
|
||||
cookingTime: cookingTime,
|
||||
activeCookingTime: activeCookingTime,
|
||||
ingredients: [],
|
||||
description: description,
|
||||
steps: steps,
|
||||
videoUrl: videoUrl,
|
||||
title: title,
|
||||
slug: slug,
|
||||
calories: calories,
|
||||
likesCount: likesCount,
|
||||
viewsCount: viewsCount,
|
||||
type: 'recipe',
|
||||
commentsCount: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
tags: [],
|
||||
author: const Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension RecipeToRecipeModelX on Recipe {
|
||||
RecipeModel toRecipeModel() {
|
||||
return RecipeModel(
|
||||
title: title,
|
||||
description: description,
|
||||
image: image,
|
||||
totalCookingTime: cookingTime,
|
||||
activeCookTime: activeCookingTime,
|
||||
energyPerServing: calories.toInt(),
|
||||
videoUrl: videoUrl,
|
||||
state: 'active',
|
||||
steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList(),
|
||||
ingredients: ingredients?.map((e) => cr_ingredient.Ingredient(
|
||||
name: e.name,
|
||||
portion: e.portion,
|
||||
unit: e.unit ?? '',
|
||||
productId: e.productId,
|
||||
)).toList() ?? [],
|
||||
tags: tags.map((e) => e.name).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import 'dart:developer';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart';
|
||||
import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart';
|
||||
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class MyRecipesCubit extends Cubit<MyRecipesState> {
|
||||
final MyRecipesRepository myRecipesRepository;
|
||||
final AboutRecipeRepository aboutRecipeRepository;
|
||||
final RecipeRepository recipeRepository;
|
||||
|
||||
static const String active = 'active';
|
||||
static const String archived = 'archived';
|
||||
static const String drafted = 'drafted';
|
||||
|
||||
MyRecipesCubit({
|
||||
required this.myRecipesRepository,
|
||||
required this.aboutRecipeRepository,
|
||||
required this.recipeRepository,
|
||||
}) : super(const MyRecipesState.loading());
|
||||
|
||||
Future<void> getMyRecipes() async {
|
||||
@@ -50,7 +58,47 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setDraftedState() async {
|
||||
emit(const MyRecipesState.loading());
|
||||
try {
|
||||
final myRecipes = await myRecipesRepository.getMyRecipes();
|
||||
final List<MyRecipeModel> draftedRecipes = myRecipes.where((recipe) => recipe.state == drafted).toList();
|
||||
emit(MyRecipesState.loaded(recipes: draftedRecipes, selectedState: drafted));
|
||||
} catch (e) {
|
||||
emit(const MyRecipesState.error());
|
||||
}
|
||||
}
|
||||
|
||||
Future<Recipe> getRecipeById(String slug) async {
|
||||
return await aboutRecipeRepository.getRecipe(slug);
|
||||
}
|
||||
|
||||
/// Attempts to publish a drafted recipe
|
||||
/// Returns `true` if successful
|
||||
/// Returns `false` if failed (e.g. validation error 422 because fields are missing)
|
||||
Future<bool> publishDraft(RecipeModel recipe, String slug) async {
|
||||
try {
|
||||
await recipeRepository.publishRecipe(recipe, true, slug);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
if (e is DioException && e.response?.statusCode == 422) {
|
||||
log('Validation failed for publishing draft: \${e.response?.data}');
|
||||
} else {
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an active recipe to draft
|
||||
Future<bool> draftRecipe(int id) async {
|
||||
try {
|
||||
await recipeRepository.draftExistingRecipe(id);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
log('Failed to draft recipe: \$e');
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
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/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipes_tab.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class MyRecipesScreen extends StatelessWidget {
|
||||
const MyRecipesScreen({super.key});
|
||||
@@ -32,15 +35,68 @@ class MyRecipesScreen extends StatelessWidget {
|
||||
create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(),
|
||||
child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
|
||||
builder: (BuildContext context, MyRecipesState state) {
|
||||
if (state is MyRecipesStateLoaded) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]),
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemCount: state.recipes.length,
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: state is MyRecipesStateLoaded
|
||||
? state.recipes.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
state.selectedState == MyRecipesCubit.drafted ? 'Нет черновиков' : 'Нет опубликованных рецептов',
|
||||
style: TextStyles.textLMedium.copyWith(color: appTheme.gray500),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.only(top: 80, bottom: 16),
|
||||
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]),
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemCount: state.recipes.length,
|
||||
)
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyRecipesTab(
|
||||
selectedIndex: state is MyRecipesStateLoaded ? (state.selectedState == MyRecipesCubit.drafted ? 1 : 0) : 0,
|
||||
onTabChanged: (index) {
|
||||
if (index == 0) {
|
||||
context.read<MyRecipesCubit>().setActiveState();
|
||||
} else {
|
||||
context.read<MyRecipesCubit>().setDraftedState();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.push(RecipeCreateScreen.path);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: appTheme.whiteA700,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,10 +8,12 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -99,11 +101,7 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
? '${widget.recipe.description.substring(0, 200)}...'
|
||||
: widget.recipe.description;
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
return MarkdownHtmlWidget(markdownText: description, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -126,7 +124,13 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
return;
|
||||
}
|
||||
showSuccessToast('Рецепт удален');
|
||||
if (context.mounted) context.read<MyRecipesCubit>().getMyRecipes();
|
||||
if (context.mounted) {
|
||||
if (isDraft) {
|
||||
context.read<MyRecipesCubit>().setDraftedState();
|
||||
} else {
|
||||
context.read<MyRecipesCubit>().getMyRecipes();
|
||||
}
|
||||
}
|
||||
},
|
||||
text: 'Удалить',
|
||||
height: 52,
|
||||
@@ -140,30 +144,147 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: isDraft
|
||||
? CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black12,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
final fullRecipe = await context.read<MyRecipesCubit>().getRecipeById(widget.recipe.slug);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // close dialog
|
||||
context.push(RecipeCreateScreen.path, extra: fullRecipe);
|
||||
}
|
||||
},
|
||||
height: 52,
|
||||
buttonStyle: ButtonStyle(
|
||||
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
text: 'Редактировать',
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||
)
|
||||
: PopupMenuButton<int>(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
offset: const Offset(0, 50),
|
||||
itemBuilder: (context) => [
|
||||
if (!isDraft)
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('\${sl<AppConfig>().baseUrl}/ru/recipe/\${widget.recipe.slug}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.share, size: 20, color: appTheme.black),
|
||||
const SizedBox(width: 8),
|
||||
Text(S.of(context).share, style: TextStyles.textSMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () {},
|
||||
height: 52,
|
||||
width: 52,
|
||||
buttonStyle: ButtonStyle(
|
||||
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
text: s.share,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isDraft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/recipe/${widget.recipe.slug}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black12,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
final fullRecipe = await context.read<MyRecipesCubit>().getRecipeById(widget.recipe.slug);
|
||||
final createCubit = sl<CreateRecipeCubit>();
|
||||
await createCubit.recipeToEntity(fullRecipe);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
if (createCubit.validateAll()) {
|
||||
final success = await createCubit.publishRecipe(true, widget.recipe.slug);
|
||||
if (success && context.mounted) {
|
||||
showSuccessToast('Черновик опубликован');
|
||||
context.read<MyRecipesCubit>().setDraftedState();
|
||||
} else {
|
||||
showErrorToast('Не удалось опубликовать черновик');
|
||||
}
|
||||
} else {
|
||||
showErrorToast('Заполните все необходимые поля для публикации рецепта');
|
||||
if (context.mounted) {
|
||||
context.push(RecipeCreateScreen.path, extra: {
|
||||
'recipe': fullRecipe,
|
||||
'autoValidate': true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await createCubit.close();
|
||||
}
|
||||
},
|
||||
height: 52,
|
||||
width: 52,
|
||||
buttonStyle: ButtonStyle(
|
||||
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
text: s.share,
|
||||
text: 'Опубликовать',
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
),
|
||||
if (!isDraft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
final result = await context.read<MyRecipesCubit>().draftRecipe(widget.recipe.id);
|
||||
if (result) {
|
||||
showSuccessToast('Рецепт перенесен в черновики');
|
||||
if (context.mounted) {
|
||||
context.read<MyRecipesCubit>().getMyRecipes();
|
||||
}
|
||||
} else {
|
||||
showErrorToast('Не удалось перенести в черновики');
|
||||
}
|
||||
},
|
||||
height: 52,
|
||||
buttonStyle: ButtonStyle(
|
||||
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
text: 'Снять с публикации',
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -172,6 +293,8 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
bool get isDraft => widget.recipe.state == 'drafted';
|
||||
|
||||
Container _iconButton(String iconPath, [String? text]) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
@@ -192,4 +315,3 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,30 +4,21 @@ import 'package:eatvio_app/core/widgets/animated_option_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MyRecipesTab extends StatefulWidget {
|
||||
const MyRecipesTab({super.key, this.additionalOnTap});
|
||||
class MyRecipesTab extends StatelessWidget {
|
||||
const MyRecipesTab({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
final void Function()? additionalOnTap;
|
||||
|
||||
@override
|
||||
State<MyRecipesTab> createState() => _MyRecipesTabState();
|
||||
}
|
||||
|
||||
class _MyRecipesTabState extends State<MyRecipesTab> {
|
||||
final List<String> tabTitles = [];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
final s = S.of(context);
|
||||
if (tabTitles.isEmpty) {
|
||||
tabTitles.add(s.published);
|
||||
tabTitles.add(s.drafted);
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onTabChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final tabTitles = [s.published, s.drafted];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
@@ -44,18 +35,14 @@ class _MyRecipesTabState extends State<MyRecipesTab> {
|
||||
children: [
|
||||
...List.generate(
|
||||
2,
|
||||
(index) =>
|
||||
Expanded(
|
||||
child: AnimatedOptionButton(
|
||||
textStyle: TextStyles.textSMedium,
|
||||
isSelected: false, // state.selectedStateIndex == index,
|
||||
text: tabTitles[index],
|
||||
onTap: () {
|
||||
widget.additionalOnTap?.call();
|
||||
// context.read<MyPlansCubit>().changeSelectedStateIndex(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
(index) => Expanded(
|
||||
child: AnimatedOptionButton(
|
||||
textStyle: TextStyles.textSMedium,
|
||||
isSelected: selectedIndex == index,
|
||||
text: tabTitles[index],
|
||||
onTap: () => onTabChanged(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user