feat: implement recipe drafting functionality and refactor MyRecipesTab to be stateless

This commit is contained in:
mso7nueh
2026-02-16 12:46:55 +03:00
parent 516dfa4158
commit d4f41fe6cf
13 changed files with 388 additions and 153 deletions

3
.gitignore vendored
View File

@@ -20,4 +20,5 @@ env/.env
.env .env
android/key.properties android/key.properties
app/upload-keystore.jks app/upload-keystore.jks
coverage/ coverage/
eatvio/

View File

@@ -172,6 +172,23 @@ 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() {
return validateAll();
}
Future<File> downloadImageAsFile(String imageUrl) async { Future<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl)); final response = await http.get(Uri.parse(imageUrl));
@@ -239,6 +256,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
final isActiveCookTimeValid = validateActiveCookTime(); final isActiveCookTimeValid = validateActiveCookTime();
final isEnergyPerServingsValid = validateEnergyPerServing(); final isEnergyPerServingsValid = validateEnergyPerServing();
final isIngredientsValid = validateIngredients();
final isStepsValid = validateSteps(); final isStepsValid = validateSteps();
return isTitleValid && return isTitleValid &&
@@ -248,8 +266,19 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
isCookTimeValid && isCookTimeValid &&
isActiveCookTimeValid && isActiveCookTimeValid &&
isEnergyPerServingsValid && isEnergyPerServingsValid &&
isIngredientsValid &&
isStepsValid; isStepsValid;
} }
bool validateIngredients() {
if (state.entity.ingredients.isEmpty) {
emit(state.copyWith(ingredientsValidationError: 'Добавьте хотя бы один ингредиент'));
return false;
} else {
emit(state.copyWith(ingredientsValidationError: null));
return true;
}
}
bool validateTitle() { bool validateTitle() {
if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) { if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) {
@@ -343,6 +372,10 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
bool validateSteps() { bool validateSteps() {
bool isValid = true; bool isValid = true;
if (state.entity.steps.isEmpty) {
emit(state.copyWith(stepsValidationError: 'Добавьте хотя бы один шаг'));
return false;
}
for (var step in state.entity.steps) { for (var step in state.entity.steps) {
if (step.description == null || step.description!.trim().isEmpty) { if (step.description == null || step.description!.trim().isEmpty) {
isValid = false; isValid = false;

View File

@@ -8,6 +8,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
const factory CreateRecipeState({ const factory CreateRecipeState({
required RecipeCreateEntity entity, required RecipeCreateEntity entity,
required bool loading, required bool loading,
@Default(false) bool drafted,
String? titleValidationError, String? titleValidationError,
String? descriptionValidationError, String? descriptionValidationError,
String? cookingTimeValidationError, String? cookingTimeValidationError,
@@ -16,6 +17,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
String? energyPerServingValidationError, String? energyPerServingValidationError,
String? imageValidationError, String? imageValidationError,
String? stepsValidationError, String? stepsValidationError,
String? ingredientsValidationError,
}) = _CreateRecipeState; }) = _CreateRecipeState;
} }

View File

@@ -81,55 +81,107 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
builder: (context, state) { builder: (context, state) {
return Builder( return Builder(
builder: (context) { builder: (context) {
return _isRecipeBeingPublished if (_isRecipeBeingPublished) {
? const Center(child: Loader()) return const Center(child: Loader());
: InkWell( }
key: AppKeys.recipeCreateScreenPublishButton, 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 ? 'Обновить' : 'Опубликовать',
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 { onTap: () async {
setState(() { setState(() {
_isRecipeBeingPublished = true; _isRecipeBeingPublished = true;
}); });
if (context.read<CreateRecipeCubit>().validateAll()) { if (context.read<CreateRecipeCubit>().validateForDraft()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe( final isSuccess = await context.read<CreateRecipeCubit>().saveAsDraft(
widget.recipe == null ? false : true, widget.recipe == null ? false : true,
widget.recipe?.slug, widget.recipe?.slug,
); );
setState(() { setState(() {
_isRecipeBeingPublished = false; _isRecipeBeingPublished = false;
}); });
if (isSuccess) { if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return;
if (!context.mounted) return; context.pop();
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else { } else {
if (!context.mounted) return; 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), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
width: MediaQuery.of(context).size.width - 32, 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( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( 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),
), ),
), ),
), ),
); ),
],
);
}, },
); );
}, },

View File

@@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)), title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)),
leading: Icon(Icons.add_rounded, 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),
),
),
], ],
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
@@ -23,55 +24,59 @@ class RecipeRepository {
} }
} }
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { Future<FormData> _buildFormData(RecipeModel recipe, {String state = 'active'}) async {
try { if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) { throw Exception('Recipe image is required');
return; }
} final image = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
final image = await ImageCompressUtility.compressFile(File(recipe.image ?? '')); final multiPartFile = await MultipartFile.fromFile(image.path);
final multiPartFile = await MultipartFile.fromFile(image.path);
final ingredientsMap = recipe.ingredients?.map((ingredient) { final ingredientsMap = recipe.ingredients?.map((ingredient) {
if (ingredient != null) { if (ingredient != null) {
return ingredient.toJson(); 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,
});
} }
}).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, final formMap = {
'state': 'active', 'type': recipe.type,
'title': recipe.title, 'state': state,
'tags[]': recipe.tags ?? [], 'title': recipe.title,
'description_md': recipe.description, 'tags[]': recipe.tags ?? [],
'video_url': recipe.videoUrl, 'description_md': recipe.description,
'ingredients': ingredientsMap, 'video_url': recipe.videoUrl,
'steps': stepsMap, 'ingredients': ingredientsMap,
'cooking_time': recipe.totalCookingTime, 'steps': stepsMap,
'active_time': recipe.activeCookTime, 'cooking_time': recipe.totalCookingTime,
'image': multiPartFile, 'active_time': recipe.activeCookTime,
'servings': recipe.servings, 'image': multiPartFile,
'energy_per_serving': recipe.energyPerServing, 'servings': recipe.servings,
}; 'energy_per_serving': recipe.energyPerServing,
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty)); };
final formData = FormData.fromMap(formMap); 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 _buildFormData(recipe, state: 'active');
if (update) { if (update) {
await recipeService.updateRecipe(slug: slug!, formData: formData); await recipeService.updateRecipe(slug: slug!, formData: formData);
} else { } else {
@@ -83,6 +88,38 @@ class RecipeRepository {
} }
} }
Future<void> 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<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 { Future<RecipeModel> getRecipe(String recipeID) async {
try { try {
return await recipeService.getRecipe(recipeID); return await recipeService.getRecipe(recipeID);

View File

@@ -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_model.dart';
import 'package:eatvio_app/features/create_recipe/models/recipe_tag_model.dart'; import 'package:eatvio_app/features/create_recipe/models/recipe_tag_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
import 'package:retrofit/http.dart';
import 'package:eatvio_app/features/create_recipe/models/product.dart'; import 'package:eatvio_app/features/create_recipe/models/product.dart';
part 'recipe_service.g.dart'; part 'recipe_service.g.dart';
@@ -33,7 +34,7 @@ abstract class CreateRecipeService {
@POST('/api/recipes/create') @POST('/api/recipes/create')
@MultiPart() @MultiPart()
Future<void> publishRecipe({ Future<HttpResponse<dynamic>> publishRecipe({
@Body() required FormData formData, @Body() required FormData formData,
}); });
@@ -47,8 +48,13 @@ abstract class CreateRecipeService {
@POST('/api/recipes/update/{slug}') @POST('/api/recipes/update/{slug}')
@MultiPart() @MultiPart()
Future<void> updateRecipe({ Future<HttpResponse<dynamic>> updateRecipe({
@Path() required String slug, @Path() required String slug,
@Body() required FormData formData, @Body() required FormData formData,
}); });
@POST('/api/recipes/draft/{id}')
Future<void> draftRecipe({
@Path() required int id,
});
} }

View File

@@ -11,6 +11,7 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
static const String active = 'active'; static const String active = 'active';
static const String archived = 'archived'; static const String archived = 'archived';
static const String drafted = 'drafted';
MyRecipesCubit({ MyRecipesCubit({
required this.myRecipesRepository, required this.myRecipesRepository,
@@ -50,6 +51,17 @@ 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 { Future<Recipe> getRecipeById(String slug) async {
return await aboutRecipeRepository.getRecipe(slug); return await aboutRecipeRepository.getRecipe(slug);
} }

View File

@@ -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_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/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_recipe_widget.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipes_tab.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -32,15 +33,41 @@ class MyRecipesScreen extends StatelessWidget {
create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(), create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(),
child: BlocBuilder<MyRecipesCubit, MyRecipesState>( child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
builder: (BuildContext context, MyRecipesState state) { builder: (BuildContext context, MyRecipesState state) {
if (state is MyRecipesStateLoaded) { return Column(
return ListView.separated( children: [
padding: const EdgeInsets.symmetric(vertical: 16), MyRecipesTab(
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]), selectedIndex: state is MyRecipesStateLoaded
separatorBuilder: (context, index) => const SizedBox(height: 12), ? (state.selectedState == MyRecipesCubit.drafted ? 1 : 0)
itemCount: state.recipes.length, : 0,
); onTabChanged: (index) {
} if (index == 0) {
return Container(); context.read<MyRecipesCubit>().setActiveState();
} else {
context.read<MyRecipesCubit>().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()),
),
],
);
}, },
), ),
), ),

View File

@@ -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:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.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.substring(0, 200)}...'
: widget.recipe.description; : widget.recipe.description;
return MarkdownHtmlWidget( return MarkdownHtmlWidget(markdownText: description, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black);
markdownText: description,
fontSize: TextStyles.textSMedium.fontSize ?? 14,
textColor: appTheme.black,
);
}, },
), ),
), ),
@@ -126,7 +124,13 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
return; return;
} }
showSuccessToast('Рецепт удален'); showSuccessToast('Рецепт удален');
if (context.mounted) context.read<MyRecipesCubit>().getMyRecipes(); if (context.mounted) {
if (isDraft) {
context.read<MyRecipesCubit>().setDraftedState();
} else {
context.read<MyRecipesCubit>().getMyRecipes();
}
}
}, },
text: 'Удалить', text: 'Удалить',
height: 52, height: 52,
@@ -140,30 +144,83 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: CustomOutlinedButton( child: PopupMenuButton<int>(
onPressed: () async { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
final box = context.findRenderObject() as RenderBox?; offset: const Offset(0, 50),
await SharePlus.instance.share( itemBuilder: (context) => [
ShareParams( if (!isDraft)
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/recipe/${widget.recipe.slug}'), PopupMenuItem(
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, 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),
],
),
), ),
); if (widget.recipe.state == 'active' || isDraft)
}, PopupMenuItem(
height: 52, onTap: () {
width: 52, context.push(RecipeCreateScreen.path, extra: widget.recipe);
buttonStyle: ButtonStyle( },
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), child: Row(
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), children: [
side: const WidgetStatePropertyAll(BorderSide.none), 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), 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<CreateRecipeCubit>().publishRecipe(true, widget.recipe.slug).then((success) {
if (success) {
context.read<MyRecipesCubit>().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<MyRecipeWidget> {
); );
} }
bool get isDraft => widget.recipe.state == 'drafted';
Container _iconButton(String iconPath, [String? text]) { Container _iconButton(String iconPath, [String? text]) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -192,4 +251,3 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
); );
} }
} }

View File

@@ -4,30 +4,21 @@ import 'package:eatvio_app/core/widgets/animated_option_button.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class MyRecipesTab extends StatefulWidget { class MyRecipesTab extends StatelessWidget {
const MyRecipesTab({super.key, this.additionalOnTap}); const MyRecipesTab({
super.key,
required this.selectedIndex,
required this.onTabChanged,
});
final void Function()? additionalOnTap; final int selectedIndex;
final ValueChanged<int> onTabChanged;
@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();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context);
final tabTitles = [s.published, s.drafted];
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Row( child: Row(
@@ -44,18 +35,14 @@ class _MyRecipesTabState extends State<MyRecipesTab> {
children: [ children: [
...List.generate( ...List.generate(
2, 2,
(index) => (index) => Expanded(
Expanded( child: AnimatedOptionButton(
child: AnimatedOptionButton( textStyle: TextStyles.textSMedium,
textStyle: TextStyles.textSMedium, isSelected: selectedIndex == index,
isSelected: false, // state.selectedStateIndex == index, text: tabTitles[index],
text: tabTitles[index], onTap: () => onTabChanged(index),
onTap: () { ),
widget.additionalOnTap?.call(); ),
// context.read<MyPlansCubit>().changeSelectedStateIndex(index);
},
),
),
), ),
], ],
), ),

View File

@@ -10,8 +10,15 @@ import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:retrofit/retrofit.dart';
class MockCreateRecipeService extends Mock implements CreateRecipeService {} class MockCreateRecipeService extends Mock implements CreateRecipeService {}
final mockHttpResponse = HttpResponse(
null,
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
);
void main() { void main() {
setUpAll(() { setUpAll(() {
registerFallbackValue(FormData()); registerFallbackValue(FormData());
@@ -109,7 +116,7 @@ void main() {
test('deleteRecipe - удаляет рецепт', () async { test('deleteRecipe - удаляет рецепт', () async {
const slug = 'recipe-slug'; 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); await repository.deleteRecipe(slug);
@@ -188,7 +195,7 @@ void main() {
// Мокируем ImageCompressUtility.compressFile // Мокируем ImageCompressUtility.compressFile
// Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe // Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe
when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))) when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
// Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику // Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику
try { try {
@@ -216,7 +223,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -243,7 +250,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -270,7 +277,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -297,7 +304,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -364,7 +371,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -399,7 +406,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -435,7 +442,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -472,7 +479,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);
@@ -499,7 +506,7 @@ void main() {
); );
when(() => mockService.publishRecipe(formData: any(named: 'formData'))) when(() => mockService.publishRecipe(formData: any(named: 'formData')))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
try { try {
await repository.publishRecipe(recipe); await repository.publishRecipe(recipe);

View File

@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('MyRecipesTab smoke', () { group('MyRecipesTab smoke', () {
Widget wrap(Widget child) { Widget wrap() {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
localizationsDelegates: const [ localizationsDelegates: const [
@@ -17,12 +17,17 @@ void main() {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: const [Locale('ru')], supportedLocales: const [Locale('ru')],
home: Scaffold(body: child), home: Material(
child: MyRecipesTab(
selectedIndex: 0,
onTabChanged: (_) {},
),
),
); );
} }
testWidgets('renders my recipes tab', (tester) async { testWidgets('renders my recipes tab', (tester) async {
await tester.pumpWidget(wrap(const MyRecipesTab())); await tester.pumpWidget(wrap());
await tester.pump(); await tester.pump();
expect(find.byType(MyRecipesTab), findsOneWidget); expect(find.byType(MyRecipesTab), findsOneWidget);