feat: Add user recipe management and creation functionality, including models, widgets, cubits, repositories, and tests.

This commit is contained in:
mso7nueh
2026-02-25 13:10:07 +03:00
parent 5167766dbf
commit 0641513ebe
10 changed files with 220 additions and 71 deletions

3
.gitignore vendored
View File

@@ -22,4 +22,5 @@ android/key.properties
app/upload-keystore.jks
coverage/
.fvm/
.fvmrc
.fvmrc
/eatvio

View File

@@ -114,7 +114,7 @@ class CubitRegistrations {
// User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
// Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));

View File

@@ -153,7 +153,16 @@ class OtherRoutes {
GoRoute(
path: RecipeCreateScreen.path,
name: RecipeCreateScreen.name,
builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null),
builder: (context, state) {
if (state.extra is Map<String, dynamic>) {
final data = state.extra as Map<String, dynamic>;
return RecipeCreateScreen(
recipe: data['recipe'] as Recipe?,
autoValidate: data['autoValidate'] as bool? ?? false,
);
}
return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null);
},
),
GoRoute(
path: CreateDietScreen.path,

View File

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

View File

@@ -62,6 +62,7 @@ class RecipeRepository {
'type': recipe.type,
'title': recipe.title,
'tags[]': recipe.tags ?? [],
'state': 'active',
'description_md': recipe.description,
'video_url': recipe.videoUrl,
'ingredients': ingredientsMap,

View File

@@ -1,4 +1,7 @@
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/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 +37,49 @@ 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),
);
}
}

View File

@@ -1,13 +1,19 @@
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';
@@ -16,6 +22,7 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
MyRecipesCubit({
required this.myRecipesRepository,
required this.aboutRecipeRepository,
required this.recipeRepository,
}) : super(const MyRecipesState.loading());
Future<void> getMyRecipes() async {
@@ -65,4 +72,33 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
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;
}
}
}

View File

@@ -12,7 +12,6 @@ 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';
@@ -144,80 +143,112 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
),
const SizedBox(width: 8),
Expanded(
child: 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: isDraft
? CustomOutlinedButton(
onPressed: () {
context.push(RecipeCreateScreen.path, extra: widget.recipe.toRecipe());
},
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: Row(
children: [
Icon(Icons.share, size: 20, color: appTheme.black),
const SizedBox(width: 8),
Text(S.of(context).share, style: TextStyles.textSMedium),
],
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: () {},
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 (widget.recipe.state == 'active' || isDraft)
PopupMenuItem(
onTap: () {
context.push(RecipeCreateScreen.path, extra: widget.recipe);
},
child: Row(
children: [
Icon(Icons.edit, size: 20, color: appTheme.black),
const SizedBox(width: 8),
Text(S.of(context).edit, 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: isDraft ? '...' : s.share,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
),
),
),
],
),
const SizedBox(height: 8),
if (isDraft)
Padding(
padding: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
child: CustomOutlinedButton(
onPressed: () async {
// Publish draft
context.read<CreateRecipeCubit>().publishRecipe(true, widget.recipe.slug).then((success) {
if (success && context.mounted) {
context.read<MyRecipesCubit>().setDraftedState();
}
});
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,
});
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: appTheme.blueA400,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 12),
height: 52,
buttonStyle: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
),
child: Text('Опубликовать', style: TextStyles.textSMedium.copyWith(color: Colors.white)),
text: 'Опубликовать',
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
),
),
),

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/features/recipe/models/my_recipe_model.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/create_recipe/reporitories/recipe_repository.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_test/flutter_test.dart';
@@ -9,16 +10,18 @@ import 'package:mockito/mockito.dart';
import 'my_recipes_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<MyRecipesRepository>(), MockSpec<AboutRecipeRepository>()])
@GenerateNiceMocks([MockSpec<MyRecipesRepository>(), MockSpec<AboutRecipeRepository>(), MockSpec<RecipeRepository>()])
void main() {
group('MyRecipesCubit', () {
MyRecipesCubit? cubit;
late MockMyRecipesRepository mockMyRecipesRepository;
late MockAboutRecipeRepository mockAboutRecipeRepository;
late MockRecipeRepository mockRecipeRepository;
setUp(() {
mockMyRecipesRepository = MockMyRecipesRepository();
mockAboutRecipeRepository = MockAboutRecipeRepository();
mockRecipeRepository = MockRecipeRepository();
});
tearDown(() async {
@@ -29,13 +32,13 @@ void main() {
});
test('начальное состояние - loading', () {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository);
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
expect(cubit!.state, isA<MyRecipesStateLoading>());
});
test('getMyRecipes - успешная загрузка активных рецептов', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository);
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [
const MyRecipeModel(
@@ -66,7 +69,7 @@ void main() {
});
test('setActiveState - фильтрует активные рецепты', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository);
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [
const MyRecipeModel(
@@ -112,7 +115,7 @@ void main() {
});
test('setArchivedState - фильтрует архивированные рецепты', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository);
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [
const MyRecipeModel(

View File

@@ -4,6 +4,7 @@ import 'package:eatvio_app/features/recipe/models/my_recipe_model.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/repositories/about_recipe_repository.dart';
import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
@@ -13,12 +14,14 @@ import 'my_recipes_cubit_test.mocks.dart';
@GenerateNiceMocks([
MockSpec<MyRecipesRepository>(),
MockSpec<AboutRecipeRepository>(),
MockSpec<RecipeRepository>(),
])
void main() {
group('MyRecipesCubit', () {
MyRecipesCubit? myRecipesCubit;
late MockMyRecipesRepository mockMyRecipesRepository;
late MockAboutRecipeRepository mockAboutRecipeRepository;
late MockRecipeRepository mockRecipeRepository;
const testActiveRecipe = MyRecipeModel(
id: 1,
@@ -85,6 +88,7 @@ void main() {
setUp(() {
mockMyRecipesRepository = MockMyRecipesRepository();
mockAboutRecipeRepository = MockAboutRecipeRepository();
mockRecipeRepository = MockRecipeRepository();
});
tearDown(() async {
@@ -99,6 +103,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
expect(myRecipesCubit!.state, isA<MyRecipesStateLoading>());
});
@@ -107,6 +112,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -127,6 +133,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -141,6 +148,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -161,6 +169,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -175,6 +184,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -195,6 +205,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -209,6 +220,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockAboutRecipeRepository.getRecipe('test-recipe')).thenAnswer((_) async => testRecipe);
@@ -225,6 +237,7 @@ void main() {
myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
);
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(