From 3c080de956c397613e445081b3cf5f6d975fbc9d Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Tue, 12 May 2026 00:32:25 +0300 Subject: [PATCH 1/4] fix: change search json --- .../recipe_form_remote_data_source_impl.dart | 98 +++++++++++++------ .../recipe_search_remote_data_source.dart | 1 - ...recipe_search_remote_data_source_impl.dart | 1 - 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/frontend/lib/features/recipe/recipe_form/data/data_sources/recipe_form_remote_data_source_impl.dart b/frontend/lib/features/recipe/recipe_form/data/data_sources/recipe_form_remote_data_source_impl.dart index c9576b6..efdd17b 100644 --- a/frontend/lib/features/recipe/recipe_form/data/data_sources/recipe_form_remote_data_source_impl.dart +++ b/frontend/lib/features/recipe/recipe_form/data/data_sources/recipe_form_remote_data_source_impl.dart @@ -1,46 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:cookify/features/recipe/recipe_form/data/data_sources/recipe_form_remote_data_source.dart'; import 'package:cookify/features/recipe/recipe_form/domain/payloads/publish_recipe_payload.dart'; import 'package:dio/dio.dart'; +import 'package:path/path.dart' as path; class RecipeFormRemoteDataSourceImpl implements RecipeFormRemoteDataSource { RecipeFormRemoteDataSourceImpl({required Dio dio}) : _dio = dio; final Dio _dio; + Future _fileToBase64(File file) async { + List bytes = await file.readAsBytes(); + String base64String = base64Encode(bytes); + String mimeType = _getMimeType(file.path); + return 'data:$mimeType;base64,$base64String'; + } + + // Функция для определения MIME типа + String _getMimeType(String filePath) { + String extension = path.extension(filePath).toLowerCase(); + switch (extension) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + default: + return 'image/jpeg'; + } + } + @override Future publishRecipe(PublishRecipePayload payload) async { + List> imagesData = []; + for (int i = 0; i < payload.photos.length; i++) { + String base64Image = await _fileToBase64(File(payload.photos[i].path)); + imagesData.add({'url': base64Image, 'order': i}); + } + + // Конвертируем фото шагов в base64 + List> stepsData = []; + for (int i = 0; i < payload.steps.length; i++) { + Map stepData = { + 'title': payload.steps[i].title, + 'description': payload.steps[i].description, + }; + + if (payload.steps[i].photoPath != null) { + String base64Image = await _fileToBase64( + File(payload.steps[i].photoPath!), + ); + stepData['image_base64'] = base64Image; + } else { + stepData['image_base64'] = null; + } + + stepsData.add(stepData); + } + await _dio.post( '/api/recipes', data: { - 'name': payload.name, - 'description': payload.description, - 'cpfc': { - 'calories': payload.calories, - 'proteins': payload.proteins, - 'fats': payload.fats, - 'carbohydrates': payload.carbohydrates, - }, - 'difficulty': payload.difficulty, - 'cookingTimeMinutes': payload.cookingTimeMinutes, - 'categories': payload.categories, - 'photos': payload.photos.map((file) => file.path).toList(), - 'ingredients': payload.ingredients - .map( - (item) => { - 'id': item.id, - 'amount': item.amount, - 'unit': item.unit, - }, - ) - .toList(), - 'steps': payload.steps - .map( - (item) => { - 'title': item.title, - 'description': item.description, - if (item.photoPath != null) 'photo': item.photoPath, - }, - ) + "title": payload.name, + "cooking_time_minutes": payload.cookingTimeMinutes, + "servings": 1, + "calories100g": payload.calories, + "protein100g": payload.proteins, + "fat100g": payload.fats, + "carb100g": payload.carbohydrates, + "description": payload.description, + "difficulty": payload.difficulty.index, + "main_image_base64": imagesData.first['url'], + "steps": stepsData, + "tags": payload.categories, + "ingredients": payload.ingredients + .map((i) => {'id': i.id, 'amount': i.amount, 'unit': i.unit}) .toList(), }, ); diff --git a/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source.dart b/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source.dart index fa39488..89251a5 100644 --- a/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source.dart +++ b/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source.dart @@ -1,4 +1,3 @@ -import 'package:cookify/features/recipe/recipe_detail/data/models/recipe_detail_model.dart'; import 'package:cookify/features/recipe/recipe_feed/data/models/recipe_preview_model.dart'; import 'package:cookify/features/recipe/recipe_search/data/requests/search_recipe_list_request.dart'; diff --git a/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source_impl.dart b/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source_impl.dart index 1923641..3a0f630 100644 --- a/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source_impl.dart +++ b/frontend/lib/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source_impl.dart @@ -1,4 +1,3 @@ -import 'package:cookify/features/recipe/recipe_detail/data/models/recipe_detail_model.dart'; import 'package:cookify/features/recipe/recipe_feed/data/models/recipe_preview_model.dart'; import 'package:cookify/features/recipe/recipe_search/data/data_sources/recipe_search_remote_data_source.dart'; import 'package:cookify/features/recipe/recipe_search/data/requests/search_recipe_list_request.dart'; From 34c62c6420f0f9159f699498e3a3507f5c6da937 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Tue, 12 May 2026 00:33:20 +0300 Subject: [PATCH 2/4] feat: add sign in with google --- frontend/.gitignore | 2 + .../core/presentation/widgets/app_toast.dart | 2 +- .../widgets/key_board_listener.dart | 1 - frontend/lib/di/di.dart | 14 +- .../presentation/widgets/auth_text_field.dart | 1 - .../repositories/profile_repository_impl.dart | 25 ++- .../widgets/profile_user_info.dart | 1 - ...common_search_remote_data_source_impl.dart | 4 +- .../widgets/category_text_field.dart | 4 +- .../widgets/ingredient_text_field.dart | 4 +- .../payloads/publish_recipe_payload.dart | 5 +- .../pages/recipe_form_page_content.dart | 183 ++++++------------ .../bloc/recipe_search_form_cubit.dart | 2 +- .../pages/recipe_search_form_page.dart | 1 - .../recipe_search_form_page_content.dart | 120 ++++++++---- .../presentation/bloc/sign_up_bloc.dart | 3 +- .../token_local_data_source_impl.dart | 1 + frontend/lib/main.dart | 2 + frontend/pubspec.lock | 10 +- frontend/pubspec.yaml | 3 + 20 files changed, 212 insertions(+), 176 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index 3d592f6..843310c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -46,3 +46,5 @@ app.*.map.json *.freezed.* *.g.* + +assets/.env/ diff --git a/frontend/lib/core/presentation/widgets/app_toast.dart b/frontend/lib/core/presentation/widgets/app_toast.dart index 06ae6f1..4a45d8f 100644 --- a/frontend/lib/core/presentation/widgets/app_toast.dart +++ b/frontend/lib/core/presentation/widgets/app_toast.dart @@ -19,7 +19,7 @@ void showToast(bool isSuccess, String text) { ? Icon(Icons.check, color: Color(0xFF7FB069)) : Icon(Icons.close, color: Color(0xFFE76F51)), SizedBox(width: 12.0), - Text(text, style: const TextStyle(color: Color(0xFFE5C9A8))), + Expanded(child: Text(text, style: const TextStyle(color: Color(0xFFE5C9A8)))), ], ), ); diff --git a/frontend/lib/core/presentation/widgets/key_board_listener.dart b/frontend/lib/core/presentation/widgets/key_board_listener.dart index 35b3abe..c1778c5 100644 --- a/frontend/lib/core/presentation/widgets/key_board_listener.dart +++ b/frontend/lib/core/presentation/widgets/key_board_listener.dart @@ -1,5 +1,4 @@ import 'dart:math'; -import 'dart:ui'; import 'package:flutter/widgets.dart'; diff --git a/frontend/lib/di/di.dart b/frontend/lib/di/di.dart index e8beeae..8821697 100644 --- a/frontend/lib/di/di.dart +++ b/frontend/lib/di/di.dart @@ -31,7 +31,13 @@ abstract class Di { } static Future initStorages(String address) async { - dio = Dio(BaseOptions(baseUrl: 'https://$address')); + dio = Dio( + BaseOptions( + baseUrl: 'https://$address', + connectTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 10), + ), + ); Di.dio = dio; dio.interceptors.add(PrettyDioLogger()); dio.interceptors.add(FailureInterceptor()); @@ -83,15 +89,13 @@ abstract class Di { class FailureInterceptor extends InterceptorsWrapper { @override void onError(DioException err, ErrorInterceptorHandler handler) { - if (err.type == DioExceptionType.connectionError || + if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.connectionTimeout) { - // Создаем кастомную ошибку, сохраняя контекст запроса throw NetworkException(); } - + // Если это не ошибка сети, пропускаем ошибку дальше return handler.next(err); } } - diff --git a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_text_field.dart b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_text_field.dart index f3a9b45..b583dc8 100644 --- a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_text_field.dart +++ b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_text_field.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class AuthTextField extends StatefulWidget { const AuthTextField({ diff --git a/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart b/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart index 0edbe3e..e806fae 100644 --- a/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -1,12 +1,17 @@ +import 'dart:convert'; + import 'package:cookify/core/data/mappers/failure_mapper.dart'; import 'package:cookify/core/domain/my_either/my_either.dart'; +import 'package:cookify/di/di.dart'; import 'package:cookify/features/profile/data/local/user_statistic_local_store.dart'; import 'package:cookify/features/profile/data/data_sources/profile_remote_data_source.dart'; import 'package:cookify/features/profile/data/mappers/update_avatar_mapper.dart'; import 'package:cookify/features/profile/data/mappers/user_mapper.dart'; +import 'package:cookify/features/profile/data/models/user_model.dart'; import 'package:cookify/features/profile/domain/entities/user_entity.dart'; import 'package:cookify/features/profile/domain/payloads/update_avatar_payload.dart'; import 'package:cookify/features/profile/domain/repositories/profile_repository.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:fpdart/fpdart.dart'; final class ProfileRepositoryImpl implements ProfileRepository { @@ -22,7 +27,25 @@ final class ProfileRepositoryImpl implements ProfileRepository { @override Future> getUser() async { try { - final userModel = await _remoteDataSource.getUser(); + UserModel? userModel; + try { + userModel = await _remoteDataSource.getUser(); + Di.getIt().write( + key: 'profile', + value: jsonEncode(userModel.toJson()), + ); + } catch (e) { + try { + userModel = UserModel.fromJson( + jsonDecode( + await Di.getIt().read(key: 'profile') + as String, + ), + ); + } catch (_) { + throw e; + } + } final userEntity = UserMapper.toEntity(userModel); final statisticDelta = await _userStatisticLocalStore.getDelta(); final actualUserEntity = userEntity.copyWith( diff --git a/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart b/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart index 752a0ad..95e2abf 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart @@ -8,7 +8,6 @@ import 'package:cookify/features/profile/presentation/bloc/profile_event.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; class ProfileUserInfo extends StatelessWidget { diff --git a/frontend/lib/features/recipe/recipe_common/data/data_sources/recipe_common_search_remote_data_source_impl.dart b/frontend/lib/features/recipe/recipe_common/data/data_sources/recipe_common_search_remote_data_source_impl.dart index 1772079..4e07a41 100644 --- a/frontend/lib/features/recipe/recipe_common/data/data_sources/recipe_common_search_remote_data_source_impl.dart +++ b/frontend/lib/features/recipe/recipe_common/data/data_sources/recipe_common_search_remote_data_source_impl.dart @@ -16,7 +16,7 @@ class RecipeCommonSearchRemoteDataSourceImpl SearchCategoryListRequest request, ) async { final response = await _dio.get( - '/api/search/tags?${request.lastId != null ? 'lastId=${request.lastId}' : ''}&name=${request.name}', + '/api/search/tags?name=${request.name}', ); return (response.data as List) @@ -29,7 +29,7 @@ class RecipeCommonSearchRemoteDataSourceImpl SearchIngredientListRequest request, ) async { final response = await _dio.get( - '/api/search/ingredients?${request.lastId != null ? 'lastId=${request.lastId}' : ''}&name=${request.name}', + '/api/search/ingredients?name=${request.name}', ); return (response.data as List) diff --git a/frontend/lib/features/recipe/recipe_common/presentation/widgets/category_text_field.dart b/frontend/lib/features/recipe/recipe_common/presentation/widgets/category_text_field.dart index 8b1fd7f..83a8505 100644 --- a/frontend/lib/features/recipe/recipe_common/presentation/widgets/category_text_field.dart +++ b/frontend/lib/features/recipe/recipe_common/presentation/widgets/category_text_field.dart @@ -157,7 +157,7 @@ class _CategoryTextFieldState extends State { ), dense: true, onTap: () { - _textController.text = category.name; + widget.controller.controller.text = category.name; widget.controller.selectCategory(category); _focusNode.unfocus(); }, @@ -174,7 +174,7 @@ class _CategoryTextFieldState extends State { children: [ Expanded( child: CookifyTextField( - controller: _textController, + controller: widget.controller.controller, focusNode: _focusNode, onChanged: widget.onChanged, hint: MyLocale.of(context).searchCategoryHint, diff --git a/frontend/lib/features/recipe/recipe_common/presentation/widgets/ingredient_text_field.dart b/frontend/lib/features/recipe/recipe_common/presentation/widgets/ingredient_text_field.dart index c022a54..7265ede 100644 --- a/frontend/lib/features/recipe/recipe_common/presentation/widgets/ingredient_text_field.dart +++ b/frontend/lib/features/recipe/recipe_common/presentation/widgets/ingredient_text_field.dart @@ -158,7 +158,7 @@ class _IngredientTextFieldState extends State { ), dense: true, onTap: () { - _textController.text = category.name; + widget.controller.controller.text = category.name; widget.controller.selectIngredient(category); _focusNode.unfocus(); }, @@ -176,7 +176,7 @@ class _IngredientTextFieldState extends State { children: [ Expanded( child: CookifyTextField( - controller: _textController, + controller: widget.controller.controller, focusNode: _focusNode, onChanged: widget.onChanged, hint: MyLocale.of(context).searchIngredientHint, diff --git a/frontend/lib/features/recipe/recipe_form/domain/payloads/publish_recipe_payload.dart b/frontend/lib/features/recipe/recipe_form/domain/payloads/publish_recipe_payload.dart index 97d23c2..37fc727 100644 --- a/frontend/lib/features/recipe/recipe_form/domain/payloads/publish_recipe_payload.dart +++ b/frontend/lib/features/recipe/recipe_form/domain/payloads/publish_recipe_payload.dart @@ -1,3 +1,4 @@ +import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; import 'package:image_picker/image_picker.dart'; class PublishRecipePayload { @@ -22,9 +23,9 @@ class PublishRecipePayload { final int proteins; final int fats; final int carbohydrates; - final String difficulty; + final RecipeDifficulty difficulty; final int cookingTimeMinutes; - final List categories; + final List categories; final List ingredients; final List steps; final List photos; diff --git a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page_content.dart b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page_content.dart index 6f79928..760024e 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page_content.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page_content.dart @@ -2,7 +2,8 @@ import 'dart:io'; import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/domain/use_cases/results/result.dart'; -import 'package:cookify/core/presentation/widgets/cookify_navigation_bar.dart'; +import 'package:cookify/core/presentation/widgets/app.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/core/presentation/widgets/cookify_text_field.dart'; import 'package:cookify/features/profile/data/local/user_statistic_local_store.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/category_entity.dart'; @@ -32,6 +33,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; @@ -82,6 +84,9 @@ class _RecipeFormPageContentState extends State { (_) => _loadDraft(_draftId!), ); } + + fToast = FToast(); + fToast!.init(context); } Future _loadDraft(String id) async { @@ -177,6 +182,7 @@ class _RecipeFormPageContentState extends State { @override void dispose() { + fToast = null; photoController.removeListener(_syncPhotos); photoController.dispose(); nameController.dispose(); @@ -281,9 +287,7 @@ class _RecipeFormPageContentState extends State { } if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(MyLocale.of(context).recipeFormDraftSaved)), - ); + showToast(true, MyLocale.of(context).recipeFormDraftSaved); } finally { if (mounted) { setState(() => _isSavingDraft = false); @@ -381,11 +385,7 @@ class _RecipeFormPageContentState extends State { ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(MyLocale.of(context).recipeFormSavedRecipeAdded), - ), - ); + showToast(true, MyLocale.of(context).recipeFormSavedRecipeAdded); } finally { if (mounted) { setState(() => _isSavingToSaved = false); @@ -430,9 +430,7 @@ class _RecipeFormPageContentState extends State { }); if (!isValid) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(MyLocale.of(context).recipeFormFillRequired)), - ); + showToast(false, MyLocale.of(context).recipeFormFillRequired); } return isValid; @@ -502,9 +500,11 @@ class _RecipeFormPageContentState extends State { proteins: _toInt(proteinsController), fats: _toInt(fatsController), carbohydrates: _toInt(carbsController), - difficulty: difficulty.name, + difficulty: difficulty, cookingTimeMinutes: _toInt(cookingTimeController), - categories: categoriesEntities.map((category) => category.id).toList(), + categories: categoriesEntities + .map((category) => int.parse(category.id)) + .toList(), ingredients: ingredientDrafts .where((draft) => draft.controller.ingredient != null) .map( @@ -550,15 +550,11 @@ class _RecipeFormPageContentState extends State { storage: GetIt.I(), ).incrementPublishedRecipesCount(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(MyLocale.of(context).recipeFormPublished)), - ); + showToast(true, MyLocale.of(context).recipeFormPublished); context.go('/'); break; case Failure(): - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(MyLocale.of(context).recipeFormPublishFailed)), - ); + showToast(false, MyLocale.of(context).recipeFormPublishFailed); break; } } @@ -615,9 +611,7 @@ class _RecipeFormPageContentState extends State { const SizedBox(height: 12.0), CookifyTextField( controller: descriptionController, - label: MyLocale.of( - context, - ).recipeFormDescriptionLabel, + label: MyLocale.of(context).recipeFormDescriptionLabel, hint: MyLocale.of(context).recipeFormDescriptionHint, maxLines: 3, maxLength: 300, @@ -654,42 +648,31 @@ class _RecipeFormPageContentState extends State { ], ), const SizedBox(height: 16.0), - _SectionLabel( - MyLocale.of(context).recipeFormDifficulty, - ), + _SectionLabel(MyLocale.of(context).recipeFormDifficulty), const SizedBox(height: 8.0), Row( children: [ _DifficultyChip( - label: MyLocale.of( - context, - ).recipeFormDifficultyEasy, + label: MyLocale.of(context).recipeFormDifficultyEasy, isSelected: difficulty == RecipeDifficulty.easy, - onTap: () => setState( - () => difficulty = RecipeDifficulty.easy, - ), + onTap: () => + setState(() => difficulty = RecipeDifficulty.easy), difficulty: RecipeDifficulty.easy, ), const SizedBox(width: 8.0), _DifficultyChip( - label: MyLocale.of( - context, - ).recipeFormDifficultyMedium, + label: MyLocale.of(context).recipeFormDifficultyMedium, isSelected: difficulty == RecipeDifficulty.medium, - onTap: () => setState( - () => difficulty = RecipeDifficulty.medium, - ), + onTap: () => + setState(() => difficulty = RecipeDifficulty.medium), difficulty: RecipeDifficulty.medium, ), const SizedBox(width: 8.0), _DifficultyChip( - label: MyLocale.of( - context, - ).recipeFormDifficultyHard, + label: MyLocale.of(context).recipeFormDifficultyHard, isSelected: difficulty == RecipeDifficulty.hard, - onTap: () => setState( - () => difficulty = RecipeDifficulty.hard, - ), + onTap: () => + setState(() => difficulty = RecipeDifficulty.hard), difficulty: RecipeDifficulty.hard, ), ], @@ -697,13 +680,10 @@ class _RecipeFormPageContentState extends State { const SizedBox(height: 16.0), CookifyTextField( controller: cookingTimeController, - label: MyLocale.of( - context, - ).recipeFormCookingTimeLabel, + label: MyLocale.of(context).recipeFormCookingTimeLabel, hint: MyLocale.of(context).recipeFormCookingTimeHint, inputType: TextInputType.number, - inputFormatter: - FilteringTextInputFormatter.digitsOnly, + inputFormatter: FilteringTextInputFormatter.digitsOnly, maxLength: 3, failureMessage: _showValidationErrors && @@ -712,9 +692,7 @@ class _RecipeFormPageContentState extends State { : null, ), const SizedBox(height: 20.0), - _SectionLabel( - MyLocale.of(context).recipeFormCategories, - ), + _SectionLabel(MyLocale.of(context).recipeFormCategories), const SizedBox(height: 8.0), ...List.generate( categoryControllers.length, @@ -729,10 +707,7 @@ class _RecipeFormPageContentState extends State { .searchCategoryList, onDelete: () { setState(() { - categoryControllers - .removeAt(i) - .controller - .dispose(); + categoryControllers.removeAt(i).controller.dispose(); }); }, ), @@ -760,18 +735,14 @@ class _RecipeFormPageContentState extends State { .isEmpty) ...[ const SizedBox(height: 4.0), _FieldErrorText( - message: MyLocale.of( - context, - ).recipeFormErrorCategories, + message: MyLocale.of(context).recipeFormErrorCategories, ), ], const SizedBox(height: 20.0), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _SectionLabel( - MyLocale.of(context).recipeFormIngredients, - ), + _SectionLabel(MyLocale.of(context).recipeFormIngredients), ], ), const SizedBox(height: 8.0), @@ -791,9 +762,7 @@ class _RecipeFormPageContentState extends State { .searchIngredientList, onDelete: () { setState(() { - ingredientDrafts - .removeAt(index) - .dispose(); + ingredientDrafts.removeAt(index).dispose(); }); }, ), @@ -835,9 +804,7 @@ class _RecipeFormPageContentState extends State { onPressed: () { setState(() { ingredientDrafts.add( - _IngredientDraft( - controller: IngredientController(), - ), + _IngredientDraft(controller: IngredientController()), ); }); }, @@ -852,26 +819,19 @@ class _RecipeFormPageContentState extends State { ), if (_showValidationErrors && ingredientDrafts + .where((draft) => draft.controller.ingredient != null) .where( (draft) => - draft.controller.ingredient != null, - ) - .where( - (draft) => draft.amountController.text - .trim() - .isNotEmpty, + draft.amountController.text.trim().isNotEmpty, ) .where( - (draft) => draft.unitController.text - .trim() - .isNotEmpty, + (draft) => + draft.unitController.text.trim().isNotEmpty, ) .isEmpty) ...[ const SizedBox(height: 4.0), _FieldErrorText( - message: MyLocale.of( - context, - ).recipeFormErrorIngredients, + message: MyLocale.of(context).recipeFormErrorIngredients, ), ], const SizedBox(height: 20.0), @@ -915,14 +875,11 @@ class _RecipeFormPageContentState extends State { if (_showValidationErrors && stepDrafts .where( - (step) => step.titleController.text - .trim() - .isNotEmpty, + (step) => step.titleController.text.trim().isNotEmpty, ) .where( - (step) => step.descriptionController.text - .trim() - .isNotEmpty, + (step) => + step.descriptionController.text.trim().isNotEmpty, ) .isEmpty) ...[ const SizedBox(height: 4.0), @@ -935,9 +892,7 @@ class _RecipeFormPageContentState extends State { height: 52.0, child: OutlinedButton( onPressed: - _isSavingDraft || - _isSavingToSaved || - state.isPublishing + _isSavingDraft || _isSavingToSaved || state.isPublishing ? null : _saveDraft, style: OutlinedButton.styleFrom( @@ -951,9 +906,7 @@ class _RecipeFormPageContentState extends State { _isSavingDraft ? MyLocale.of(context).recipeFormSaving : MyLocale.of(context).recipeFormSaveDraft, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontWeight: FontWeight.w700), ), ), ), @@ -962,9 +915,7 @@ class _RecipeFormPageContentState extends State { height: 52.0, child: OutlinedButton( onPressed: - _isSavingDraft || - _isSavingToSaved || - state.isPublishing + _isSavingDraft || _isSavingToSaved || state.isPublishing ? null : _saveToMyRecipes, style: OutlinedButton.styleFrom( @@ -978,9 +929,7 @@ class _RecipeFormPageContentState extends State { _isSavingToSaved ? MyLocale.of(context).recipeFormSaving : MyLocale.of(context).recipeFormSaveToSaved, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontWeight: FontWeight.w700), ), ), ), @@ -989,9 +938,7 @@ class _RecipeFormPageContentState extends State { height: 56.0, child: ElevatedButton( onPressed: - state.isPublishing || - _isSavingDraft || - _isSavingToSaved + state.isPublishing || _isSavingDraft || _isSavingToSaved ? null : _publish, style: ElevatedButton.styleFrom( @@ -1005,9 +952,7 @@ class _RecipeFormPageContentState extends State { state.isPublishing ? MyLocale.of(context).recipeFormPublishing : MyLocale.of(context).recipeFormPublish, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), + style: const TextStyle(fontWeight: FontWeight.w700), ), ), ), @@ -1303,22 +1248,22 @@ class _StepCard extends StatelessWidget { ), child: draft.photo == null ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.add_a_photo_outlined, - color: Color(0x99E5C9A8), - ), - const SizedBox(height: 8.0), - Text( - MyLocale.of(context).recipeFormAddPhoto, - style: const TextStyle( - color: Color(0xB3E5C9A8), - fontWeight: FontWeight.w600, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.add_a_photo_outlined, + color: Color(0x99E5C9A8), ), - ), - ], - ) + const SizedBox(height: 8.0), + Text( + MyLocale.of(context).recipeFormAddPhoto, + style: const TextStyle( + color: Color(0xB3E5C9A8), + fontWeight: FontWeight.w600, + ), + ), + ], + ) : ClipRRect( borderRadius: BorderRadius.circular(10.0), child: Image.file( diff --git a/frontend/lib/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart b/frontend/lib/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart index afbd8a9..cc36a3e 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart @@ -72,7 +72,7 @@ class RecipeSearchFormCubit extends Cubit { ); if (result is Success && (result as Success>).data.isNotEmpty) { - ingredients.add((result as Success>).data.first); + ingredients.add((result).data.first); } } diff --git a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page.dart b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page.dart index cf635c7..c08d4b8 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page.dart @@ -1,4 +1,3 @@ -import 'package:cookify/core/presentation/widgets/cookify_navigation_bar.dart'; import 'package:cookify/features/recipe/recipe_search/di/recipe_search_di.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart'; diff --git a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart index 2803e4f..9f0771f 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart @@ -1,7 +1,8 @@ import 'dart:convert'; -import 'dart:io'; import 'package:cookify/core/l10n/my_locale.dart'; +import 'package:cookify/core/presentation/widgets/app.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/core/presentation/widgets/key_board_listener.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/category_entity.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/ingredient_entity.dart'; @@ -13,6 +14,8 @@ import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_s import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_page_args.dart'; import 'package:flutter/material.dart' hide KeyboardListener; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:google_generative_ai/google_generative_ai.dart'; import 'package:image_picker/image_picker.dart'; @@ -38,6 +41,9 @@ class RecipeSearchFormPageContent extends StatefulWidget { class _RecipeSearchFormPageContentState extends State { + final TextEditingController _categoryController = TextEditingController(); + final TextEditingController _ingredientController = TextEditingController(); + bool _isShowKeyboard = false; final KeyboardListener _keyboardListener = KeyboardListener(); @@ -63,6 +69,8 @@ class _RecipeSearchFormPageContentState final List selectedCategories = []; final List selectedIngredients = []; + late final List keys; + @override void initState() { super.initState(); @@ -71,12 +79,17 @@ class _RecipeSearchFormPageContentState onChange: (bool isVisible) { setState(() { if (_isShowKeyboard && !isVisible) { + _categoryFocus.unfocus(); + _ingredientFocus.unfocus(); _closeOverlay(); } _isShowKeyboard = isVisible; }); }, ); + fToast = FToast(); + fToast!.init(context); + keys = dotenv.get('GEMINI_API_KEY').split('|'); } void _setupFocusListeners() { @@ -109,6 +122,7 @@ class _RecipeSearchFormPageContentState _categoryFocus.dispose(); _ingredientFocus.dispose(); _closeOverlay(); + fToast = null; super.dispose(); } @@ -241,6 +255,7 @@ class _RecipeSearchFormPageContentState // Категории SliverToBoxAdapter( child: _buildSearchSection( + controller: _categoryController, title: MyLocale.of(context).searchCategoriesTitle, hint: MyLocale.of(context).searchAddCategoryHint, link: _categoryLink, @@ -250,8 +265,9 @@ class _RecipeSearchFormPageContentState context.read().searchCategoryList(val), onRemove: (item) => setState(() => selectedCategories.remove(item)), - onTap: () => - context.read().searchCategoryList(''), + onTap: () => context + .read() + .searchCategoryList(_categoryController.text), ), ), @@ -265,6 +281,7 @@ class _RecipeSearchFormPageContentState children: [ Expanded( child: _buildSearchSection( + controller: _ingredientController, title: MyLocale.of(context).searchIngredientsTitle, hint: MyLocale.of(context).searchAddIngredientHint, link: _ingredientLink, @@ -277,7 +294,7 @@ class _RecipeSearchFormPageContentState setState(() => selectedIngredients.remove(item)), onTap: () => context .read() - .searchIngredientList(''), + .searchIngredientList(_ingredientController.text), ), ), @@ -286,33 +303,44 @@ class _RecipeSearchFormPageContentState child: GestureDetector( onTap: () async { final image = await ImagePickerSheet.show(context); - + if (image != null) { - final model = GenerativeModel( - model: 'models/gemini-2.5-flash', - apiKey: 'AIzaSyCGlL2vJ24CtZ-zKi6lv9PN_Jom3qM-lUA', - // Настройка формата ответа JSON - generationConfig: GenerationConfig( - responseMimeType: 'application/json', - ), - ); - final bytes = await image.readAsBytes(); - + final prompt = TextPart( "Перечисли все продукты на фото. " "Ответь строго в формате JSON: {'products': ['название', 'название']}", ); - + final content = [ - Content.multi([prompt, DataPart('image/jpeg', bytes)]), + Content.multi([ + prompt, + DataPart('image/jpeg', bytes), + ]), ]; - - final response = await model.generateContent(content); - if (context.mounted) { - final json = - jsonDecode(response.text ?? '') - as Map; + + Map? json; + for (int i = 0; i < keys.length; i++) { + try { + final model = GenerativeModel( + model: 'models/gemini-2.5-flash', + apiKey: keys[i], + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + ), + ); + final response = await model.generateContent( + content, + ); + json = + jsonDecode(response.text ?? '') + as Map; + break; + } catch (e) { + //print(e); + } + } + if (context.mounted && json != null) { final ingredients = await context .read() .searchIngredientListFromAI( @@ -320,8 +348,18 @@ class _RecipeSearchFormPageContentState .map((e) => e as String) .toList(), ); - - setState(() => selectedIngredients.addAll(ingredients)); + + if (ingredients.isEmpty) { + showToast(false, 'Не удалось распознать продукты'); + return; + } + + setState( + () => selectedIngredients.addAll(ingredients), + ); + showToast(true, 'Продукты распознаны'); + } else { + showToast(false, 'Не удалось распознать продукты'); } } }, @@ -372,6 +410,7 @@ class _RecipeSearchFormPageContentState } Widget _buildSearchSection({ + required TextEditingController controller, required String title, required String hint, required LayerLink link, @@ -396,6 +435,7 @@ class _RecipeSearchFormPageContentState CompositedTransformTarget( link: link, child: _CustomTextField( + controller: controller, focusNode: focusNode, hintText: hint, onChanged: onSearch, @@ -423,6 +463,12 @@ class _RecipeSearchFormPageContentState } void _showSearchOverlay({required LayerLink link, required bool isCategory}) { + // Если оверлей уже открыт, просто обновляем его содержимое + if (_activeOverlay != null) { + _activeOverlay?.markNeedsBuild(); + return; + } + final cubit = context.read(); _activeOverlay = OverlayEntry( @@ -432,7 +478,6 @@ class _RecipeSearchFormPageContentState link: link, offset: const Offset(0, 52), child: Material( - // Чтобы работал GestureDetector и стили текста color: Colors.transparent, child: BlocProvider.value( value: cubit, @@ -449,6 +494,7 @@ class _RecipeSearchFormPageContentState color: Colors.white.withValues(alpha: 0.05), ), ), + height: 100, child: list.isEmpty ? Padding( padding: const EdgeInsets.all(16), @@ -460,13 +506,16 @@ class _RecipeSearchFormPageContentState ), ), ) - : Column( - mainAxisSize: MainAxisSize.min, - children: list - .map( - (item) => _buildOverlayItem(item, isCategory), - ) - .toList(), + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: list + .map( + (item) => + _buildOverlayItem(item, isCategory), + ) + .toList(), + ), ), ); }, @@ -495,8 +544,9 @@ class _RecipeSearchFormPageContentState ? selectedIngredients.removeWhere((e) => e.id == item.id) : selectedIngredients.add(item); } - _closeOverlay(); - _showSearchOverlay(link: _categoryLink, isCategory: isCategory); + // Не закрываем и не пересоздаем оверлей! + // Просто обновляем текущий + _activeOverlay?.markNeedsBuild(); }), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/frontend/lib/features/sign_up/presentation/bloc/sign_up_bloc.dart b/frontend/lib/features/sign_up/presentation/bloc/sign_up_bloc.dart index f97684e..688739c 100644 --- a/frontend/lib/features/sign_up/presentation/bloc/sign_up_bloc.dart +++ b/frontend/lib/features/sign_up/presentation/bloc/sign_up_bloc.dart @@ -30,6 +30,7 @@ class SignUpBloc extends Bloc { on(_onValidateEmail); on(_onValidatePassword); on(_onValidateConfirmPassword); + on(_onSignUpWithGoogle); on(_onSignUp); } @@ -176,7 +177,7 @@ class SignUpBloc extends Bloc { _signUpNavigator?.goRecipeFeed(); } } catch (error) { - print('Ошибка входа: $error'); + //print('Ошибка входа: $error'); } } diff --git a/frontend/lib/features/token/data/data_sources/token_local_data_source_impl.dart b/frontend/lib/features/token/data/data_sources/token_local_data_source_impl.dart index fafaa9f..780c8e0 100644 --- a/frontend/lib/features/token/data/data_sources/token_local_data_source_impl.dart +++ b/frontend/lib/features/token/data/data_sources/token_local_data_source_impl.dart @@ -32,6 +32,7 @@ final class TokenLocalDataSourceImpl implements TokenLocalDataSource { @override Future deleteToken() { + _storage.delete(key: 'profile'); return _storage.delete(key: tokenKey); } } diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index a8bb8bb..61927a1 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,10 +1,12 @@ import 'package:cookify/core/presentation/widgets/app.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: 'assets/.env/.env'); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( // Верхняя панель (Status Bar) diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 4644e4b..4b4b22d 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -358,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" flutter_launcher_icons: dependency: "direct main" description: @@ -878,7 +886,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 412644d..717a813 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: google_sign_in: ^6.0.0 fluttertoast: ^9.0.0 google_generative_ai: ^0.4.7 + flutter_dotenv: ^6.0.1 + path: ^1.9.1 dev_dependencies: flutter_test: @@ -54,6 +56,7 @@ flutter: assets: - assets/images/ + - assets/.env/ fonts: - family: PlusJakartaSans fonts: From 7f8789a4c2584cc3f17cecfb366411cf2ddfbfcf Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Tue, 12 May 2026 00:40:25 +0300 Subject: [PATCH 3/4] feat: add pull to refresh --- .../widgets/cookify_pagination_list_view.dart | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/lib/core/presentation/widgets/cookify_pagination_list_view.dart b/frontend/lib/core/presentation/widgets/cookify_pagination_list_view.dart index 013c786..da50964 100644 --- a/frontend/lib/core/presentation/widgets/cookify_pagination_list_view.dart +++ b/frontend/lib/core/presentation/widgets/cookify_pagination_list_view.dart @@ -1,5 +1,7 @@ import 'package:cookify/core/presentation/widgets/cookify_loading_content.dart'; +import 'package:cookify/features/recipe/recipe_feed/presentation/bloc/recipe_feed_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class CookifyPaginationListView extends StatelessWidget { const CookifyPaginationListView({ @@ -33,13 +35,20 @@ class CookifyPaginationListView extends StatelessWidget { onNotification: _onScrollNotification, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: ListView.separated( - controller: controller, - itemBuilder: (_, index) => index != items.length - ? items[index] - : const CookifyLoadingContent(), - separatorBuilder: (_, _) => const SizedBox(height: 24.0), - itemCount: isLoading ? items.length + 1 : items.length, + child: RefreshIndicator( + onRefresh: () async { + await context.read().getRecipeList(); + }, + backgroundColor: Color(0xFF1A0F0A), + color: Color(0xFFE5C9A8), + child: ListView.separated( + controller: controller, + itemBuilder: (_, index) => index != items.length + ? items[index] + : const CookifyLoadingContent(), + separatorBuilder: (_, _) => const SizedBox(height: 24.0), + itemCount: isLoading ? items.length + 1 : items.length, + ), ), ), ); From ce9033d12bb85a87578d7e6934479c625372a9d6 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Tue, 12 May 2026 00:45:49 +0300 Subject: [PATCH 4/4] fix: remove password change --- .../auth_common/presentation/widgets/auth_bar.dart | 14 +++++++------- .../presentation/widgets/profile_settings.dart | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_bar.dart b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_bar.dart index 312cef2..7cc5e07 100644 --- a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_bar.dart +++ b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_bar.dart @@ -33,13 +33,13 @@ class AuthBar extends StatelessWidget { isSelected: type == AuthPageContentType.signUp, title: MyLocale.of(context).authBarSignUp, ), - AuthBarItem( - onTap: () { - onTypeChanged(AuthPageContentType.restore); - }, - isSelected: type == AuthPageContentType.restore, - title: MyLocale.of(context).authBarRestore, - ), + // AuthBarItem( + // onTap: () { + // onTypeChanged(AuthPageContentType.restore); + // }, + // isSelected: type == AuthPageContentType.restore, + // title: MyLocale.of(context).authBarRestore, + // ), ], ), ); diff --git a/frontend/lib/features/profile/presentation/widgets/profile_settings.dart b/frontend/lib/features/profile/presentation/widgets/profile_settings.dart index 7a3488d..8600ac6 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_settings.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_settings.dart @@ -30,9 +30,9 @@ class ProfileSettings extends StatelessWidget { ProfileSettingsLocale(locale: locale), - const SizedBox(height: 12.0), + // const SizedBox(height: 12.0), - const _ChangePasswordButton(), + // const _ChangePasswordButton(), const SizedBox(height: 12.0),