Merge remote-tracking branch 'upstream/main' into issue-911

This commit is contained in:
misha
2026-03-04 09:08:17 +03:00
30 changed files with 792 additions and 200 deletions

View File

@@ -3,7 +3,7 @@ name: Eatvio App Android Test
on: [ pull_request ] on: [ pull_request ]
env: env:
flutter_version: "3.38.9" flutter_version: "3.41.2"
java_version: "21.x" java_version: "21.x"
flutter_channel: "stable" flutter_channel: "stable"
@@ -12,10 +12,10 @@ jobs:
runs-on: [ ubuntu-latest ] runs-on: [ ubuntu-latest ]
steps: steps:
- uses: actions/checkout@v3 - uses: https://github.com/actions/checkout@v3
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v3 uses: https://github.com/actions/setup-java@v3
with: with:
distribution: "zulu" distribution: "zulu"
java-version: ${{ env.java_version }} java-version: ${{ env.java_version }}
@@ -24,7 +24,7 @@ jobs:
- name: Install Android SDK - name: Install Android SDK
run: | run: |
apt-get update 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 wget https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip
unzip commandlinetools.zip unzip commandlinetools.zip
mkdir -p $HOME/Android/Sdk/cmdline-tools/latest mkdir -p $HOME/Android/Sdk/cmdline-tools/latest
@@ -44,7 +44,7 @@ jobs:
apt-get install -y jq apt-get install -y jq
- id: flutter-action - id: flutter-action
uses: actions/flutter-action@v2 uses: https://github.com/subosito/flutter-action@v2
with: with:
channel: ${{ env.flutter_channel }} channel: ${{ env.flutter_channel }}
flutter-version: ${{ env.flutter_version }} flutter-version: ${{ env.flutter_version }}
@@ -79,7 +79,7 @@ jobs:
echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: https://github.com/actions/upload-artifact@v3
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }}

View File

@@ -3,7 +3,7 @@ name: Eatvio App Android Test on custom docker
on: [ pull_request ] on: [ pull_request ]
env: env:
flutter_version: "3.32.0" flutter_version: "3.41.2"
java_version: "21.x" java_version: "21.x"
flutter_channel: "stable" flutter_channel: "stable"
my_local_cache_dir: "/opt/hostedtoolcache/" my_local_cache_dir: "/opt/hostedtoolcache/"
@@ -15,7 +15,7 @@ jobs:
runs-on: [ dev-office-flutter ] runs-on: [ dev-office-flutter ]
steps: steps:
- uses: actions/checkout@v3 - uses: https://github.com/actions/checkout@v3
- name: Set up Android environment variables - name: Set up Android environment variables
id: "android-env-setup" id: "android-env-setup"
@@ -52,7 +52,7 @@ jobs:
# echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV # echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
# - name: Upload APK artifact # - name: Upload APK artifact
# uses: actions/upload-artifact@v3 # uses: https://github.com/actions/upload-artifact@v3
# with: # with:
# name: ${{ env.APK_NAME }} # name: ${{ env.APK_NAME }}
# path: ${{ env.APK_NAME }} # path: ${{ env.APK_NAME }}

View File

@@ -9,10 +9,10 @@ jobs:
runs-on: [macos-arm64] runs-on: [macos-arm64]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: https://github.com/actions/checkout@v2
- name: Checkout env repository - name: Checkout env repository
uses: actions/checkout@v2 uses: https://github.com/actions/checkout@v2
with: with:
repository: mso7nueh/eatvio_app_env repository: mso7nueh/eatvio_app_env
path: env_repo path: env_repo

View File

@@ -3,7 +3,7 @@ name: Eatvio App Android Test
on: [ pull_request ] on: [ pull_request ]
env: env:
flutter_version: "3.38.9" flutter_version: "3.41.2"
java_version: "21.x" java_version: "21.x"
flutter_channel: "stable" flutter_channel: "stable"
my_local_cache_dir: "/opt/hostedtoolcache/" my_local_cache_dir: "/opt/hostedtoolcache/"
@@ -11,13 +11,23 @@ env:
jobs: jobs:
build: build:
runs-on: [ dev-office ] runs-on: [ ubuntu-latest ]
steps: 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 <<EOF >/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 - name: Checkout env repository
uses: actions/checkout@v3 uses: https://github.com/actions/checkout@v3
with: with:
repository: mso7nueh/eatvio_app_env repository: mso7nueh/eatvio_app_env
path: env_repo path: env_repo
@@ -56,13 +66,13 @@ jobs:
- name: Cache Android SDK - name: Cache Android SDK
id: cache-sdk id: cache-sdk
uses: "https://gitea.com/actions/cache@v4" uses: "https://github.com/actions/cache@v4"
with: with:
path: ${{ env.my_local_cache_dir }} path: ${{ env.my_local_cache_dir }}
key: ${{ runner.os }}-cache-sdk key: ${{ runner.os }}-cache-sdk
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v3 uses: https://github.com/actions/setup-java@v3
with: with:
distribution: "zulu" distribution: "zulu"
java-version: ${{ env.java_version }} 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 "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 echo "ANDROID_HOME=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV
- id: flutter-action
uses: actions/flutter-action@v2 - name: Set up Flutter
with: run: |
channel: ${{ env.flutter_channel }} FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter"
flutter-version: ${{ env.flutter_version }} if [ ! -f "$FLUTTER_DIR/bin/flutter" ]; then
cache: true echo "Downloading Flutter ${{ env.flutter_version }}..."
cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache 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"
cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path echo "Extracting Flutter..."
pub-cache-key: "flutter-pub:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache of dart pub get dependencies mkdir -p "${{ env.my_local_cache_dir }}"
pub-cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path 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 # - name: Print flutter-action outputs
# shell: bash # shell: bash
@@ -126,7 +143,9 @@ jobs:
- name: Fix dir permissions - name: Fix dir permissions
run: | 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 config --android-sdk="$ANDROID_HOME"
flutter doctor -v flutter doctor -v
@@ -220,7 +239,7 @@ jobs:
echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: https://github.com/actions/upload-artifact@v3
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }}

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ android/key.properties
app/upload-keystore.jks app/upload-keystore.jks
coverage/ coverage/
backend/ backend/
.fvm/
.fvmrc
/eatvio

File diff suppressed because one or more lines are too long

View File

@@ -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/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_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/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/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/presentation/bloc/verify_email_cubit/verify_email_cubit.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
@@ -114,7 +114,7 @@ class CubitRegistrations {
// User content cubits // User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl())); sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl())); sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
// Other feature cubits // Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl())); sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));

View File

@@ -153,7 +153,16 @@ class OtherRoutes {
GoRoute( GoRoute(
path: RecipeCreateScreen.path, path: RecipeCreateScreen.path,
name: RecipeCreateScreen.name, name: RecipeCreateScreen.name,
builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null), builder: (context, state) {
if (state.extra is Map<String, dynamic>) {
final data = state.extra as Map<String, dynamic>;
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( GoRoute(
path: CreateDietScreen.path, path: CreateDietScreen.path,

View File

@@ -14,7 +14,7 @@ class AboutOrderButton extends StatelessWidget {
child: Container( child: Container(
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: appTheme.gray50, color: const Color(0xFFFFFDD8),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(

View File

@@ -29,7 +29,7 @@ class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
if (widget.hasPersonalDiet) { if (widget.hasPersonalDiet) {
startDate = [DateTime.now().add(const Duration(days: 2))]; startDate = [DateTime.now().add(const Duration(days: 2))];
} else { } else {
startDate = [DateTime.now()]; startDate = [DateTime.now().add(const Duration(days: 1))];
} }
} }

View File

@@ -36,7 +36,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
final s = S.of(context); final s = S.of(context);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: appTheme.gray50, color: const Color(0xFFFFFDD8),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Column( child: Column(

View File

@@ -172,6 +172,38 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
} }
} }
Future<bool> saveAsDraft([bool update = false, String? slug]) async {
try {
final model = state.entity.toModel();
await recipeRepository.saveAsDraft(model, update, slug);
emit(state.copyWith(drafted: true));
return true;
} catch (e, st) {
await Sentry.captureException(e, stackTrace: st);
log(e.toString(), stackTrace: st);
return false;
}
}
bool validateForDraft() {
final isTitleValid = validateTitle();
_clearNonTitleValidationErrors();
return isTitleValid;
}
void _clearNonTitleValidationErrors() {
emit(state.copyWith(
descriptionValidationError: null,
cookingTimeValidationError: null,
activeTimeValidationError: null,
servingsValidationError: null,
energyPerServingValidationError: null,
imageValidationError: null,
stepsValidationError: null,
ingredientsValidationError: null,
));
}
Future<File> downloadImageAsFile(String imageUrl) async { Future<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl)); final response = await http.get(Uri.parse(imageUrl));
@@ -239,6 +271,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
final isActiveCookTimeValid = validateActiveCookTime(); final isActiveCookTimeValid = validateActiveCookTime();
final isEnergyPerServingsValid = validateEnergyPerServing(); final isEnergyPerServingsValid = validateEnergyPerServing();
final isIngredientsValid = validateIngredients();
final isStepsValid = validateSteps(); final isStepsValid = validateSteps();
return isTitleValid && return isTitleValid &&
@@ -248,9 +281,20 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
isCookTimeValid && isCookTimeValid &&
isActiveCookTimeValid && isActiveCookTimeValid &&
isEnergyPerServingsValid && isEnergyPerServingsValid &&
isIngredientsValid &&
isStepsValid; isStepsValid;
} }
bool validateIngredients() {
if (state.entity.ingredients.isEmpty) {
emit(state.copyWith(ingredientsValidationError: 'Добавьте хотя бы один ингредиент'));
return false;
} else {
emit(state.copyWith(ingredientsValidationError: null));
return true;
}
}
bool validateTitle() { bool validateTitle() {
if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) { if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) {
emit(state.copyWith(titleValidationError: 'Поле не заполнено')); emit(state.copyWith(titleValidationError: 'Поле не заполнено'));
@@ -343,6 +387,10 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
bool validateSteps() { bool validateSteps() {
bool isValid = true; bool isValid = true;
if (state.entity.steps.isEmpty) {
emit(state.copyWith(stepsValidationError: 'Добавьте хотя бы один шаг'));
return false;
}
for (var step in state.entity.steps) { for (var step in state.entity.steps) {
if (step.description == null || step.description!.trim().isEmpty) { if (step.description == null || step.description!.trim().isEmpty) {
isValid = false; isValid = false;

View File

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

View File

@@ -19,8 +19,9 @@ import 'package:go_router/go_router.dart';
class RecipeCreateScreen extends StatefulWidget { class RecipeCreateScreen extends StatefulWidget {
final Recipe? recipe; 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 name = 'recipeCreateScreen';
static const path = '/recipe-create-screen'; static const path = '/recipe-create-screen';
@@ -40,6 +41,11 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
createRecipeCubit = sl<CreateRecipeCubit>(); createRecipeCubit = sl<CreateRecipeCubit>();
if (widget.recipe != null) { if (widget.recipe != null) {
createRecipeCubit.recipeToEntity(widget.recipe!); createRecipeCubit.recipeToEntity(widget.recipe!);
if (widget.autoValidate) {
WidgetsBinding.instance.addPostFrameCallback((_) {
createRecipeCubit.validateAll();
});
}
} else { } else {
createRecipeCubit.disableLoading(); createRecipeCubit.disableLoading();
} }
@@ -81,55 +87,107 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
builder: (context, state) { builder: (context, state) {
return Builder( return Builder(
builder: (context) { builder: (context) {
return _isRecipeBeingPublished if (_isRecipeBeingPublished) {
? const Center(child: Loader()) return const Center(child: Loader());
: InkWell( }
key: AppKeys.recipeCreateScreenPublishButton, return Column(
children: [
InkWell(
key: AppKeys.recipeCreateScreenPublishButton,
onTap: () async {
setState(() {
_isRecipeBeingPublished = true;
});
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
setState(() {
_isRecipeBeingPublished = false;
});
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
} else {
setState(() {
_isRecipeBeingPublished = false;
});
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
(widget.recipe != null && 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 { onTap: () async {
setState(() { setState(() {
_isRecipeBeingPublished = true; _isRecipeBeingPublished = true;
}); });
if (context.read<CreateRecipeCubit>().validateAll()) { if (context.read<CreateRecipeCubit>().validateForDraft()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe( final isSuccess = await context.read<CreateRecipeCubit>().saveAsDraft(
widget.recipe == null ? false : true, widget.recipe == null ? false : true,
widget.recipe?.slug, widget.recipe?.slug,
); );
setState(() { setState(() {
_isRecipeBeingPublished = false; _isRecipeBeingPublished = false;
}); });
if (isSuccess) { if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return;
if (!context.mounted) return; context.pop();
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else { } else {
if (!context.mounted) return; if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}'); showErrorDialog(context, title: 'Не удалось сохранить черновик');
} }
} else {
setState(() {
_isRecipeBeingPublished = false;
});
} }
setState(() {
_isRecipeBeingPublished = false;
});
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: appTheme.blueA400),
),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать', widget.recipe != null ? 'Обновить черновик' : 'Сохранить как черновик',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), style: TextStyle(fontSize: 16, color: appTheme.blueA400, fontWeight: FontWeight.w600),
), ),
), ),
), ),
); ),
],
);
}, },
); );
}, },

View File

@@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)), title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)),
leading: Icon(Icons.add_rounded, color: appTheme.blueA400), leading: Icon(Icons.add_rounded, color: appTheme.blueA400),
), ),
if (state.ingredientsValidationError != null)
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Text(
state.ingredientsValidationError!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
], ],
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
@@ -23,55 +24,94 @@ class RecipeRepository {
} }
} }
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { Future<List> _buildStepsMap(List<Step?>? steps) async {
try { final stepsMap = [];
if (recipe.image == null || (recipe.image?.isEmpty ?? true)) { for (final Step? step in steps ?? []) {
return; if (step != null) {
} if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) {
final image = await ImageCompressUtility.compressFile(File(recipe.image ?? '')); stepsMap.add({'description_md': step.description ?? ''});
final multiPartFile = await MultipartFile.fromFile(image.path); } else if (step.image != null && (step.image?.isNotEmpty ?? false)) {
final compressed = await ImageCompressUtility.compressFile(File(step.image ?? ''));
final ingredientsMap = recipe.ingredients?.map((ingredient) { final multipartFile = await MultipartFile.fromFile(compressed.path);
if (ingredient != null) { stepsMap.add({
return ingredient.toJson(); 'description_md': step.description ?? '',
} 'image': multipartFile,
}).toList() ?? });
[];
final stepsMap = [];
for (final Step? step in recipe.steps ?? []) {
if (step != null) {
if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) {
stepsMap.add({
'description_md': step.description ?? "",
});
} else if (step.image != null && (step.image?.isNotEmpty ?? false)) {
final image = await ImageCompressUtility.compressFile(File(step.image ?? ''));
final multipartFile = await MultipartFile.fromFile(image.path);
stepsMap.add({
'description_md': step.description ?? "",
'image': multipartFile,
});
}
} }
} }
final formMap = { }
'type': recipe.type, return stepsMap;
'state': 'active', }
'title': recipe.title,
'tags[]': recipe.tags ?? [], Future<FormData> _buildPublishFormData(RecipeModel recipe) async {
'description_md': recipe.description, if (recipe.image == null || (recipe.image?.isEmpty ?? true)) {
'video_url': recipe.videoUrl, throw Exception('Recipe image is required');
'ingredients': ingredientsMap, }
'steps': stepsMap, final image = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
'cooking_time': recipe.totalCookingTime, final multiPartFile = await MultipartFile.fromFile(image.path);
'active_time': recipe.activeCookTime,
'image': multiPartFile, final ingredientsMap = recipe.ingredients
'servings': recipe.servings, ?.map((i) => i?.toJson())
'energy_per_serving': recipe.energyPerServing, .whereType<Map<String, dynamic>>()
}; .toList() ??
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty)); [];
final formData = FormData.fromMap(formMap);
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<FormData> _buildDraftFormData(RecipeModel recipe) async {
final ingredientsMap = recipe.ingredients
?.map((i) => i?.toJson())
.whereType<Map<String, dynamic>>()
.toList() ??
[];
final stepsMap = await _buildStepsMap(recipe.steps);
final formMap = <String, dynamic>{
'type': recipe.type,
'title': recipe.title,
'tags[]': recipe.tags ?? [],
'description_md': recipe.description,
'video_url': recipe.videoUrl,
'ingredients': ingredientsMap,
'steps': stepsMap,
'cooking_time': recipe.totalCookingTime,
'active_time': recipe.activeCookTime,
'servings': recipe.servings,
'energy_per_serving': recipe.energyPerServing,
};
if (recipe.image != null && (recipe.image?.isNotEmpty ?? false)) {
final compressed = await ImageCompressUtility.compressFile(File(recipe.image ?? ''));
formMap['image'] = await MultipartFile.fromFile(compressed.path);
}
formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty));
return FormData.fromMap(formMap);
}
Future<void> publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async {
try {
final formData = await _buildPublishFormData(recipe);
if (update) { if (update) {
await recipeService.updateRecipe(slug: slug!, formData: formData); await recipeService.updateRecipe(slug: slug!, formData: formData);
} else { } else {
@@ -83,6 +123,30 @@ class RecipeRepository {
} }
} }
Future<void> 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<void> draftExistingRecipe(int id) async {
try {
await recipeService.draftRecipe(id: id);
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
}
Future<RecipeModel> getRecipe(String recipeID) async { Future<RecipeModel> getRecipe(String recipeID) async {
try { try {
return await recipeService.getRecipe(recipeID); return await recipeService.getRecipe(recipeID);

View File

@@ -33,7 +33,7 @@ abstract class CreateRecipeService {
@POST('/api/recipes/create') @POST('/api/recipes/create')
@MultiPart() @MultiPart()
Future<void> publishRecipe({ Future<HttpResponse<dynamic>> publishRecipe({
@Body() required FormData formData, @Body() required FormData formData,
}); });
@@ -47,8 +47,19 @@ abstract class CreateRecipeService {
@POST('/api/recipes/update/{slug}') @POST('/api/recipes/update/{slug}')
@MultiPart() @MultiPart()
Future<void> updateRecipe({ Future<HttpResponse<dynamic>> updateRecipe({
@Path() required String slug, @Path() required String slug,
@Body() required FormData formData, @Body() required FormData formData,
}); });
@POST('/api/recipes/draft/{id}')
Future<void> draftRecipe({
@Path() required int id,
});
@POST('/api/recipes/create/drafted')
@MultiPart()
Future<HttpResponse<dynamic>> createDraftedRecipe({
@Body() required FormData formData,
});
} }

View File

@@ -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/recipes_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'my_recipe_model.g.dart'; part 'my_recipe_model.g.dart';
@@ -34,3 +38,72 @@ abstract class MyRecipeModel with _$MyRecipeModel {
factory MyRecipeModel.fromJson(Map<String, Object?> json) => _$MyRecipeModelFromJson(json); factory MyRecipeModel.fromJson(Map<String, Object?> 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<cr_step.Step?>(),
// 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(),
);
}
}

View File

@@ -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/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/recipe/models/my_recipe_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/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/about_recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class MyRecipesCubit extends Cubit<MyRecipesState> { class MyRecipesCubit extends Cubit<MyRecipesState> {
final MyRecipesRepository myRecipesRepository; final MyRecipesRepository myRecipesRepository;
final AboutRecipeRepository aboutRecipeRepository; final AboutRecipeRepository aboutRecipeRepository;
final RecipeRepository recipeRepository;
static const String active = 'active'; static const String active = 'active';
static const String archived = 'archived'; static const String archived = 'archived';
static const String drafted = 'drafted';
MyRecipesCubit({ MyRecipesCubit({
required this.myRecipesRepository, required this.myRecipesRepository,
required this.aboutRecipeRepository, required this.aboutRecipeRepository,
required this.recipeRepository,
}) : super(const MyRecipesState.loading()); }) : super(const MyRecipesState.loading());
Future<void> getMyRecipes() async { Future<void> getMyRecipes() async {
@@ -50,7 +58,47 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
} }
} }
Future<void> setDraftedState() async {
emit(const MyRecipesState.loading());
try {
final myRecipes = await myRecipesRepository.getMyRecipes();
final List<MyRecipeModel> draftedRecipes = myRecipes.where((recipe) => recipe.state == drafted).toList();
emit(MyRecipesState.loaded(recipes: draftedRecipes, selectedState: drafted));
} catch (e) {
emit(const MyRecipesState.error());
}
}
Future<Recipe> getRecipeById(String slug) async { Future<Recipe> getRecipeById(String slug) async {
return await aboutRecipeRepository.getRecipe(slug); return await aboutRecipeRepository.getRecipe(slug);
} }
/// Attempts to publish a drafted recipe
/// Returns `true` if successful
/// Returns `false` if failed (e.g. validation error 422 because fields are missing)
Future<bool> 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<bool> 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;
}
}
} }

View File

@@ -1,11 +1,14 @@
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.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/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart'; import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipes_tab.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:go_router/go_router.dart';
class MyRecipesScreen extends StatelessWidget { class MyRecipesScreen extends StatelessWidget {
const MyRecipesScreen({super.key}); const MyRecipesScreen({super.key});
@@ -32,15 +35,68 @@ class MyRecipesScreen extends StatelessWidget {
create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(), create: (BuildContext context) => sl<MyRecipesCubit>()..getMyRecipes(),
child: BlocBuilder<MyRecipesCubit, MyRecipesState>( child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
builder: (BuildContext context, MyRecipesState state) { builder: (BuildContext context, MyRecipesState state) {
if (state is MyRecipesStateLoaded) { return Stack(
return ListView.separated( children: [
padding: const EdgeInsets.symmetric(vertical: 16), Positioned.fill(
itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]), child: state is MyRecipesStateLoaded
separatorBuilder: (context, index) => const SizedBox(height: 12), ? state.recipes.isEmpty
itemCount: state.recipes.length, ? Center(
); child: Text(
} state.selectedState == MyRecipesCubit.drafted ? 'Нет черновиков' : 'Нет опубликованных рецептов',
return Container(); 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<MyRecipesCubit>().setActiveState();
} else {
context.read<MyRecipesCubit>().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,
),
),
),
],
),
),
],
);
}, },
), ),
), ),

View File

@@ -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/core/widgets/custom_outlined_button.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_cubit.dart';
import 'package:eatvio_app/features/recipe/models/my_recipe_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/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -99,11 +101,7 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
? '${widget.recipe.description.substring(0, 200)}...' ? '${widget.recipe.description.substring(0, 200)}...'
: widget.recipe.description; : widget.recipe.description;
return MarkdownHtmlWidget( return MarkdownHtmlWidget(markdownText: description, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black);
markdownText: description,
fontSize: TextStyles.textSMedium.fontSize ?? 14,
textColor: appTheme.black,
);
}, },
), ),
), ),
@@ -126,7 +124,13 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
return; return;
} }
showSuccessToast('Рецепт удален'); showSuccessToast('Рецепт удален');
if (context.mounted) context.read<MyRecipesCubit>().getMyRecipes(); if (context.mounted) {
if (isDraft) {
context.read<MyRecipesCubit>().setDraftedState();
} else {
context.read<MyRecipesCubit>().getMyRecipes();
}
}
}, },
text: 'Удалить', text: 'Удалить',
height: 52, height: 52,
@@ -140,30 +144,147 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: isDraft
? CustomOutlinedButton(
onPressed: () async {
showDialog(
context: context,
barrierColor: Colors.black12,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
final fullRecipe = await context.read<MyRecipesCubit>().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<int>(
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<AppConfig>().baseUrl}/ru/recipe/\${widget.recipe.slug}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
child: Row(
children: [
Icon(Icons.share, size: 20, color: appTheme.black),
const SizedBox(width: 8),
Text(S.of(context).share, style: TextStyles.textSMedium),
],
),
),
],
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( child: CustomOutlinedButton(
onPressed: () async { onPressed: () async {
final box = context.findRenderObject() as RenderBox?; showDialog(
await SharePlus.instance.share( context: context,
ShareParams( barrierColor: Colors.black12,
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/recipe/${widget.recipe.slug}'), builder: (context) => const Center(child: CircularProgressIndicator()),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
); );
final fullRecipe = await context.read<MyRecipesCubit>().getRecipeById(widget.recipe.slug);
final createCubit = sl<CreateRecipeCubit>();
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<MyRecipesCubit>().setDraftedState();
} else {
showErrorToast('Не удалось опубликовать черновик');
}
} else {
showErrorToast('Заполните все необходимые поля для публикации рецепта');
if (context.mounted) {
context.push(RecipeCreateScreen.path, extra: {
'recipe': fullRecipe,
'autoValidate': true,
});
}
}
await createCubit.close();
}
}, },
height: 52, height: 52,
width: 52,
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none), side: const WidgetStatePropertyAll(BorderSide.none),
), ),
text: s.share, text: 'Опубликовать',
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
), ),
), ),
], ),
), if (!isDraft)
const SizedBox(height: 8), Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: CustomOutlinedButton(
onPressed: () async {
final result = await context.read<MyRecipesCubit>().draftRecipe(widget.recipe.id);
if (result) {
showSuccessToast('Рецепт перенесен в черновики');
if (context.mounted) {
context.read<MyRecipesCubit>().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<MyRecipeWidget> {
); );
} }
bool get isDraft => widget.recipe.state == 'drafted';
Container _iconButton(String iconPath, [String? text]) { Container _iconButton(String iconPath, [String? text]) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -192,4 +315,3 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
); );
} }
} }

View File

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

View File

@@ -433,9 +433,49 @@ void main() {
final tempFile = File('${Directory.systemTemp.path}/test_image.jpg'); final tempFile = File('${Directory.systemTemp.path}/test_image.jpg');
await tempFile.writeAsBytes([1, 2, 3]); await tempFile.writeAsBytes([1, 2, 3]);
cubit.replaceImage(tempFile); cubit.replaceImage(tempFile);
cubit.addIngredient(const i.Ingredient(name: 'Test Ingredient', unit: 'gr', portion: '100', productId: 1));
cubit.replaceDescriptionInStep('Step 1 description', 0); cubit.replaceDescriptionInStep('Step 1 description', 0);
expect(cubit.validateAll(), true); 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);
});
}); });
} }

View File

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

View File

@@ -9,6 +9,10 @@ import 'package:mockito/mockito.dart';
import 'recipe_service_test.mocks.dart'; import 'recipe_service_test.mocks.dart';
import 'package:retrofit/retrofit.dart';
final mockHttpResponse = HttpResponse(null, Response(requestOptions: RequestOptions()));
@GenerateNiceMocks([MockSpec<CreateRecipeService>()]) @GenerateNiceMocks([MockSpec<CreateRecipeService>()])
void main() { void main() {
group('CreateRecipeService', () { group('CreateRecipeService', () {
@@ -77,7 +81,7 @@ void main() {
final formData = FormData.fromMap({}); final formData = FormData.fromMap({});
when(mockCreateRecipeService.publishRecipe(formData: formData)) when(mockCreateRecipeService.publishRecipe(formData: formData))
.thenAnswer((_) async => Future.value()); .thenAnswer((_) async => Future.value(mockHttpResponse));
await mockCreateRecipeService.publishRecipe(formData: formData); await mockCreateRecipeService.publishRecipe(formData: formData);

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/features/recipe/models/my_recipe_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/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/about_recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -9,16 +10,18 @@ import 'package:mockito/mockito.dart';
import 'my_recipes_cubit_test.mocks.dart'; import 'my_recipes_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<MyRecipesRepository>(), MockSpec<AboutRecipeRepository>()]) @GenerateNiceMocks([MockSpec<MyRecipesRepository>(), MockSpec<AboutRecipeRepository>(), MockSpec<RecipeRepository>()])
void main() { void main() {
group('MyRecipesCubit', () { group('MyRecipesCubit', () {
MyRecipesCubit? cubit; MyRecipesCubit? cubit;
late MockMyRecipesRepository mockMyRecipesRepository; late MockMyRecipesRepository mockMyRecipesRepository;
late MockAboutRecipeRepository mockAboutRecipeRepository; late MockAboutRecipeRepository mockAboutRecipeRepository;
late MockRecipeRepository mockRecipeRepository;
setUp(() { setUp(() {
mockMyRecipesRepository = MockMyRecipesRepository(); mockMyRecipesRepository = MockMyRecipesRepository();
mockAboutRecipeRepository = MockAboutRecipeRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository();
mockRecipeRepository = MockRecipeRepository();
}); });
tearDown(() async { tearDown(() async {
@@ -29,13 +32,13 @@ void main() {
}); });
test('начальное состояние - loading', () { test('начальное состояние - loading', () {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
expect(cubit!.state, isA<MyRecipesStateLoading>()); expect(cubit!.state, isA<MyRecipesStateLoading>());
}); });
test('getMyRecipes - успешная загрузка активных рецептов', () async { test('getMyRecipes - успешная загрузка активных рецептов', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [ final testRecipes = [
const MyRecipeModel( const MyRecipeModel(
@@ -66,7 +69,7 @@ void main() {
}); });
test('setActiveState - фильтрует активные рецепты', () async { test('setActiveState - фильтрует активные рецепты', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [ final testRecipes = [
const MyRecipeModel( const MyRecipeModel(
@@ -112,7 +115,7 @@ void main() {
}); });
test('setArchivedState - фильтрует архивированные рецепты', () async { test('setArchivedState - фильтрует архивированные рецепты', () async {
cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository);
final testRecipes = [ final testRecipes = [
const MyRecipeModel( const MyRecipeModel(

View File

@@ -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/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.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/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:eatvio_app/features/recipe/presentation/pages/my_recipes_screen.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/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
@@ -26,7 +28,17 @@ void main() {
}); });
Widget wrap(Widget child) { 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 { testWidgets('renders my recipes screen', (tester) async {

View File

@@ -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_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_state.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/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/cubit/my_recipes_cubit/my_recipes_state.dart';
import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart'; import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';

View File

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

View File

@@ -1,9 +1,10 @@
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; 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/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/recipe/models/my_recipe_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/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/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:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
@@ -13,12 +14,14 @@ import 'my_recipes_cubit_test.mocks.dart';
@GenerateNiceMocks([ @GenerateNiceMocks([
MockSpec<MyRecipesRepository>(), MockSpec<MyRecipesRepository>(),
MockSpec<AboutRecipeRepository>(), MockSpec<AboutRecipeRepository>(),
MockSpec<RecipeRepository>(),
]) ])
void main() { void main() {
group('MyRecipesCubit', () { group('MyRecipesCubit', () {
MyRecipesCubit? myRecipesCubit; MyRecipesCubit? myRecipesCubit;
late MockMyRecipesRepository mockMyRecipesRepository; late MockMyRecipesRepository mockMyRecipesRepository;
late MockAboutRecipeRepository mockAboutRecipeRepository; late MockAboutRecipeRepository mockAboutRecipeRepository;
late MockRecipeRepository mockRecipeRepository;
const testActiveRecipe = MyRecipeModel( const testActiveRecipe = MyRecipeModel(
id: 1, id: 1,
@@ -85,6 +88,7 @@ void main() {
setUp(() { setUp(() {
mockMyRecipesRepository = MockMyRecipesRepository(); mockMyRecipesRepository = MockMyRecipesRepository();
mockAboutRecipeRepository = MockAboutRecipeRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository();
mockRecipeRepository = MockRecipeRepository();
}); });
tearDown(() async { tearDown(() async {
@@ -99,6 +103,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
expect(myRecipesCubit!.state, isA<MyRecipesStateLoading>()); expect(myRecipesCubit!.state, isA<MyRecipesStateLoading>());
}); });
@@ -107,6 +112,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -127,6 +133,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -141,6 +148,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -161,6 +169,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -175,6 +184,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(
@@ -195,6 +205,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error'));
@@ -209,6 +220,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockAboutRecipeRepository.getRecipe('test-recipe')).thenAnswer((_) async => testRecipe); when(mockAboutRecipeRepository.getRecipe('test-recipe')).thenAnswer((_) async => testRecipe);
@@ -225,6 +237,7 @@ void main() {
myRecipesCubit = MyRecipesCubit( myRecipesCubit = MyRecipesCubit(
myRecipesRepository: mockMyRecipesRepository, myRecipesRepository: mockMyRecipesRepository,
aboutRecipeRepository: mockAboutRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository,
recipeRepository: mockRecipeRepository,
); );
when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( when(mockMyRecipesRepository.getMyRecipes()).thenAnswer(