From b9136a80de44d9cb5ce820d181b3c6fa01950ff4 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Fri, 17 Oct 2025 17:11:27 +0300 Subject: [PATCH] feat(tests); recipe create --- integration_test/create_recipe/README.md | 147 +++++++++++ .../create_recipe/create_recipe_helpers.dart | 237 ++++++++++++++++++ .../create_recipe/create_recipe_test.dart | 128 ++++++++++ lib/app_keys.dart | 22 ++ .../pages/recipe_create_screen.dart | 46 +--- .../widgets/info_about_recipe_widget.dart | 33 +-- .../widgets/ingredients_widget.dart | 42 +--- .../presentation/widgets/portion_widget.dart | 11 +- .../presentation/widgets/steps_widget.dart | 40 +-- .../presentation/widgets/tags_widget.dart | 23 +- .../widgets/time_of_cook_widget.dart | 45 +--- 11 files changed, 606 insertions(+), 168 deletions(-) create mode 100644 integration_test/create_recipe/README.md create mode 100644 integration_test/create_recipe/create_recipe_helpers.dart create mode 100644 integration_test/create_recipe/create_recipe_test.dart diff --git a/integration_test/create_recipe/README.md b/integration_test/create_recipe/README.md new file mode 100644 index 00000000..6c9d04b3 --- /dev/null +++ b/integration_test/create_recipe/README.md @@ -0,0 +1,147 @@ +# Интеграционные тесты создания рецепта + +Этот набор интеграционных тестов покрывает полный пользовательский путь создания нового рецепта в приложении Eatvio. + +## Структура файлов + +- `create_recipe_test.dart` - Основные интеграционные тесты +- `create_recipe_helpers.dart` - Helper функции для тестирования +- `README.md` - Документация + +## Тестовые сценарии + +### 1. Полное создание рецепта +**Файл:** `create_recipe_test.dart` - `should create and publish a new recipe successfully` + +**Описание:** Тестирует полный пользовательский путь создания рецепта с заполнением всех полей. + +**Шаги:** +1. Запуск приложения и вход в систему +2. Навигация к экрану создания рецепта +3. Заполнение основной информации (название, тип, порции, калории, время) +4. Добавление тегов +5. Добавление ингредиентов +6. Добавление шагов приготовления +7. Симуляция добавления изображения +8. Публикация рецепта +9. Проверка успешного перехода + +### 2. Валидация полей +**Файл:** `create_recipe_test.dart` - `should validate required fields before publishing` + +**Описание:** Проверяет, что валидация обязательных полей работает корректно. + +**Шаги:** +1. Запуск приложения и вход в систему +2. Навигация к экрану создания рецепта +3. Попытка публикации без заполнения полей +4. Проверка, что остались на экране создания (валидация не прошла) + +### 3. Минимальное создание рецепта +**Файл:** `create_recipe_test.dart` - `should create recipe with minimal required fields` + +**Описание:** Тестирует создание рецепта с минимально необходимыми полями. + +**Шаги:** +1. Запуск приложения и вход в систему +2. Навигация к экрану создания рецепта +3. Заполнение только обязательных полей +4. Добавление минимума ингредиентов +5. Симуляция добавления изображения +6. Публикация рецепта +7. Проверка успешного перехода + +## Helper функции + +### Навигация +- `navigateToCreateRecipeScreen()` - Переход к экрану создания рецепта + +### Заполнение формы +- `fillRecipeBasicInfo()` - Заполнение основной информации о рецепте +- `selectRecipeType()` - Выбор типа рецепта +- `addTags()` - Добавление тегов +- `addIngredients()` - Добавление ингредиентов +- `addCookingSteps()` - Добавление шагов приготовления + +### Публикация и проверки +- `publishRecipe()` - Публикация рецепта +- `verifyRecipePublished()` - Проверка успешной публикации +- `verifyValidationErrors()` - Проверка ошибок валидации + +### Симуляция изображений +- `mockImageSelection()` - Симуляция выбора изображения для рецепта +- `mockStepImageSelection()` - Симуляция выбора изображения для шага + +### Тестовые данные +- `createTestRecipeData()` - Создание набора тестовых данных + +## Ключи для тестирования + +В файле `lib/app_keys.dart` добавлены следующие ключи для стабильного тестирования: + +### Основные поля +- `recipeCreateScreenTitleField` - Поле названия +- `recipeCreateScreenDescriptionField` - Поле описания +- `recipeCreateScreenTypeDropdown` - Выпадающий список типа рецепта +- `recipeCreateScreenPortionsField` - Поле количества порций +- `recipeCreateScreenCaloriesField` - Поле калорийности + +### Время приготовления +- `recipeCreateScreenTotalHoursField` - Часы общего времени +- `recipeCreateScreenTotalMinutesField` - Минуты общего времени +- `recipeCreateScreenActiveHoursField` - Часы активного времени +- `recipeCreateScreenActiveMinutesField` - Минуты активного времени + +### Теги и ингредиенты +- `recipeCreateScreenTagField` - Поле для ввода тега +- `recipeCreateScreenAddTagButton` - Кнопка добавления тега +- `recipeCreateScreenAddIngredientButton` - Кнопка добавления ингредиента + +### Шаги приготовления +- `recipeCreateScreenAddStepButton` - Кнопка добавления шага +- `recipeCreateScreenStepDescriptionField` - Поле описания шага +- `recipeCreateScreenStepImageWidget` - Виджет изображения шага + +### Изображения и публикация +- `recipeCreateScreenImageWidget` - Виджет основного изображения +- `recipeCreateScreenPublishButton` - Кнопка публикации + +## Запуск тестов + +```bash +# Запуск всех интеграционных тестов +flutter test integration_test/ + +# Запуск только тестов создания рецепта +flutter test integration_test/create_recipe/ + +# Запуск конкретного теста +flutter test integration_test/create_recipe/create_recipe_test.dart +``` + +## Требования + +- Flutter SDK +- Настроенная тестовая среда (AppBuildType.tests) +- Тестовые учетные данные в .env файле +- Доступ к API бэкенда для тестирования + +## Примечания + +1. **Изображения**: В текущей реализации используется симуляция выбора изображений. Для полноценного тестирования рекомендуется добавить моки для `image_picker`. + +2. **API взаимодействие**: Тесты взаимодействуют с реальным API бэкенда. Убедитесь, что тестовая среда настроена корректно. + +3. **Время выполнения**: Тесты могут занимать значительное время из-за анимаций и API запросов. Используйте `pumpAndSettle()` с достаточными таймаутами. + +4. **Стабильность**: Ключи для тестирования добавлены в UI компоненты для обеспечения стабильности тестов при изменениях в интерфейсе. + +## Расширение тестов + +Для добавления новых тестовых сценариев: + +1. Создайте новый `testWidgets` в `create_recipe_test.dart` +2. Используйте существующие helper функции или создайте новые в `create_recipe_helpers.dart` +3. При необходимости добавьте новые ключи в `app_keys.dart` +4. Обновите документацию в этом файле + diff --git a/integration_test/create_recipe/create_recipe_helpers.dart b/integration_test/create_recipe/create_recipe_helpers.dart new file mode 100644 index 00000000..3a7dbb3e --- /dev/null +++ b/integration_test/create_recipe/create_recipe_helpers.dart @@ -0,0 +1,237 @@ +import 'package:eatvio_app/app_keys.dart'; +import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Helper функции для интеграционных тестов создания рецепта + +/// Навигация к экрану создания рецепта +Future navigateToCreateRecipeScreen(WidgetTester tester) async { + // Переключаемся на вкладку "Рецепты" если она есть + final recipesTab = find.text('Рецепты'); + if (recipesTab.evaluate().isNotEmpty) { + await tester.tap(recipesTab); + await tester.pumpAndSettle(); + } + + // Находим кнопку добавления рецепта (круглая кнопка с плюсом) + final addRecipeButton = find.byType(InkWell).first; + await tester.tap(addRecipeButton); + await tester.pumpAndSettle(const Duration(seconds: 2)); +} + +/// Заполняет основную информацию о рецепте +Future fillRecipeBasicInfo( + WidgetTester tester, { + String title = 'Интеграционный Тестовый Рецепт', + TypeOfRecipe type = TypeOfRecipe.salads, + int portions = 4, + double calories = 250.0, + int totalHours = 1, + int totalMinutes = 30, + int activeHours = 0, + int activeMinutes = 45, + String description = 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.', +}) async { + // Заполняем название рецепта + final titleField = find.byKey(AppKeys.recipeCreateScreenTitleField); + await tester.enterText(titleField, title); + await tester.pumpAndSettle(); + + // Выбираем тип рецепта + await selectRecipeType(tester, type); + + // Заполняем количество порций + final portionsField = find.byKey(AppKeys.recipeCreateScreenPortionsField); + await tester.enterText(portionsField, portions.toString()); + await tester.pumpAndSettle(); + + // Заполняем калорийность + final caloriesField = find.byKey(AppKeys.recipeCreateScreenCaloriesField); + await tester.enterText(caloriesField, calories.toString()); + await tester.pumpAndSettle(); + + // Заполняем общее время приготовления + final totalHoursField = find.byKey(AppKeys.recipeCreateScreenTotalHoursField); + await tester.enterText(totalHoursField, totalHours.toString()); + await tester.pumpAndSettle(); + + final totalMinutesField = find.byKey(AppKeys.recipeCreateScreenTotalMinutesField); + await tester.enterText(totalMinutesField, totalMinutes.toString()); + await tester.pumpAndSettle(); + + // Заполняем активное время приготовления + final activeHoursField = find.byKey(AppKeys.recipeCreateScreenActiveHoursField); + await tester.enterText(activeHoursField, activeHours.toString()); + await tester.pumpAndSettle(); + + final activeMinutesField = find.byKey(AppKeys.recipeCreateScreenActiveMinutesField); + await tester.enterText(activeMinutesField, activeMinutes.toString()); + await tester.pumpAndSettle(); + + // Заполняем описание + final descriptionField = find.byKey(AppKeys.recipeCreateScreenDescriptionField); + await tester.enterText(descriptionField, description); + await tester.pumpAndSettle(); +} + +/// Выбирает тип рецепта из выпадающего списка +Future selectRecipeType(WidgetTester tester, TypeOfRecipe type) async { + final typeDropdown = find.byKey(AppKeys.recipeCreateScreenTypeDropdown); + await tester.tap(typeDropdown); + await tester.pumpAndSettle(); + + // Выбираем нужный тип из выпадающего списка + final typeOption = find.text(type.toString()); + await tester.tap(typeOption); + await tester.pumpAndSettle(); +} + +/// Добавляет теги к рецепту +Future addTags(WidgetTester tester, List tags) async { + final tagField = find.byKey(AppKeys.recipeCreateScreenTagField); + final addTagButton = find.byKey(AppKeys.recipeCreateScreenAddTagButton); + + for (final tag in tags) { + await tester.enterText(tagField, tag); + await tester.pumpAndSettle(); + await tester.tap(addTagButton); + await tester.pumpAndSettle(); + } +} + +/// Добавляет ингредиенты к рецепту +Future addIngredients(WidgetTester tester, List> ingredients) async { + final addIngredientButton = find.byKey(AppKeys.recipeCreateScreenAddIngredientButton); + + for (final ingredient in ingredients) { + await tester.ensureVisible(addIngredientButton); + await tester.tap(addIngredientButton); + await tester.pumpAndSettle(); + + await addIngredientInModal(tester, ingredient['name']!, ingredient['quantity']!); + await tester.pumpAndSettle(); + } +} + +/// Добавляет ингредиент в модальном окне +Future addIngredientInModal(WidgetTester tester, String name, String quantity) async { + // Ищем поля в модальном окне + final nameFields = find.byType(TextFormField); + if (nameFields.evaluate().isNotEmpty) { + await tester.enterText(nameFields.first, name); + await tester.pumpAndSettle(); + } + + // Ищем поле количества (второе поле) + if (nameFields.evaluate().length > 1) { + await tester.enterText(nameFields.at(1), quantity); + await tester.pumpAndSettle(); + } + + // Ищем кнопку добавления в модальном окне + final addButton = find.text('Добавить'); + if (addButton.evaluate().isNotEmpty) { + await tester.tap(addButton); + await tester.pumpAndSettle(); + } +} + +/// Добавляет шаги приготовления +Future addCookingSteps(WidgetTester tester, List> steps) async { + final addStepButton = find.byKey(AppKeys.recipeCreateScreenAddStepButton); + + for (int i = 0; i < steps.length; i++) { + final step = steps[i]; + + // Заполняем описание шага + final stepDescription = find.byKey(Key('${AppKeys.recipeCreateScreenStepDescriptionField}_$i')); + await tester.enterText(stepDescription, step['description']!); + await tester.pumpAndSettle(); + + // Симулируем добавление изображения к шагу если указано + if (step.containsKey('hasImage') && step['hasImage'] == 'true') { + await mockStepImageSelection(tester, i); + } + + // Добавляем следующий шаг (кроме последнего) + if (i < steps.length - 1) { + await tester.ensureVisible(addStepButton); + await tester.tap(addStepButton); + await tester.pumpAndSettle(); + } + } +} + +/// Публикует рецепт +Future publishRecipe(WidgetTester tester) async { + final publishButton = find.byKey(AppKeys.recipeCreateScreenPublishButton); + await tester.ensureVisible(publishButton); + await tester.tap(publishButton); + await tester.pumpAndSettle(const Duration(seconds: 5)); // Дать время на API-запрос +} + +/// Симулирует выбор изображения для основного рецепта +Future mockImageSelection(WidgetTester tester) async { + final imageWidget = find.byKey(AppKeys.recipeCreateScreenImageWidget); + if (imageWidget.evaluate().isNotEmpty) { + await tester.tap(imageWidget); + await tester.pumpAndSettle(); + + // В реальном интеграционном тесте здесь будет мок image_picker + // который возвращает тестовое изображение + } +} + +/// Симулирует выбор изображения для шага приготовления +Future mockStepImageSelection(WidgetTester tester, int stepIndex) async { + final stepImageWidget = find.byKey(Key('${AppKeys.recipeCreateScreenStepImageWidget}_$stepIndex')); + if (stepImageWidget.evaluate().isNotEmpty) { + await tester.tap(stepImageWidget); + await tester.pumpAndSettle(); + + // В реальном интеграционном тесте здесь будет мок image_picker + // который возвращает тестовое изображение + } +} + +/// Проверяет, что рецепт был успешно опубликован +void verifyRecipePublished(WidgetTester tester) { + // После успешной публикации должен произойти переход обратно на экран ленты + expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsNothing); + // Можно добавить дополнительные проверки в зависимости от того, куда происходит переход +} + +/// Проверяет, что валидация полей работает корректно +void verifyValidationErrors(WidgetTester tester) { + // Проверяем, что остались на экране создания рецепта (валидация не прошла) + expect(find.byType(RecipeCreateScreen), findsOneWidget); + expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsOneWidget); +} + +/// Создает тестовые данные для рецепта +Map createTestRecipeData() { + return { + 'title': 'Интеграционный Тестовый Рецепт', + 'type': TypeOfRecipe.salads, + 'portions': 4, + 'calories': 250.0, + 'totalHours': 1, + 'totalMinutes': 30, + 'activeHours': 0, + 'activeMinutes': 45, + 'description': 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.', + 'tags': ['здоровое питание', 'быстро', 'легко'], + 'ingredients': [ + {'name': 'Помидоры', 'quantity': '200'}, + {'name': 'Огурцы', 'quantity': '150'}, + {'name': 'Лук', 'quantity': '50'}, + ], + 'steps': [ + {'description': 'Помойте и нарежьте помидоры и огурцы кубиками среднего размера.', 'hasImage': 'true'}, + {'description': 'Нарежьте лук полукольцами и добавьте к овощам.', 'hasImage': 'false'}, + {'description': 'Смешайте все ингредиенты в большой миске, добавьте соль и перец по вкусу.', 'hasImage': 'true'}, + ], + }; +} diff --git a/integration_test/create_recipe/create_recipe_test.dart b/integration_test/create_recipe/create_recipe_test.dart new file mode 100644 index 00000000..46a7b08e --- /dev/null +++ b/integration_test/create_recipe/create_recipe_test.dart @@ -0,0 +1,128 @@ +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; +import 'package:eatvio_app/main.dart' as app; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../common_cases/skip_welcome_screen.dart'; +import '../common_cases/skip_sign_in.dart'; +import 'create_recipe_helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await AppInitializer.initialize(buildType: AppBuildType.tests); + }); + + group('Create Recipe Integration Tests', () { + testWidgets('should create and publish a new recipe successfully', (tester) async { + // 1. Запустить приложение + await tester.pumpWidget(const app.MyApp()); + await tester.pumpAndSettle(const Duration(seconds: 20)); + + // 2. Пропустить онбординг и войти в систему + await skipWelcomeScreen(tester); + await skipSignIn(tester); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // 3. Перейти на экран создания рецепта + await navigateToCreateRecipeScreen(tester); + + // Проверяем, что мы на экране создания рецепта + expect(find.byType(RecipeCreateScreen), findsOneWidget); + + // 4. Заполнить все поля формы + final testData = createTestRecipeData(); + await fillRecipeBasicInfo( + tester, + title: testData['title'] as String, + type: testData['type'] as TypeOfRecipe, + portions: testData['portions'] as int, + calories: testData['calories'] as double, + totalHours: testData['totalHours'] as int, + totalMinutes: testData['totalMinutes'] as int, + activeHours: testData['activeHours'] as int, + activeMinutes: testData['activeMinutes'] as int, + description: testData['description'] as String, + ); + + // 5. Добавить теги + await addTags(tester, testData['tags'] as List); + + // 6. Добавить ингредиенты + await addIngredients(tester, testData['ingredients'] as List>); + + // 7. Добавить шаги приготовления + await addCookingSteps(tester, testData['steps'] as List>); + + // 8. Симулировать добавление изображения + await mockImageSelection(tester); + + // 9. Нажать "Опубликовать" + await publishRecipe(tester); + + // 10. Проверить результат + verifyRecipePublished(tester); + }); + + testWidgets('should validate required fields before publishing', (tester) async { + // 1. Запустить приложение и войти в систему + await tester.pumpWidget(const app.MyApp()); + await tester.pumpAndSettle(const Duration(seconds: 3)); + await skipWelcomeScreen(tester); + await skipSignIn(tester); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // 2. Перейти на экран создания рецепта + await navigateToCreateRecipeScreen(tester); + + // 3. Попытаться опубликовать без заполнения полей + await publishRecipe(tester); + + // 4. Проверить, что валидация сработала + verifyValidationErrors(tester); + }); + + testWidgets('should create recipe with minimal required fields', (tester) async { + // 1. Запустить приложение и войти в систему + await tester.pumpWidget(const app.MyApp()); + await tester.pumpAndSettle(const Duration(seconds: 3)); + await skipWelcomeScreen(tester); + await skipSignIn(tester); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // 2. Перейти на экран создания рецепта + await navigateToCreateRecipeScreen(tester); + + // 3. Заполнить только обязательные поля + await fillRecipeBasicInfo( + tester, + title: 'Минимальный тестовый рецепт', + type: TypeOfRecipe.firstMeals, + portions: 2, + calories: 300.0, + totalHours: 0, + totalMinutes: 30, + activeHours: 0, + activeMinutes: 15, + description: 'Простой рецепт для тестирования.', + ); + + // 4. Добавить минимум ингредиентов + await addIngredients(tester, [ + {'name': 'Тестовый ингредиент', 'quantity': '100'}, + ]); + + // 5. Симулировать добавление изображения + await mockImageSelection(tester); + + // 6. Опубликовать рецепт + await publishRecipe(tester); + + // 7. Проверить результат + verifyRecipePublished(tester); + }); + }); +} diff --git a/lib/app_keys.dart b/lib/app_keys.dart index 6c497417..912dab9b 100644 --- a/lib/app_keys.dart +++ b/lib/app_keys.dart @@ -19,4 +19,26 @@ class AppKeys { static const blogPostCreateScreenVideoLinkTextField = ValueKey('blogPostCreateScreenVideoLinkTextField'); static const blogPostCreateScreenCreateButton = ValueKey('blogPostCreateScreenCreateButton'); static const imageWidget = ValueKey('imageWidget'); + + // Recipe Create Screen + static const recipeCreateScreenTitleField = ValueKey('recipeCreateScreenTitleField'); + static const recipeCreateScreenDescriptionField = ValueKey('recipeCreateScreenDescriptionField'); + static const recipeCreateScreenTypeDropdown = ValueKey('recipeCreateScreenTypeDropdown'); + static const recipeCreateScreenPortionsField = ValueKey('recipeCreateScreenPortionsField'); + static const recipeCreateScreenCaloriesField = ValueKey('recipeCreateScreenCaloriesField'); + static const recipeCreateScreenTotalHoursField = ValueKey('recipeCreateScreenTotalHoursField'); + static const recipeCreateScreenTotalMinutesField = ValueKey('recipeCreateScreenTotalMinutesField'); + static const recipeCreateScreenActiveHoursField = ValueKey('recipeCreateScreenActiveHoursField'); + static const recipeCreateScreenActiveMinutesField = ValueKey('recipeCreateScreenActiveMinutesField'); + static const recipeCreateScreenTagField = ValueKey('recipeCreateScreenTagField'); + static const recipeCreateScreenAddTagButton = ValueKey('recipeCreateScreenAddTagButton'); + static const recipeCreateScreenAddIngredientButton = ValueKey('recipeCreateScreenAddIngredientButton'); + static const recipeCreateScreenAddStepButton = ValueKey('recipeCreateScreenAddStepButton'); + static const recipeCreateScreenPublishButton = ValueKey('recipeCreateScreenPublishButton'); + static const recipeCreateScreenImageWidget = ValueKey('recipeCreateScreenImageWidget'); + static const recipeCreateScreenStepDescriptionField = ValueKey('recipeCreateScreenStepDescriptionField'); + static const recipeCreateScreenStepImageWidget = ValueKey('recipeCreateScreenStepImageWidget'); + static const recipeCreateScreenIngredientNameField = ValueKey('recipeCreateScreenIngredientNameField'); + static const recipeCreateScreenIngredientQuantityField = ValueKey('recipeCreateScreenIngredientQuantityField'); + static const recipeCreateScreenIngredientAddButton = ValueKey('recipeCreateScreenIngredientAddButton'); } 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 76683d79..d230bd1a 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; @@ -18,10 +19,7 @@ import 'package:go_router/go_router.dart'; class RecipeCreateScreen extends StatefulWidget { final Recipe? recipe; - const RecipeCreateScreen({ - super.key, - this.recipe, - }); + const RecipeCreateScreen({super.key, this.recipe}); static const name = 'recipeCreateScreen'; static const path = '/recipe-create-screen'; @@ -52,20 +50,14 @@ class _RecipeCreateScreenState extends State { child: BlocBuilder( builder: (BuildContext context, CreateRecipeState state) { if (state.loading) { - return const Scaffold( - body: Loader(), - ); + return const Scaffold(body: Loader()); } else { return Scaffold( backgroundColor: const Color(0xFFF0F2F5), appBar: AppBar( title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'), centerTitle: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(12), - ), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))), ), body: SingleChildScrollView( physics: const BouncingScrollPhysics(), @@ -73,24 +65,21 @@ class _RecipeCreateScreenState extends State { child: Column( spacing: 16, children: [ - InfoAboutRecipeWidget( - createRecipeCubit: createRecipeCubit, - ), + InfoAboutRecipeWidget(createRecipeCubit: createRecipeCubit), const IngredientsWidget(), - StepsWidget( - createRecipeCubit: createRecipeCubit, - ), + StepsWidget(createRecipeCubit: createRecipeCubit), BlocBuilder( builder: (context, state) { return Builder( builder: (context) { return InkWell( + key: AppKeys.recipeCreateScreenPublishButton, onTap: () async { if (context.read().validateAll()) { final isSuccess = await context.read().publishRecipe( - widget.recipe == null ? false : true, - widget.recipe?.slug, - ); + widget.recipe == null ? false : true, + widget.recipe?.slug, + ); if (isSuccess) { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; @@ -98,9 +87,7 @@ class _RecipeCreateScreenState extends State { showBottomSheetWithBlur( userRootNavigator: true, context: context, - child: SuccessPublishedRecipeBottomSheet( - title: state.entity.title ?? 'Рецепт', - ), + child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'), ); }); } else { @@ -112,20 +99,13 @@ class _RecipeCreateScreenState extends State { 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: 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, - ), + style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), ), ), ), diff --git a/lib/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart b/lib/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart index 38a4c4ee..cee7b49e 100644 --- a/lib/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_drop_down.dart'; @@ -58,6 +59,7 @@ class _InfoAboutRecipeWidgetState extends State { return Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenTitleField, hintText: 'Например: тар-тар из говядины', hasError: state.titleValidationError != null, initialValue: state.entity.title, @@ -68,12 +70,7 @@ class _InfoAboutRecipeWidgetState extends State { } }, ), - if (state.titleValidationError != null) ...[ - const SizedBox(height: 4), - ValidationErrorWidget( - error: state.titleValidationError!, - ), - ], + if (state.titleValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.titleValidationError!)], ], ); }, @@ -82,15 +79,14 @@ class _InfoAboutRecipeWidgetState extends State { const Text('Тип блюда'), const SizedBox(height: 4), CustomDropDown( + key: AppKeys.recipeCreateScreenTypeDropdown, items: items, value: selectedType, onChanged: (newSelectedType) { setState(() { selectedType = newSelectedType; }); - widget.createRecipeCubit.changeType( - TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other, - ); + widget.createRecipeCubit.changeType(TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other); }, ), const SizedBox(height: 12), @@ -108,6 +104,7 @@ class _InfoAboutRecipeWidgetState extends State { return Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenDescriptionField, hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.', minLines: 5, initialValue: state.entity.description, @@ -122,9 +119,7 @@ class _InfoAboutRecipeWidgetState extends State { ), if (state.descriptionValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.descriptionValidationError!, - ), + ValidationErrorWidget(error: state.descriptionValidationError!), ], ], ); @@ -140,16 +135,14 @@ class _InfoAboutRecipeWidgetState extends State { children: [ Container( decoration: state.imageValidationError != null - ? BoxDecoration(border: Border.all(width: 1, color: appTheme.red), borderRadius: BorderRadius.circular(8)) + ? BoxDecoration( + border: Border.all(width: 1, color: appTheme.red), + borderRadius: BorderRadius.circular(8), + ) : null, - child: ImageWidget(image: state.entity.image), + child: ImageWidget(key: AppKeys.recipeCreateScreenImageWidget, image: state.entity.image), ), - if (state.imageValidationError != null) ...[ - const SizedBox(height: 4), - ValidationErrorWidget( - error: state.imageValidationError!, - ), - ], + if (state.imageValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.imageValidationError!)], ], ); }, diff --git a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart index 06cc7c1f..d9769505 100644 --- a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/theme/theme_helper.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/bloc/create_recipe_cubit/create_recipe_state.dart'; @@ -40,11 +41,7 @@ class _IngredientsWidgetState extends State { padding: EdgeInsets.symmetric(horizontal: 16), child: Text( 'Ингредиенты ', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF323F4B), - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), ), ), ReorderableListView( @@ -63,46 +60,29 @@ class _IngredientsWidgetState extends State { ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16), key: Key('$index'), - leading: Icon( - Icons.drag_handle_rounded, - color: Colors.grey.shade600, - ), + leading: Icon(Icons.drag_handle_rounded, color: Colors.grey.shade600), title: Text(state.entity.ingredients[index].name), subtitle: Text('${state.entity.ingredients[index].portion} ${state.entity.ingredients[index].unit == 'gr' ? 'гр' : 'шт'}'), trailing: IconButton( onPressed: () { createRecipeCubit.removeIngredient(index); }, - icon: Icon( - Icons.delete_outline_rounded, - color: Colors.grey.shade600, - ), + icon: Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600), ), ), ], ), ListTile( + key: AppKeys.recipeCreateScreenAddIngredientButton, onTap: () { nameController.clear(); gramsController.clear(); pcsController.clear(); - _modalBottomSheet( - createRecipeCubit: createRecipeCubit, - ); + _modalBottomSheet(createRecipeCubit: createRecipeCubit); }, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - title: Text( - 'Добавить ингредиент', - style: TextStyle( - color: appTheme.blueA400, - ), - ), - leading: Icon( - Icons.add_rounded, - color: appTheme.blueA400, - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)), + leading: Icon(Icons.add_rounded, color: appTheme.blueA400), ), ], ), @@ -121,9 +101,7 @@ class _IngredientsWidgetState extends State { return showModalBottomSheet( useRootNavigator: true, context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))), isScrollControlled: true, builder: (context) { return AddPortionWidget( diff --git a/lib/features/create_recipe/presentation/widgets/portion_widget.dart b/lib/features/create_recipe/presentation/widgets/portion_widget.dart index abd593c2..0654c016 100644 --- a/lib/features/create_recipe/presentation/widgets/portion_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/portion_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; @@ -44,6 +45,7 @@ class _PortionWidgetState extends State { Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenPortionsField, hintText: '1', hasError: state.servingsValidationError != null, initialValue: state.entity.countOfPortions?.toString(), @@ -57,9 +59,7 @@ class _PortionWidgetState extends State { ), if (state.servingsValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.servingsValidationError!, - ), + ValidationErrorWidget(error: state.servingsValidationError!), ], ], ), @@ -77,6 +77,7 @@ class _PortionWidgetState extends State { Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenCaloriesField, hintText: '100', hasError: state.energyPerServingValidationError != null, initialValue: state.entity.energyInOnePortion?.toString(), @@ -91,9 +92,7 @@ class _PortionWidgetState extends State { ), if (state.energyPerServingValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.energyPerServingValidationError!, - ), + ValidationErrorWidget(error: state.energyPerServingValidationError!), ], ], ), diff --git a/lib/features/create_recipe/presentation/widgets/steps_widget.dart b/lib/features/create_recipe/presentation/widgets/steps_widget.dart index 07474c72..3d0d127a 100644 --- a/lib/features/create_recipe/presentation/widgets/steps_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/steps_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; @@ -22,9 +23,7 @@ class _StepsWidgetState extends State { bloc: widget.createRecipeCubit, builder: (BuildContext context, CreateRecipeState state) { return Material( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -32,11 +31,7 @@ class _StepsWidgetState extends State { children: [ const Text( 'Пошаговая инструкция', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF323F4B), - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), ), const SizedBox(height: 12), ListView.separated( @@ -49,20 +44,11 @@ class _StepsWidgetState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: appTheme.blueA400, - borderRadius: BorderRadius.circular(8), - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(8)), child: Text( 'Шаг ${index + 1}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), ), ), GestureDetector( @@ -77,6 +63,7 @@ class _StepsWidgetState extends State { const Text('Описание'), const SizedBox(height: 4), CustomTextFormField( + key: Key('${AppKeys.recipeCreateScreenStepDescriptionField}_$index'), hintText: 'Например: подготовьте ингредиенты', initialValue: widget.createRecipeCubit.state.entity.steps[index].description, maxLines: 5, @@ -89,6 +76,7 @@ class _StepsWidgetState extends State { const Text('Фото шага'), const SizedBox(height: 4), StepImageWidget( + key: Key('${AppKeys.recipeCreateScreenStepImageWidget}_$index'), index: index, createRecipeCubit: widget.createRecipeCubit, ), @@ -98,26 +86,20 @@ class _StepsWidgetState extends State { separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24), ), InkWell( + key: AppKeys.recipeCreateScreenAddStepButton, onTap: () { context.read().addStep(); }, 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: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), child: Container( padding: const EdgeInsets.all(16), alignment: Alignment.center, child: const Text( 'Добавить шаг', - style: TextStyle( - fontSize: 16, - color: Colors.white, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), ), ), ), diff --git a/lib/features/create_recipe/presentation/widgets/tags_widget.dart b/lib/features/create_recipe/presentation/widgets/tags_widget.dart index affc9e90..5356132e 100644 --- a/lib/features/create_recipe/presentation/widgets/tags_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/tags_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; @@ -26,9 +27,11 @@ class _TagsWidgetState extends State { const Text('Теги'), const SizedBox(height: 4), CustomTextFormField( + key: AppKeys.recipeCreateScreenTagField, hintText: 'Например: без майонеза', controller: tagController, suffix: IconButton( + key: AppKeys.recipeCreateScreenAddTagButton, onPressed: () { if (tagController.text.isNotEmpty) { context.read().addTag(tagController.text); @@ -37,10 +40,7 @@ class _TagsWidgetState extends State { }); } }, - icon: Icon( - Icons.done, - color: appTheme.blueA400, - ), + icon: Icon(Icons.done, color: appTheme.blueA400), ), ), const SizedBox(height: 12), @@ -56,22 +56,13 @@ class _TagsWidgetState extends State { borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Colors.grey.shade200, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: Colors.grey.shade200), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - state.entity.tags[i], - ), + Text(state.entity.tags[i]), const SizedBox(width: 4), - Icon( - Icons.delete_outline_rounded, - color: Colors.grey.shade600, - size: 20, - ), + Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600, size: 20), ], ), ), diff --git a/lib/features/create_recipe/presentation/widgets/time_of_cook_widget.dart b/lib/features/create_recipe/presentation/widgets/time_of_cook_widget.dart index d995207d..7d9c9990 100644 --- a/lib/features/create_recipe/presentation/widgets/time_of_cook_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/time_of_cook_widget.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; @@ -43,20 +44,12 @@ class _TimeOfCookWidgetState extends State { builder: (context) => const AlertDialog( content: Text( 'Общее время приготовления - это период времени от начала приготовления блюда до подачи его на стол.', - style: TextStyle( - fontWeight: FontWeight.w500, - color: Color(0xFF323F4B), - height: 1.2, - ), + style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2), ), ), ); }, - child: Icon( - Icons.help_outline_rounded, - color: appTheme.blueA400, - size: 20, - ), + child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20), ), ], ), @@ -68,6 +61,7 @@ class _TimeOfCookWidgetState extends State { child: Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenTotalHoursField, hintText: 'Часов', hasError: state.cookingTimeValidationError != null, textInputType: TextInputType.number, @@ -83,9 +77,7 @@ class _TimeOfCookWidgetState extends State { ), if (state.cookingTimeValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.cookingTimeValidationError!, - ), + ValidationErrorWidget(error: state.cookingTimeValidationError!), ], ], ), @@ -95,6 +87,7 @@ class _TimeOfCookWidgetState extends State { child: Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenTotalMinutesField, hintText: 'Минут', hasError: state.cookingTimeValidationError != null, textInputType: TextInputType.number, @@ -110,9 +103,7 @@ class _TimeOfCookWidgetState extends State { ), if (state.cookingTimeValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.cookingTimeValidationError!, - ), + ValidationErrorWidget(error: state.cookingTimeValidationError!), ], ], ), @@ -131,20 +122,12 @@ class _TimeOfCookWidgetState extends State { builder: (context) => const AlertDialog( content: Text( 'Активное время приготовления - это период времени, требующий активного участия в приготовлении блюда. Оно включает мытье и очистку продуктов, нарезку, шинковку, взбивание, перемешивание, размешивание, перекладывание и т.п.', - style: TextStyle( - fontWeight: FontWeight.w500, - color: Color(0xFF323F4B), - height: 1.2, - ), + style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2), ), ), ); }, - child: Icon( - Icons.help_outline_rounded, - color: appTheme.blueA400, - size: 20, - ), + child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20), ), ], ), @@ -156,6 +139,7 @@ class _TimeOfCookWidgetState extends State { child: Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenActiveHoursField, hintText: 'Часов', hasError: state.activeTimeValidationError != null, initialValue: state.entity.activeCookHours?.toString(), @@ -172,9 +156,7 @@ class _TimeOfCookWidgetState extends State { ), if (state.activeTimeValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.activeTimeValidationError!, - ), + ValidationErrorWidget(error: state.activeTimeValidationError!), ], ], ), @@ -184,6 +166,7 @@ class _TimeOfCookWidgetState extends State { child: Column( children: [ CustomTextFormField( + key: AppKeys.recipeCreateScreenActiveMinutesField, hintText: 'Минут', hasError: state.activeTimeValidationError != null, initialValue: state.entity.activeCookMinutes?.toString(), @@ -200,9 +183,7 @@ class _TimeOfCookWidgetState extends State { ), if (state.activeTimeValidationError != null) ...[ const SizedBox(height: 4), - ValidationErrorWidget( - error: state.activeTimeValidationError!, - ), + ValidationErrorWidget(error: state.activeTimeValidationError!), ], ], ),