feat(recipe): drafts

This commit is contained in:
mso7nueh
2026-02-18 18:04:01 +03:00
parent 75983818d1
commit 9753644570
6 changed files with 127 additions and 39 deletions

4
.gitignore vendored
View File

@@ -20,4 +20,6 @@ env/.env
.env
android/key.properties
app/upload-keystore.jks
coverage/
coverage/
.fvm/
.fvmrc

File diff suppressed because one or more lines are too long

View File

@@ -186,7 +186,22 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
bool validateForDraft() {
return validateAll();
final isTitleValid = validateTitle();
_clearNonTitleValidationErrors();
return isTitleValid;
}
void _clearNonTitleValidationErrors() {
emit(state.copyWith(
descriptionValidationError: null,
cookingTimeValidationError: null,
activeTimeValidationError: null,
servingsValidationError: null,
energyPerServingValidationError: null,
imageValidationError: null,
stepsValidationError: null,
ingredientsValidationError: null,
));
}
Future<File> downloadImageAsFile(String imageUrl) async {

View File

@@ -24,40 +24,42 @@ class RecipeRepository {
}
}
Future<FormData> _buildFormData(RecipeModel recipe, {String state = 'active'}) async {
Future<List> _buildStepsMap(List<Step?>? steps) async {
final stepsMap = [];
for (final Step? step in 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 compressed = await ImageCompressUtility.compressFile(File(step.image ?? ''));
final multipartFile = await MultipartFile.fromFile(compressed.path);
stepsMap.add({
'description_md': step.description ?? '',
'image': multipartFile,
});
}
}
}
return stepsMap;
}
Future<FormData> _buildPublishFormData(RecipeModel recipe) 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 ingredientsMap = recipe.ingredients
?.map((i) => i?.toJson())
.whereType<Map<String, dynamic>>()
.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 stepsMap = await _buildStepsMap(recipe.steps);
final formMap = {
'type': recipe.type,
'state': state,
'title': recipe.title,
'tags[]': recipe.tags ?? [],
'description_md': recipe.description,
@@ -74,9 +76,41 @@ class RecipeRepository {
return FormData.fromMap(formMap);
}
Future<FormData> _buildDraftFormData(RecipeModel recipe) async {
final ingredientsMap = recipe.ingredients
?.map((i) => i?.toJson())
.whereType<Map<String, dynamic>>()
.toList() ??
[];
final stepsMap = await _buildStepsMap(recipe.steps);
final formMap = <String, dynamic>{
'type': recipe.type,
'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,
'servings': recipe.servings,
'energy_per_serving': recipe.energyPerServing,
};
if (recipe.image != null && (recipe.image?.isNotEmpty ?? false)) {
final compressed = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
formMap['image'] = await MultipartFile.fromFile(compressed.path);
}
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');
final formData = await _buildPublishFormData(recipe);
if (update) {
await recipeService.updateRecipe(slug: slug!, formData: formData);
} else {
@@ -90,20 +124,12 @@ class RecipeRepository {
Future<void> saveAsDraft(RecipeModel recipe, [bool update = false, String? slug]) async {
try {
final formData = await _buildFormData(recipe, state: 'archived');
final formData = await _buildDraftFormData(recipe);
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);
}
await recipeService.updateRecipe(slug: slug!, formData: formData);
} 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);
}
await recipeService.createDraftedRecipe(formData: formData);
}
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);

View File

@@ -56,4 +56,10 @@ abstract class CreateRecipeService {
Future<void> draftRecipe({
@Path() required int id,
});
@POST('/api/recipes/create/drafted')
@MultiPart()
Future<HttpResponse<dynamic>> createDraftedRecipe({
@Body() required FormData formData,
});
}

View File

@@ -438,5 +438,44 @@ void main() {
expect(cubit.validateAll(), true);
});
// validateForDraft tests
test('validateForDraft - возвращает false когда title не задан', () {
expect(cubit.validateForDraft(), false);
expect(cubit.state.titleValidationError, 'Поле не заполнено');
});
test('validateForDraft - возвращает false когда title слишком короткий', () {
cubit.replaceTitle('AB');
expect(cubit.validateForDraft(), false);
expect(cubit.state.titleValidationError, 'Минимальная длина 3 символа ');
});
test('validateForDraft - возвращает true когда задан только title', () {
cubit.replaceTitle('Мой черновик');
expect(cubit.validateForDraft(), true);
expect(cubit.state.titleValidationError, isNull);
});
test('validateForDraft - сбрасывает ошибки остальных полей', () {
// Сначала вызываем validateAll, чтобы появились ошибки
cubit.validateAll();
expect(cubit.state.descriptionValidationError, isNotNull);
expect(cubit.state.imageValidationError, isNotNull);
// Теперь задаём title и вызываем validateForDraft
cubit.replaceTitle('Мой черновик');
final result = cubit.validateForDraft();
expect(result, true);
expect(cubit.state.descriptionValidationError, isNull);
expect(cubit.state.cookingTimeValidationError, isNull);
expect(cubit.state.activeTimeValidationError, isNull);
expect(cubit.state.servingsValidationError, isNull);
expect(cubit.state.energyPerServingValidationError, isNull);
expect(cubit.state.imageValidationError, isNull);
expect(cubit.state.stepsValidationError, isNull);
expect(cubit.state.ingredientsValidationError, isNull);
});
});
}