diff --git a/.gitignore b/.gitignore index 1cd6d5b0..522e6995 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ android/key.properties app/upload-keystore.jks coverage/ .fvm/ -.fvmrc \ No newline at end of file +.fvmrc +/eatvio \ No newline at end of file diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index ad4ce637..6f26ab95 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -114,7 +114,7 @@ class CubitRegistrations { // User content cubits sl.registerFactory(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory(() => MyPlansCubit(myPlansRepository: sl())); - sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl())); + sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl())); // Other feature cubits sl.registerFactory(() => CalendarCubit(calendarRepository: sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 7c16bf74..98e767da 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -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) { + final data = state.extra as Map; + 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, diff --git a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart index 1538dc57..96fb5a38 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -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 { createRecipeCubit = sl(); if (widget.recipe != null) { createRecipeCubit.recipeToEntity(widget.recipe!); + if (widget.autoValidate) { + WidgetsBinding.instance.addPostFrameCallback((_) { + createRecipeCubit.validateAll(); + }); + } } else { createRecipeCubit.disableLoading(); } diff --git a/lib/features/create_recipe/reporitories/recipe_repository.dart b/lib/features/create_recipe/reporitories/recipe_repository.dart index 34d2599f..6acc905b 100644 --- a/lib/features/create_recipe/reporitories/recipe_repository.dart +++ b/lib/features/create_recipe/reporitories/recipe_repository.dart @@ -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, diff --git a/lib/features/recipe/models/my_recipe_model.dart b/lib/features/recipe/models/my_recipe_model.dart index f7068938..c55f2e77 100644 --- a/lib/features/recipe/models/my_recipe_model.dart +++ b/lib/features/recipe/models/my_recipe_model.dart @@ -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 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(), + // 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), + ); + } +} diff --git a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart index 2ab45971..db46b7b4 100644 --- a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart +++ b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart @@ -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 { 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 { MyRecipesCubit({ required this.myRecipesRepository, required this.aboutRecipeRepository, + required this.recipeRepository, }) : super(const MyRecipesState.loading()); Future getMyRecipes() async { @@ -65,4 +72,33 @@ class MyRecipesCubit extends Cubit { Future 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 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 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; + } + } } diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index 941971fa..0855b609 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -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 { ), const SizedBox(width: 8), Expanded( - child: PopupMenuButton( - 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().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( + 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().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().draftRecipe(widget.recipe.id); + if (result) { + showSuccessToast('Рецепт перенесен в черновики'); + if (context.mounted) { + context.read().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().publishRecipe(true, widget.recipe.slug).then((success) { - if (success && context.mounted) { - context.read().setDraftedState(); - } - }); + final recipeModel = widget.recipe.toRecipeModel(); + final success = await context.read().publishDraft(recipeModel, widget.recipe.slug); + if (success && context.mounted) { + showSuccessToast('Черновик опубликован'); + context.read().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), ), ), ), diff --git a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart index 3bd1f05f..5260e3ee 100644 --- a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart +++ b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart @@ -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(), MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) 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()); }); 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( diff --git a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart index e116dc1e..4afde5ec 100644 --- a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart +++ b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart @@ -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(), MockSpec(), + MockSpec(), ]) 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()); }); @@ -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(