feat: Introduce user-generated content features, including recipe creation and management, and blog post creation.

This commit is contained in:
mso7nueh
2026-02-26 02:52:08 +03:00
parent 0641513ebe
commit 0b2e026d1d
5 changed files with 127 additions and 68 deletions

View File

@@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
const SizedBox(height: 80),
_createBlogPostButton(),
const SizedBox(height: 80),
],
),
),

View File

@@ -134,7 +134,7 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
(widget.recipe != null && widget.recipe?.state != 'drafted') ? 'Обновить' : 'Опубликовать',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
),
@@ -180,7 +180,7 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
'Сохранить как черновик',
widget.recipe != null ? 'Обновить черновик' : 'Сохранить как черновик',
style: TextStyle(fontSize: 16, color: appTheme.blueA400, fontWeight: FontWeight.w600),
),
),

View File

@@ -1,5 +1,6 @@
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';
@@ -83,3 +84,26 @@ extension MyRecipeModelX on MyRecipeModel {
);
}
}
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(),
);
}
}

View File

@@ -33,39 +33,40 @@ class MyRecipesScreen extends StatelessWidget {
create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(),
child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
builder: (BuildContext context, MyRecipesState state) {
return Column(
return Stack(
children: [
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();
}
},
),
Expanded(
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.symmetric(vertical: 16),
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]),
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemCount: state.recipes.length,
)
? 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: 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();
}
},
),
),
],
);
},

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/m
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';
@@ -145,8 +146,17 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
Expanded(
child: isDraft
? CustomOutlinedButton(
onPressed: () {
context.push(RecipeCreateScreen.path, extra: widget.recipe.toRecipe());
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(
@@ -180,27 +190,6 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
],
),
),
if (!isDraft)
PopupMenuItem(
onTap: () async {
final result = await context.read<MyRecipesCubit>().draftRecipe(widget.recipe.id);
if (result) {
showSuccessToast('Рецепт перенесен в черновики');
if (context.mounted) {
context.read<MyRecipesCubit>().getMyRecipes();
}
} else {
showErrorToast('Не удалось перенести в черновики');
}
},
child: Row(
children: [
Icon(Icons.archive_outlined, size: 20, color: appTheme.black),
const SizedBox(width: 8),
const Text('В черновик', style: TextStyles.textSMedium),
],
),
),
],
child: CustomOutlinedButton(
onPressed: () {},
@@ -225,21 +214,36 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
width: double.infinity,
child: CustomOutlinedButton(
onPressed: () async {
// Publish draft
final recipeModel = widget.recipe.toRecipeModel();
final success = await context.read<MyRecipesCubit>().publishDraft(recipeModel, widget.recipe.slug);
if (success && context.mounted) {
showSuccessToast('Черновик опубликован');
context.read<MyRecipesCubit>().setDraftedState();
} else {
showErrorToast('Заполните все необходимые поля для публикации рецепта');
if (context.mounted) {
context.push(RecipeCreateScreen.path, extra: {
'recipe': widget.recipe.toRecipe(),
'autoValidate': true,
});
}
}
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,
buttonStyle: ButtonStyle(
@@ -252,6 +256,35 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
),
),
),
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),
],
),
),