diff --git a/.gitignore b/.gitignore index 530ea713..0d94aecf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ env/.env .env android/key.properties app/upload-keystore.jks -coverage/ \ No newline at end of file +coverage/ +eatvio/ \ No newline at end of file diff --git a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart index 8f7bca23..482d7d56 100644 --- a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart +++ b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart @@ -172,6 +172,23 @@ class CreateRecipeCubit extends Cubit { } } + Future 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() { + return validateAll(); + } + Future downloadImageAsFile(String imageUrl) async { final response = await http.get(Uri.parse(imageUrl)); @@ -239,6 +256,7 @@ class CreateRecipeCubit extends Cubit { final isActiveCookTimeValid = validateActiveCookTime(); final isEnergyPerServingsValid = validateEnergyPerServing(); + final isIngredientsValid = validateIngredients(); final isStepsValid = validateSteps(); return isTitleValid && @@ -248,8 +266,19 @@ class CreateRecipeCubit extends Cubit { 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 +372,10 @@ class CreateRecipeCubit extends Cubit { 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; diff --git a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart index ef0efe78..23cb9895 100644 --- a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart +++ b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart @@ -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; } 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 932852ac..1538dc57 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -81,55 +81,107 @@ class _RecipeCreateScreenState extends State { 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().validateAll()) { + final isSuccess = await context.read().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 ? 'Обновить' : 'Опубликовать', + 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().validateAll()) { - final isSuccess = await context.read().publishRecipe( - widget.recipe == null ? false : true, - widget.recipe?.slug, - ); + if (context.read().validateForDraft()) { + final isSuccess = await context.read().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), + 'Сохранить как черновик', + style: TextStyle(fontSize: 16, color: appTheme.blueA400, fontWeight: FontWeight.w600), ), ), ), - ); + ), + ], + ); }, ); }, diff --git a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart index 4b3261ab..6894c162 100644 --- a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart @@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State { 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), + ), + ), ], ), ), diff --git a/lib/features/create_recipe/reporitories/recipe_repository.dart b/lib/features/create_recipe/reporitories/recipe_repository.dart index b1b5fce9..f079d810 100644 --- a/lib/features/create_recipe/reporitories/recipe_repository.dart +++ b/lib/features/create_recipe/reporitories/recipe_repository.dart @@ -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,59 @@ class RecipeRepository { } } - Future 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); + Future _buildFormData(RecipeModel recipe, {String state = 'active'}) 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((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, - }); + 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, + }); } } - 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); + } + final formMap = { + 'type': recipe.type, + 'state': state, + '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)); + return FormData.fromMap(formMap); + } + + Future publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildFormData(recipe, state: 'active'); if (update) { await recipeService.updateRecipe(slug: slug!, formData: formData); } else { @@ -83,6 +88,38 @@ class RecipeRepository { } } + Future saveAsDraft(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildFormData(recipe, state: 'archived'); + log(formData.fields.toString()); + if (update) { + final response = await recipeService.updateRecipe(slug: slug!, formData: formData); + final recipeId = response.data['data']?['id'] ?? response.data['id']; + if (recipeId != null) { + await recipeService.draftRecipe(id: recipeId as int); + } + } else { + final response = await recipeService.publishRecipe(formData: formData); + final recipeId = response.data['data']?['id'] ?? response.data['id']; + if (recipeId != null) { + await recipeService.draftRecipe(id: recipeId as int); + } + } + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } + + Future draftExistingRecipe(int id) async { + try { + await recipeService.draftRecipe(id: id); + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } + Future getRecipe(String recipeID) async { try { return await recipeService.getRecipe(recipeID); diff --git a/lib/features/create_recipe/services/recipe_service.dart b/lib/features/create_recipe/services/recipe_service.dart index 41ee957a..199c7d54 100644 --- a/lib/features/create_recipe/services/recipe_service.dart +++ b/lib/features/create_recipe/services/recipe_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; import 'package:eatvio_app/features/create_recipe/models/recipe_tag_model.dart'; import 'package:retrofit/retrofit.dart'; +import 'package:retrofit/http.dart'; import 'package:eatvio_app/features/create_recipe/models/product.dart'; part 'recipe_service.g.dart'; @@ -33,7 +34,7 @@ abstract class CreateRecipeService { @POST('/api/recipes/create') @MultiPart() - Future publishRecipe({ + Future> publishRecipe({ @Body() required FormData formData, }); @@ -47,8 +48,13 @@ abstract class CreateRecipeService { @POST('/api/recipes/update/{slug}') @MultiPart() - Future updateRecipe({ + Future> updateRecipe({ @Path() required String slug, @Body() required FormData formData, }); + + @POST('/api/recipes/draft/{id}') + Future draftRecipe({ + @Path() required int id, + }); } diff --git a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart index 44481c3a..2ab45971 100644 --- a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart +++ b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart @@ -11,6 +11,7 @@ class MyRecipesCubit extends Cubit { static const String active = 'active'; static const String archived = 'archived'; + static const String drafted = 'drafted'; MyRecipesCubit({ required this.myRecipesRepository, @@ -50,6 +51,17 @@ class MyRecipesCubit extends Cubit { } } + Future setDraftedState() async { + emit(const MyRecipesState.loading()); + try { + final myRecipes = await myRecipesRepository.getMyRecipes(); + final List draftedRecipes = myRecipes.where((recipe) => recipe.state == drafted).toList(); + emit(MyRecipesState.loaded(recipes: draftedRecipes, selectedState: drafted)); + } catch (e) { + emit(const MyRecipesState.error()); + } + } + Future getRecipeById(String slug) async { return await aboutRecipeRepository.getRecipe(slug); } diff --git a/lib/features/recipe/presentation/pages/my_recipes_screen.dart b/lib/features/recipe/presentation/pages/my_recipes_screen.dart index c3e29db5..1f276cf6 100644 --- a/lib/features/recipe/presentation/pages/my_recipes_screen.dart +++ b/lib/features/recipe/presentation/pages/my_recipes_screen.dart @@ -4,6 +4,7 @@ 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_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'; @@ -32,15 +33,41 @@ class MyRecipesScreen extends StatelessWidget { create: (BuildContext context) => sl()..getMyRecipes(), child: BlocBuilder( 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 Column( + children: [ + MyRecipesTab( + selectedIndex: state is MyRecipesStateLoaded + ? (state.selectedState == MyRecipesCubit.drafted ? 1 : 0) + : 0, + onTabChanged: (index) { + if (index == 0) { + context.read().setActiveState(); + } else { + context.read().setDraftedState(); + } + }, + ), + Expanded( + 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, + ) + : const Center(child: CircularProgressIndicator()), + ), + ], + ); }, ), ), diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index c30aebc1..a47b6161 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -12,6 +12,8 @@ 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'; import 'package:go_router/go_router.dart'; @@ -99,11 +101,7 @@ class _MyRecipeWidgetState extends State { ? '${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 { return; } showSuccessToast('Рецепт удален'); - if (context.mounted) context.read().getMyRecipes(); + if (context.mounted) { + if (isDraft) { + context.read().setDraftedState(); + } else { + context.read().getMyRecipes(); + } + } }, text: 'Удалить', height: 52, @@ -140,30 +144,83 @@ class _MyRecipeWidgetState extends State { ), const SizedBox(width: 8), Expanded( - child: CustomOutlinedButton( - onPressed: () 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: 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), + ], + ), ), - ); - }, - height: 52, - width: 52, - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - side: const WidgetStatePropertyAll(BorderSide.none), + 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), ), - text: s.share, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), ), ), ], ), const SizedBox(height: 8), + if (isDraft) + Padding( + padding: const EdgeInsets.only(top: 12), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + // Publish draft + context.read().publishRecipe(true, widget.recipe.slug).then((success) { + if (success) { + context.read().setDraftedState(); + } + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text('Опубликовать', style: TextStyles.textSMedium.copyWith(color: Colors.white)), + ), + ), + ), ], ), ), @@ -172,6 +229,8 @@ class _MyRecipeWidgetState extends State { ); } + 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 +251,3 @@ class _MyRecipeWidgetState extends State { ); } } - diff --git a/lib/features/recipe/presentation/widgets/my_recipes_tab.dart b/lib/features/recipe/presentation/widgets/my_recipes_tab.dart index f6de8669..af68f89e 100644 --- a/lib/features/recipe/presentation/widgets/my_recipes_tab.dart +++ b/lib/features/recipe/presentation/widgets/my_recipes_tab.dart @@ -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 createState() => _MyRecipesTabState(); -} - -class _MyRecipesTabState extends State { - final List 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 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 { 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().changeSelectedStateIndex(index); - }, - ), - ), + (index) => Expanded( + child: AnimatedOptionButton( + textStyle: TextStyles.textSMedium, + isSelected: selectedIndex == index, + text: tabTitles[index], + onTap: () => onTabChanged(index), + ), + ), ), ], ), diff --git a/test/features/create_recipe/reporitories/recipe_repository_test.dart b/test/features/create_recipe/reporitories/recipe_repository_test.dart index 95cb1462..4a82a264 100644 --- a/test/features/create_recipe/reporitories/recipe_repository_test.dart +++ b/test/features/create_recipe/reporitories/recipe_repository_test.dart @@ -10,8 +10,15 @@ import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:retrofit/retrofit.dart'; + class MockCreateRecipeService extends Mock implements CreateRecipeService {} +final mockHttpResponse = HttpResponse( + null, + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), +); + void main() { setUpAll(() { registerFallbackValue(FormData()); @@ -109,7 +116,7 @@ void main() { test('deleteRecipe - удаляет рецепт', () async { const slug = 'recipe-slug'; - when(() => mockService.deleteRecipe(slug: slug)).thenAnswer((_) async => Future.value()); + when(() => mockService.deleteRecipe(slug: slug)).thenAnswer((_) async => Future.value(mockHttpResponse)); await repository.deleteRecipe(slug); @@ -188,7 +195,7 @@ void main() { // Мокируем ImageCompressUtility.compressFile // Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); // Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику try { @@ -216,7 +223,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -243,7 +250,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -270,7 +277,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -297,7 +304,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -364,7 +371,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -399,7 +406,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -435,7 +442,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -472,7 +479,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -499,7 +506,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); diff --git a/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart b/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart index efba34ba..58d21ef1 100644 --- a/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart +++ b/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('MyRecipesTab smoke', () { - Widget wrap(Widget child) { + Widget wrap() { return MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -17,12 +17,17 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('ru')], - home: Scaffold(body: child), + home: Material( + child: MyRecipesTab( + selectedIndex: 0, + onTabChanged: (_) {}, + ), + ), ); } testWidgets('renders my recipes tab', (tester) async { - await tester.pumpWidget(wrap(const MyRecipesTab())); + await tester.pumpWidget(wrap()); await tester.pump(); expect(find.byType(MyRecipesTab), findsOneWidget);