diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b35f4c6d..db73bc05 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -3,7 +3,7 @@ name: Eatvio App Android Test on: [ pull_request ] env: - flutter_version: "3.38.9" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" @@ -12,10 +12,10 @@ jobs: runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v3 + uses: https://github.com/actions/setup-java@v3 with: distribution: "zulu" java-version: ${{ env.java_version }} @@ -24,7 +24,7 @@ jobs: - name: Install Android SDK run: | apt-get update - apt-get install -y openjdk-11-jdk wget + apt-get install -y openjdk-11-jdk wget xz-utils tar wget https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip unzip commandlinetools.zip mkdir -p $HOME/Android/Sdk/cmdline-tools/latest @@ -44,7 +44,7 @@ jobs: apt-get install -y jq - id: flutter-action - uses: actions/flutter-action@v2 + uses: https://github.com/subosito/flutter-action@v2 with: channel: ${{ env.flutter_channel }} flutter-version: ${{ env.flutter_version }} @@ -79,7 +79,7 @@ jobs: echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV - name: Upload APK artifact - uses: actions/upload-artifact@v3 + uses: https://github.com/actions/upload-artifact@v3 with: name: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }} diff --git a/.gitea/workflows/docker-run-tests.yml b/.gitea/workflows/docker-run-tests.yml index 08fb5031..17902e45 100644 --- a/.gitea/workflows/docker-run-tests.yml +++ b/.gitea/workflows/docker-run-tests.yml @@ -3,7 +3,7 @@ name: Eatvio App Android Test on custom docker on: [ pull_request ] env: - flutter_version: "3.32.0" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" my_local_cache_dir: "/opt/hostedtoolcache/" @@ -15,7 +15,7 @@ jobs: runs-on: [ dev-office-flutter ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 - name: Set up Android environment variables id: "android-env-setup" @@ -52,7 +52,7 @@ jobs: # echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV # - name: Upload APK artifact - # uses: actions/upload-artifact@v3 + # uses: https://github.com/actions/upload-artifact@v3 # with: # name: ${{ env.APK_NAME }} # path: ${{ env.APK_NAME }} diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 7ca43518..0355bd9c 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -9,10 +9,10 @@ jobs: runs-on: [macos-arm64] steps: - name: Checkout - uses: actions/checkout@v2 + uses: https://github.com/actions/checkout@v2 - name: Checkout env repository - uses: actions/checkout@v2 + uses: https://github.com/actions/checkout@v2 with: repository: mso7nueh/eatvio_app_env path: env_repo diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f07c8dd3..27f86097 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -3,7 +3,7 @@ name: Eatvio App Android Test on: [ pull_request ] env: - flutter_version: "3.38.9" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" my_local_cache_dir: "/opt/hostedtoolcache/" @@ -11,13 +11,23 @@ env: jobs: build: - runs-on: [ dev-office ] + runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 + + - name: Set proxy server + env: + PROXY_USER: ${{ secrets.PROXY_USER }} + PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }} + run: | + echo "Setting up proxy..." + cat </etc/apt/apt.conf.d/proxy.conf + Acquire::http::Proxy "socks5h://${PROXY_USER}:${PROXY_PASSWORD}@10.55.5.202:19843/"; + EOF - name: Checkout env repository - uses: actions/checkout@v3 + uses: https://github.com/actions/checkout@v3 with: repository: mso7nueh/eatvio_app_env path: env_repo @@ -56,13 +66,13 @@ jobs: - name: Cache Android SDK id: cache-sdk - uses: "https://gitea.com/actions/cache@v4" + uses: "https://github.com/actions/cache@v4" with: path: ${{ env.my_local_cache_dir }} key: ${{ runner.os }}-cache-sdk - name: Set up JDK - uses: actions/setup-java@v3 + uses: https://github.com/actions/setup-java@v3 with: distribution: "zulu" java-version: ${{ env.java_version }} @@ -99,16 +109,23 @@ jobs: echo "PATH=$PATH:${{ env.my_local_cache_dir }}Android/Sdk/cmdline-tools/latest/bin:${{ env.my_local_cache_dir }}Android/Sdk/platform-tools:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/30.0.3:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/34.0.0" >> $GITHUB_ENV echo "ANDROID_HOME=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV - - id: flutter-action - uses: actions/flutter-action@v2 - with: - channel: ${{ env.flutter_channel }} - flutter-version: ${{ env.flutter_version }} - cache: true - cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache - cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path - pub-cache-key: "flutter-pub:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache of dart pub get dependencies - pub-cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path + + - name: Set up Flutter + run: | + FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter" + if [ ! -f "$FLUTTER_DIR/bin/flutter" ]; then + echo "Downloading Flutter ${{ env.flutter_version }}..." + curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/${{ env.flutter_channel }}/linux/flutter_linux_${{ env.flutter_version }}-${{ env.flutter_channel }}.tar.xz" + echo "Extracting Flutter..." + mkdir -p "${{ env.my_local_cache_dir }}" + tar xf /tmp/flutter.tar.xz -C "${{ env.my_local_cache_dir }}" + rm /tmp/flutter.tar.xz + else + echo "Flutter already cached at $FLUTTER_DIR" + fi + echo "$FLUTTER_DIR/bin" >> $GITHUB_PATH + echo "$FLUTTER_DIR/bin/cache/dart-sdk/bin" >> $GITHUB_PATH + echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH # - name: Print flutter-action outputs # shell: bash @@ -126,7 +143,9 @@ jobs: - name: Fix dir permissions run: | - git config --global --add safe.directory /opt/hostedtoolcache/flutter/${{ env.flutter_channel }}-${{ env.flutter_version }}-${{ steps.flutter-action.outputs.ARCHITECTURE }} + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then ARCH="x64"; fi + git config --global --add safe.directory "${{ env.my_local_cache_dir }}flutter" flutter config --android-sdk="$ANDROID_HOME" flutter doctor -v @@ -220,7 +239,7 @@ jobs: echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV - name: Upload APK artifact - uses: actions/upload-artifact@v3 + uses: https://github.com/actions/upload-artifact@v3 with: name: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }} diff --git a/.gitignore b/.gitignore index 1503b5ce..6b66540b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ env/.env android/key.properties app/upload-keystore.jks coverage/ -backend/ \ No newline at end of file +backend/ +.fvm/ +.fvmrc +/eatvio \ No newline at end of file diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 746fadde..379f47c9 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 7b12e0fb..6f26ab95 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -45,7 +45,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_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_cubit.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart'; import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; @@ -114,7 +114,7 @@ class CubitRegistrations { // User content cubits sl.registerFactory(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory(() => MyPlansCubit(myPlansRepository: sl())); - sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl())); + sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl())); // Other feature cubits sl.registerFactory(() => CalendarCubit(calendarRepository: sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 63640ac4..05c8e244 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -153,7 +153,16 @@ class OtherRoutes { GoRoute( path: RecipeCreateScreen.path, name: RecipeCreateScreen.name, - builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null), + builder: (context, state) { + if (state.extra is Map) { + final data = state.extra as Map; + return RecipeCreateScreen( + recipe: data['recipe'] as Recipe?, + autoValidate: data['autoValidate'] as bool? ?? false, + ); + } + return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null); + }, ), GoRoute( path: CreateDietScreen.path, diff --git a/lib/features/chat/presentation/widgets/about_order_button.dart b/lib/features/chat/presentation/widgets/about_order_button.dart index f32ac3f3..26fe8b86 100644 --- a/lib/features/chat/presentation/widgets/about_order_button.dart +++ b/lib/features/chat/presentation/widgets/about_order_button.dart @@ -14,7 +14,7 @@ class AboutOrderButton extends StatelessWidget { child: Container( height: 44, decoration: BoxDecoration( - color: appTheme.gray50, + color: const Color(0xFFFFFDD8), borderRadius: BorderRadius.circular(20), ), child: Row( diff --git a/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart b/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart index 809422c1..5b385a5c 100644 --- a/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart +++ b/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart @@ -29,7 +29,7 @@ class _ChosePlanDateWidget extends State { if (widget.hasPersonalDiet) { startDate = [DateTime.now().add(const Duration(days: 2))]; } else { - startDate = [DateTime.now()]; + startDate = [DateTime.now().add(const Duration(days: 1))]; } } diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 3a8203f9..c2a8069b 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -36,7 +36,7 @@ class _NotificationBannerState extends State { final s = S.of(context); return Container( decoration: BoxDecoration( - color: appTheme.gray50, + color: const Color(0xFFFFFDD8), borderRadius: BorderRadius.circular(20), ), child: Column( 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..e7537af7 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,38 @@ 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() { + 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 downloadImageAsFile(String imageUrl) async { final response = await http.get(Uri.parse(imageUrl)); @@ -239,6 +271,7 @@ class CreateRecipeCubit extends Cubit { final isActiveCookTimeValid = validateActiveCookTime(); final isEnergyPerServingsValid = validateEnergyPerServing(); + final isIngredientsValid = validateIngredients(); final isStepsValid = validateSteps(); return isTitleValid && @@ -248,8 +281,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 +387,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..a749186d 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -19,8 +19,9 @@ import 'package:go_router/go_router.dart'; class RecipeCreateScreen extends StatefulWidget { final Recipe? recipe; + final bool autoValidate; - const RecipeCreateScreen({super.key, this.recipe}); + const RecipeCreateScreen({super.key, this.recipe, this.autoValidate = false}); static const name = 'recipeCreateScreen'; static const path = '/recipe-create-screen'; @@ -40,6 +41,11 @@ class _RecipeCreateScreenState extends State { createRecipeCubit = sl(); if (widget.recipe != null) { createRecipeCubit.recipeToEntity(widget.recipe!); + if (widget.autoValidate) { + WidgetsBinding.instance.addPostFrameCallback((_) { + createRecipeCubit.validateAll(); + }); + } } else { createRecipeCubit.disableLoading(); } @@ -81,55 +87,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 && widget.recipe?.state != 'drafted') ? 'Обновить' : 'Опубликовать', + 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), + widget.recipe != null ? 'Обновить черновик' : 'Сохранить как черновик', + 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..6acc905b 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,94 @@ 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); - - 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, - }); - } + Future _buildStepsMap(List? 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, + }); } } - 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); + } + return stepsMap; + } + + Future _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((i) => i?.toJson()) + .whereType>() + .toList() ?? + []; + + final stepsMap = await _buildStepsMap(recipe.steps); + + final formMap = { + 'type': recipe.type, + 'title': recipe.title, + 'tags[]': recipe.tags ?? [], + 'state': 'active', + '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 _buildDraftFormData(RecipeModel recipe) async { + final ingredientsMap = recipe.ingredients + ?.map((i) => i?.toJson()) + .whereType>() + .toList() ?? + []; + + final stepsMap = await _buildStepsMap(recipe.steps); + + final formMap = { + '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 publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildPublishFormData(recipe); if (update) { await recipeService.updateRecipe(slug: slug!, formData: formData); } else { @@ -83,6 +123,30 @@ class RecipeRepository { } } + Future saveAsDraft(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildDraftFormData(recipe); + log(formData.fields.toString()); + if (update) { + await recipeService.updateRecipe(slug: slug!, formData: formData); + } else { + await recipeService.createDraftedRecipe(formData: formData); + } + } 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..4bed540a 100644 --- a/lib/features/create_recipe/services/recipe_service.dart +++ b/lib/features/create_recipe/services/recipe_service.dart @@ -33,7 +33,7 @@ abstract class CreateRecipeService { @POST('/api/recipes/create') @MultiPart() - Future publishRecipe({ + Future> publishRecipe({ @Body() required FormData formData, }); @@ -47,8 +47,19 @@ 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, + }); + + @POST('/api/recipes/create/drafted') + @MultiPart() + Future> createDraftedRecipe({ + @Body() required FormData formData, + }); } diff --git a/lib/features/recipe/models/my_recipe_model.dart b/lib/features/recipe/models/my_recipe_model.dart index f7068938..c3f82b17 100644 --- a/lib/features/recipe/models/my_recipe_model.dart +++ b/lib/features/recipe/models/my_recipe_model.dart @@ -1,4 +1,8 @@ +import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; +import 'package:eatvio_app/features/create_recipe/models/step.dart' as cr_step; +import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as cr_ingredient; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'my_recipe_model.g.dart'; @@ -34,3 +38,72 @@ abstract class MyRecipeModel with _$MyRecipeModel { factory MyRecipeModel.fromJson(Map json) => _$MyRecipeModelFromJson(json); } + +extension MyRecipeModelX on MyRecipeModel { + RecipeModel toRecipeModel() { + return RecipeModel( + title: title, + description: description, + image: image, + totalCookingTime: cookingTime, + activeCookTime: activeCookingTime, + energyPerServing: calories.toInt(), + videoUrl: videoUrl, + // The API expects 'active' to publish + state: 'active', + steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList().cast(), + // For My Recipes we don't have ingredients directly accessible in this basic model, + // but when it gets sent to publish, RecipeRepository merges properties correctly if available. + ingredients: [], + tags: [], + ); + } + + Recipe toRecipe() { + return Recipe( + id: id, + image: image, + state: state, + cookingTime: cookingTime, + activeCookingTime: activeCookingTime, + ingredients: [], + description: description, + steps: steps, + videoUrl: videoUrl, + title: title, + slug: slug, + calories: calories, + likesCount: likesCount, + viewsCount: viewsCount, + type: 'recipe', + commentsCount: 0, + wasLikedByMe: false, + isFavorite: false, + tags: [], + author: const Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: true), + ); + } +} + +extension RecipeToRecipeModelX on Recipe { + RecipeModel toRecipeModel() { + return RecipeModel( + title: title, + description: description, + image: image, + totalCookingTime: cookingTime, + activeCookTime: activeCookingTime, + energyPerServing: calories.toInt(), + videoUrl: videoUrl, + state: 'active', + steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList(), + ingredients: ingredients?.map((e) => cr_ingredient.Ingredient( + name: e.name, + portion: e.portion, + unit: e.unit ?? '', + productId: e.productId, + )).toList() ?? [], + tags: tags.map((e) => e.name).toList(), + ); + } +} 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_recipes_cubit.dart similarity index 56% rename from lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart rename to lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart index 44481c3a..db46b7b4 100644 --- a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart +++ b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart @@ -1,20 +1,28 @@ +import 'dart:developer'; +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class MyRecipesCubit extends Cubit { final MyRecipesRepository myRecipesRepository; final AboutRecipeRepository aboutRecipeRepository; + final RecipeRepository recipeRepository; static const String active = 'active'; static const String archived = 'archived'; + static const String drafted = 'drafted'; MyRecipesCubit({ required this.myRecipesRepository, required this.aboutRecipeRepository, + required this.recipeRepository, }) : super(const MyRecipesState.loading()); Future getMyRecipes() async { @@ -50,7 +58,47 @@ 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); } + + /// Attempts to publish a drafted recipe + /// Returns `true` if successful + /// Returns `false` if failed (e.g. validation error 422 because fields are missing) + Future publishDraft(RecipeModel recipe, String slug) async { + try { + await recipeRepository.publishRecipe(recipe, true, slug); + return true; + } catch (e, st) { + if (e is DioException && e.response?.statusCode == 422) { + log('Validation failed for publishing draft: \${e.response?.data}'); + } else { + await Sentry.captureException(e, stackTrace: st); + } + return false; + } + } + + /// Moves an active recipe to draft + Future draftRecipe(int id) async { + try { + await recipeRepository.draftExistingRecipe(id); + return true; + } catch (e, st) { + log('Failed to draft recipe: \$e'); + await Sentry.captureException(e, stackTrace: st); + return false; + } + } } diff --git a/lib/features/recipe/presentation/pages/my_recipes_screen.dart b/lib/features/recipe/presentation/pages/my_recipes_screen.dart index c3e29db5..f3fc4b36 100644 --- a/lib/features/recipe/presentation/pages/my_recipes_screen.dart +++ b/lib/features/recipe/presentation/pages/my_recipes_screen.dart @@ -1,11 +1,14 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; 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_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'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; +import 'package:go_router/go_router.dart'; class MyRecipesScreen extends StatelessWidget { const MyRecipesScreen({super.key}); @@ -32,15 +35,68 @@ 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 Stack( + children: [ + Positioned.fill( + 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.only(top: 80, bottom: 16), + itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]), + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemCount: state.recipes.length, + ) + : const Center(child: CircularProgressIndicator()), + ), + Positioned( + top: 16, + left: 16, + right: 16, + child: Row( + children: [ + Expanded( + child: MyRecipesTab( + selectedIndex: state is MyRecipesStateLoaded ? (state.selectedState == MyRecipesCubit.drafted ? 1 : 0) : 0, + onTabChanged: (index) { + if (index == 0) { + context.read().setActiveState(); + } else { + context.read().setDraftedState(); + } + }, + ), + ), + const SizedBox(width: 8), + InkWell( + onTap: () { + context.push(RecipeCreateScreen.path); + }, + borderRadius: BorderRadius.circular(100), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add, + color: appTheme.whiteA700, + size: 24, + ), + ), + ), + ], + ), + ), + ], + ); }, ), ), diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index c30aebc1..31c92082 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -8,10 +8,12 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.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_cubit.dart'; 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,147 @@ class _MyRecipeWidgetState extends State { ), const SizedBox(width: 8), Expanded( + child: isDraft + ? CustomOutlinedButton( + onPressed: () async { + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); + if (context.mounted) { + Navigator.pop(context); // close dialog + context.push(RecipeCreateScreen.path, extra: fullRecipe); + } + }, + height: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: 'Редактировать', + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ) + : PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + offset: const Offset(0, 50), + itemBuilder: (context) => [ + if (!isDraft) + PopupMenuItem( + onTap: () async { + final box = context.findRenderObject() as RenderBox?; + await SharePlus.instance.share( + ShareParams( + uri: Uri.parse('\${sl().baseUrl}/ru/recipe/\${widget.recipe.slug}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), + ); + }, + child: Row( + children: [ + Icon(Icons.share, size: 20, color: appTheme.black), + const SizedBox(width: 8), + Text(S.of(context).share, style: TextStyles.textSMedium), + ], + ), + ), + ], + child: CustomOutlinedButton( + onPressed: () {}, + height: 52, + width: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: s.share, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + ), + ), + ), + ], + ), + if (isDraft) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, 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, - ), + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (context) => const Center(child: CircularProgressIndicator()), ); + final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); + final createCubit = sl(); + await createCubit.recipeToEntity(fullRecipe); + + if (context.mounted) { + Navigator.pop(context); + if (createCubit.validateAll()) { + final success = await createCubit.publishRecipe(true, widget.recipe.slug); + if (success && context.mounted) { + showSuccessToast('Черновик опубликован'); + context.read().setDraftedState(); + } else { + showErrorToast('Не удалось опубликовать черновик'); + } + } else { + showErrorToast('Заполните все необходимые поля для публикации рецепта'); + if (context.mounted) { + context.push(RecipeCreateScreen.path, extra: { + 'recipe': fullRecipe, + 'autoValidate': true, + }); + } + } + await createCubit.close(); + } }, height: 52, - width: 52, buttonStyle: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), side: const WidgetStatePropertyAll(BorderSide.none), ), - text: s.share, + text: 'Опубликовать', buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), ), ), - ], - ), - const SizedBox(height: 8), + ), + if (!isDraft) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, + child: CustomOutlinedButton( + onPressed: () async { + final result = await context.read().draftRecipe(widget.recipe.id); + if (result) { + showSuccessToast('Рецепт перенесен в черновики'); + if (context.mounted) { + context.read().getMyRecipes(); + } + } else { + showErrorToast('Не удалось перенести в черновики'); + } + }, + height: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: 'Снять с публикации', + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + ), + const SizedBox(height: 16), ], ), ), @@ -172,6 +293,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 +315,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/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart b/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart index 241a9a91..eb4bd3b8 100644 --- a/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart +++ b/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart @@ -433,9 +433,49 @@ void main() { final tempFile = File('${Directory.systemTemp.path}/test_image.jpg'); await tempFile.writeAsBytes([1, 2, 3]); cubit.replaceImage(tempFile); + cubit.addIngredient(const i.Ingredient(name: 'Test Ingredient', unit: 'gr', portion: '100', productId: 1)); cubit.replaceDescriptionInStep('Step 1 description', 0); 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); + }); }); } diff --git a/test/features/create_recipe/reporitories/recipe_repository_test.dart b/test/features/create_recipe/reporitories/recipe_repository_test.dart index 95cb1462..8392d3b7 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()); @@ -140,7 +147,7 @@ void main() { energyPerServing: 300, ); - await repository.publishRecipe(recipe); + expect(() => repository.publishRecipe(recipe), throwsException); // Методы не должны вызываться, так как изображение отсутствует verifyNever(() => mockService.publishRecipe(formData: any(named: 'formData'))); @@ -162,7 +169,7 @@ void main() { energyPerServing: 300, ); - await repository.publishRecipe(recipe); + expect(() => repository.publishRecipe(recipe), throwsException); // Методы не должны вызываться, так как изображение пустое verifyNever(() => mockService.publishRecipe(formData: any(named: 'formData'))); @@ -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/create_recipe/services/recipe_service_test.dart b/test/features/create_recipe/services/recipe_service_test.dart index 022eb89a..e9b0f900 100644 --- a/test/features/create_recipe/services/recipe_service_test.dart +++ b/test/features/create_recipe/services/recipe_service_test.dart @@ -9,6 +9,10 @@ import 'package:mockito/mockito.dart'; import 'recipe_service_test.mocks.dart'; +import 'package:retrofit/retrofit.dart'; + +final mockHttpResponse = HttpResponse(null, Response(requestOptions: RequestOptions())); + @GenerateNiceMocks([MockSpec()]) void main() { group('CreateRecipeService', () { @@ -77,7 +81,7 @@ void main() { final formData = FormData.fromMap({}); when(mockCreateRecipeService.publishRecipe(formData: formData)) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); await mockCreateRecipeService.publishRecipe(formData: formData); diff --git a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart index a77e0a7f..5260e3ee 100644 --- a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart +++ b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,16 +10,18 @@ import 'package:mockito/mockito.dart'; import 'my_recipes_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) void main() { group('MyRecipesCubit', () { MyRecipesCubit? cubit; late MockMyRecipesRepository mockMyRecipesRepository; late MockAboutRecipeRepository mockAboutRecipeRepository; + late MockRecipeRepository mockRecipeRepository; setUp(() { mockMyRecipesRepository = MockMyRecipesRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository(); + mockRecipeRepository = MockRecipeRepository(); }); tearDown(() async { @@ -29,13 +32,13 @@ void main() { }); test('начальное состояние - loading', () { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); expect(cubit!.state, isA()); }); test('getMyRecipes - успешная загрузка активных рецептов', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( @@ -66,7 +69,7 @@ void main() { }); test('setActiveState - фильтрует активные рецепты', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( @@ -112,7 +115,7 @@ void main() { }); test('setArchivedState - фильтрует архивированные рецепты', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( diff --git a/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart b/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart index fac14897..ebd26e01 100644 --- a/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart +++ b/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart @@ -2,11 +2,13 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; 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_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/presentation/pages/my_recipes_screen.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -26,7 +28,17 @@ void main() { }); Widget wrap(Widget child) { - return MaterialApp(theme: ThemeHelper().themeData(), home: child); + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ); } testWidgets('renders my recipes screen', (tester) async { diff --git a/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart b/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart index 52fca10f..caa91380 100644 --- a/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart +++ b/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart @@ -5,7 +5,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_state.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.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_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/generated/l10n.dart'; 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); diff --git a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart index 267e5191..4afde5ec 100644 --- a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart +++ b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart @@ -1,9 +1,10 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -13,12 +14,14 @@ import 'my_recipes_cubit_test.mocks.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), + MockSpec(), ]) void main() { group('MyRecipesCubit', () { MyRecipesCubit? myRecipesCubit; late MockMyRecipesRepository mockMyRecipesRepository; late MockAboutRecipeRepository mockAboutRecipeRepository; + late MockRecipeRepository mockRecipeRepository; const testActiveRecipe = MyRecipeModel( id: 1, @@ -85,6 +88,7 @@ void main() { setUp(() { mockMyRecipesRepository = MockMyRecipesRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository(); + mockRecipeRepository = MockRecipeRepository(); }); tearDown(() async { @@ -99,6 +103,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); expect(myRecipesCubit!.state, isA()); }); @@ -107,6 +112,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -127,6 +133,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -141,6 +148,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -161,6 +169,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -175,6 +184,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -195,6 +205,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -209,6 +220,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockAboutRecipeRepository.getRecipe('test-recipe')).thenAnswer((_) async => testRecipe); @@ -225,6 +237,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(