From 7e91d656dfa74b21aaa428309c9b4463387a3a04 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Sat, 9 May 2026 16:09:52 +0300 Subject: [PATCH 1/6] add toast --- .../lib/core/presentation/widgets/app.dart | 10 ++++- .../core/presentation/widgets/app_toast.dart | 32 ++++++++++++++ frontend/lib/di/di.dart | 18 ++++++++ .../presentation/widgets/auth_text_field.dart | 6 +-- .../localized_password_validation_status.dart | 2 +- .../presentation/pages/otp_page_content.dart | 2 +- .../restore_remote_data_source_impl.dart | 4 ++ .../presentation/bloc/restore_bloc.dart | 15 ++++++- .../pages/restore_widget_content.dart | 14 +++++- .../sign_in_remote_data_source_impl.dart | 4 ++ .../presentation/bloc/sign_in_bloc.dart | 11 ++++- .../pages/sign_in_widget_content.dart | 44 +++++++------------ .../sign_up_remote_data_source_impl.dart | 4 ++ .../presentation/bloc/sign_up_bloc.dart | 16 ++++++- .../pages/sign_up_widget_content.dart | 10 +++++ frontend/pubspec.lock | 8 ++++ frontend/pubspec.yaml | 1 + 17 files changed, 161 insertions(+), 40 deletions(-) create mode 100644 frontend/lib/core/presentation/widgets/app_toast.dart diff --git a/frontend/lib/core/presentation/widgets/app.dart b/frontend/lib/core/presentation/widgets/app.dart index 59d4163..f9daea5 100644 --- a/frontend/lib/core/presentation/widgets/app.dart +++ b/frontend/lib/core/presentation/widgets/app.dart @@ -5,10 +5,18 @@ import 'package:cookify/features/locale/presentation/pages/locale_wrapper.dart'; import 'package:cookify/navigations/navigator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; -class App extends StatelessWidget { +FToast? fToast; + +class App extends StatefulWidget { const App({super.key}); + @override + State createState() => _AppState(); +} + +class _AppState extends State { @override Widget build(BuildContext context) { return LocaleWrapper( diff --git a/frontend/lib/core/presentation/widgets/app_toast.dart b/frontend/lib/core/presentation/widgets/app_toast.dart new file mode 100644 index 0000000..06ae6f1 --- /dev/null +++ b/frontend/lib/core/presentation/widgets/app_toast.dart @@ -0,0 +1,32 @@ +import 'app.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +void showToast(bool isSuccess, String text) { + Widget toast = Container( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25.0), + border: Border.all( + color: Color(0xFFE5C9A8).withAlpha((0.1 * 255).toInt()), + ), + color: Color(0xFF2C1C16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isSuccess + ? Icon(Icons.check, color: Color(0xFF7FB069)) + : Icon(Icons.close, color: Color(0xFFE76F51)), + SizedBox(width: 12.0), + Text(text, style: const TextStyle(color: Color(0xFFE5C9A8))), + ], + ), + ); + + fToast?.showToast( + child: toast, + gravity: ToastGravity.BOTTOM, + toastDuration: Duration(seconds: 2), + ); +} diff --git a/frontend/lib/di/di.dart b/frontend/lib/di/di.dart index 9b1c0a6..e8beeae 100644 --- a/frontend/lib/di/di.dart +++ b/frontend/lib/di/di.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/data/exceptions/exceptions.dart'; import 'package:cookify/dependencies/change_password_dependency_impl.dart'; import 'package:cookify/dependencies/profile_dependency_impl.dart'; import 'package:cookify/dependencies/restore_dependency_impl.dart'; @@ -33,6 +34,7 @@ abstract class Di { dio = Dio(BaseOptions(baseUrl: 'https://$address')); Di.dio = dio; dio.interceptors.add(PrettyDioLogger()); + dio.interceptors.add(FailureInterceptor()); getIt.registerSingleton(dio); dio.interceptors.add(TokenDi.tokenInterceptor); } @@ -77,3 +79,19 @@ abstract class Di { deleteTokenUseCase: TokenDi.deleteTokenUseCase, ); } + +class FailureInterceptor extends InterceptorsWrapper { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + 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 bbe4e91..f3a9b45 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 @@ -96,9 +96,9 @@ class _AuthTextFieldState extends State { keyboardType: widget.inputType, maxLength: 40, onChanged: widget.onChanged, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9@$!%*?&_.]')), - ], + // inputFormatters: [ + // FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9@$!%*?&_.]')), + // ], style: const TextStyle(color: Color(0xFFFFE6C9)), cursorColor: Color(0xFFFFE6C9), ), diff --git a/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart b/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart index 23749ef..44e3bcc 100644 --- a/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart +++ b/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart @@ -6,7 +6,7 @@ extension LocalizedPasswordValidationStatus on PasswordValidationStatus { PasswordValidationStatus.empty => 'Поле не может быть пустым', PasswordValidationStatus.tooShort => 'Пароль слишком короткий', PasswordValidationStatus.tooLong => 'Пароль слишком длинный', - PasswordValidationStatus.invalid => 'Пароль должен содержать строчные и заглавные буквы, цифры и спец символы', + PasswordValidationStatus.invalid => r'Пароль должен содержать строчные и заглавные латинские буквы, цифры и спец символы (@$!%*?&_)', PasswordValidationStatus.valid => '', }; } diff --git a/frontend/lib/features/otp/presentation/pages/otp_page_content.dart b/frontend/lib/features/otp/presentation/pages/otp_page_content.dart index ae156ef..d6dd769 100644 --- a/frontend/lib/features/otp/presentation/pages/otp_page_content.dart +++ b/frontend/lib/features/otp/presentation/pages/otp_page_content.dart @@ -76,7 +76,7 @@ class _OtpPageContentState extends State { OtpTextField( showCursor: false, numberOfFields: 6, - fieldWidth: 52.0, + fieldWidth: 40.0, fieldHeight: 52.0, textStyle: const TextStyle( color: Color(0xFFFFE6C9), diff --git a/frontend/lib/features/restore/data/data_sources/restore_remote_data_source_impl.dart b/frontend/lib/features/restore/data/data_sources/restore_remote_data_source_impl.dart index 00e1c8b..63253fa 100644 --- a/frontend/lib/features/restore/data/data_sources/restore_remote_data_source_impl.dart +++ b/frontend/lib/features/restore/data/data_sources/restore_remote_data_source_impl.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/data/exceptions/exceptions.dart'; import 'package:cookify/features/restore/data/data_sources/restore_remote_data_source.dart'; import 'package:cookify/features/restore/data/exceptions/restore_exceptions.dart'; import 'package:cookify/features/restore/data/requests/restore_request.dart'; @@ -13,6 +14,9 @@ class RestoreRemoteDataSourceImpl implements RestoreRemoteDataSource { try { await _dio.post('/api/restore', data: request.toJson()); } on DioException catch (e) { + if (e.error is NetworkException) { + throw e.error as NetworkException; + } if (e.response?.statusCode == 400) { throw NonExistentLoginOrEmailException(); } diff --git a/frontend/lib/features/restore/presentation/bloc/restore_bloc.dart b/frontend/lib/features/restore/presentation/bloc/restore_bloc.dart index 416b6be..16d7c19 100644 --- a/frontend/lib/features/restore/presentation/bloc/restore_bloc.dart +++ b/frontend/lib/features/restore/presentation/bloc/restore_bloc.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:cookify/core/domain/failures/failures.dart'; import 'package:cookify/core/presentation/localize/localized_error_value.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/features/restore/dependencies/restore_dependency.dart'; import 'package:cookify/features/restore/domain/payloads/restore_payload.dart'; import 'package:cookify/features/restore/domain/use_cases/restore_use_case.dart'; @@ -55,8 +57,17 @@ class RestoreBloc extends Bloc { emit(state.copyWith(isLoading: true, hasError: false)); - _restoreUseCase(RestorePayload(login: state.login.value)); - _restoreNavigator?.goOtp(state.login.value); + final result = await _restoreUseCase( + RestorePayload(login: state.login.value), + ); + result.fold((failure) { + emit(state.copyWith(hasError: true)); + if (failure is NetworkFailure) { + showToast(false, 'Нет подключения к интернету'); + } else if (failure is UnknownFailure) { + showToast(false, 'Повторите попытку'); + } + }, (_) => _restoreNavigator?.goOtp(state.login.value)); } @override diff --git a/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart b/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart index 11f17d3..38f4d96 100644 --- a/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart +++ b/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/presentation/widgets/app.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_service_button.dart'; @@ -7,6 +8,7 @@ import 'package:cookify/features/restore/presentation/bloc/restore_event.dart'; import 'package:cookify/features/restore/presentation/bloc/restore_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; class RestoreWidgetContent extends StatefulWidget { const RestoreWidgetContent({super.key}); @@ -18,8 +20,16 @@ class RestoreWidgetContent extends StatefulWidget { class _RestoreWidgetContentState extends State { final loginController = TextEditingController(); + @override + void initState() { + super.initState(); + fToast = FToast(); + fToast!.init(context); + } + @override void dispose() { + fToast = null; loginController.dispose(); super.dispose(); } @@ -42,7 +52,9 @@ class _RestoreWidgetContentState extends State { label: 'ЛОГИН ИЛИ EMAIL', hint: 'Введите логин или email', isPassword: false, - failureMessage: state.login.localizeError?.call(context), + failureMessage: + state.login.localizeError?.call(context) ?? + (state.hasError ? 'Неверный логин или email' : null), ), AuthButton( diff --git a/frontend/lib/features/sign_in/data/data_sources/sign_in_remote_data_source_impl.dart b/frontend/lib/features/sign_in/data/data_sources/sign_in_remote_data_source_impl.dart index 750379a..3fd73a3 100644 --- a/frontend/lib/features/sign_in/data/data_sources/sign_in_remote_data_source_impl.dart +++ b/frontend/lib/features/sign_in/data/data_sources/sign_in_remote_data_source_impl.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/data/exceptions/exceptions.dart'; import 'package:cookify/features/sign_in/data/consts/sign_in_end_points.dart'; import 'package:cookify/features/sign_in/data/data_sources/sign_in_remote_data_source.dart'; import 'package:cookify/features/sign_in/data/exceptions/sign_in_exceptions.dart'; @@ -17,6 +18,9 @@ final class SignInRemoteDataSourceImpl implements SignInRemoteDataSource { return TokenModel.fromJson(response.data); } on DioException catch (e) { + if (e.error is NetworkException) { + throw e.error as NetworkException; + } if (e.response?.statusCode == 401) { throw IncorrectLoginOrPasswordException(); } diff --git a/frontend/lib/features/sign_in/presentation/bloc/sign_in_bloc.dart b/frontend/lib/features/sign_in/presentation/bloc/sign_in_bloc.dart index dc18ebf..d233147 100644 --- a/frontend/lib/features/sign_in/presentation/bloc/sign_in_bloc.dart +++ b/frontend/lib/features/sign_in/presentation/bloc/sign_in_bloc.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:cookify/core/domain/failures/failures.dart'; import 'package:cookify/core/presentation/localize/localized_error_value.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/di/di.dart'; import 'package:cookify/features/sign_in/dependecies/sign_in_dependency.dart'; import 'package:cookify/features/sign_in/domain/payloads/sign_in_payload.dart'; @@ -80,7 +82,14 @@ class SignInBloc extends Bloc { if (isClosed) return; result.fold( - (failure) => emit(state.copyWith(isLoading: false, hasError: true)), + (failure) { + if (failure is NetworkFailure) { + showToast(false, 'Нет подключения к интернету'); + } else if (failure is UnknownFailure) { + showToast(false, 'Повторите попытку'); + } + emit(state.copyWith(isLoading: false, hasError: true)); + }, (token) async { await _signInDependency.setToken(token); _signInNavigator?.goRecipeFeed(); diff --git a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart index 0b71dea..c8c2303 100644 --- a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart +++ b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart @@ -1,3 +1,5 @@ +import 'package:cookify/core/presentation/widgets/app.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_service_button.dart'; @@ -7,6 +9,7 @@ import 'package:cookify/features/sign_in/presentation/bloc/sign_in_event.dart'; import 'package:cookify/features/sign_in/presentation/bloc/sign_in_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:google_sign_in/google_sign_in.dart'; class SignInWidgetContent extends StatefulWidget { @@ -20,16 +23,24 @@ class _SignInWidgetContentState extends State { final loginController = TextEditingController(); final passwordController = TextEditingController(); + @override + void initState() { + super.initState(); + fToast = FToast(); + fToast!.init(context); + } + @override void dispose() { loginController.dispose(); passwordController.dispose(); + fToast = null; super.dispose(); } @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( builder: (context, state) { return Column( spacing: 40.0, @@ -84,7 +95,7 @@ class _SignInWidgetContentState extends State { Expanded( child: AuthServiceButton( onPressed: () { - signInWithGoogle(); + context.read().add(SignInWithGoogle()); }, imagePath: 'google', ), @@ -101,32 +112,9 @@ class _SignInWidgetContentState extends State { ], ); }, - ); - } -} - -final GoogleSignIn _googleSignIn = GoogleSignIn( - serverClientId: '139854363821-gv5jnts7t67e8mpm2a1erv6fu84gd8nr.apps.googleusercontent.com', -); + listener: (context, state) { -Future signInWithGoogle() async { - try { - final GoogleSignInAccount? account = await _googleSignIn.signIn(); - if (account != null) { - final GoogleSignInAuthentication auth = await account.authentication; - - // Это тот самый токен, который ждет бэкенд - final String? idToken = auth.idToken; - - // Отправляем на C# - // var response = await http.post( - // Uri.parse('https://your-api.com'), - // body: {'idToken': idToken}, - // ); - // print('Статус бэка: ${response.statusCode}'); - } - } catch (error) { - print('Ошибка входа: $error'); + }, + ); } } - diff --git a/frontend/lib/features/sign_up/data/data_sources/sign_up_remote_data_source_impl.dart b/frontend/lib/features/sign_up/data/data_sources/sign_up_remote_data_source_impl.dart index 25f6b03..4e903fa 100644 --- a/frontend/lib/features/sign_up/data/data_sources/sign_up_remote_data_source_impl.dart +++ b/frontend/lib/features/sign_up/data/data_sources/sign_up_remote_data_source_impl.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/data/exceptions/exceptions.dart'; import 'package:cookify/features/sign_up/data/consts/sign_up_end_points.dart'; import 'package:cookify/features/sign_up/data/data_sources/sign_up_remote_data_source.dart'; import 'package:cookify/features/sign_up/data/exceptions/sign_in_exceptions.dart'; @@ -14,6 +15,9 @@ final class SignUpRemoteDataSourceImpl implements SignUpRemoteDataSource { try { await _dio.post(signUpEndPoint, data: request.toJson()); } on DioException catch (e) { + if (e.error is NetworkException) { + throw e.error as NetworkException; + } if (e.response?.statusCode == 400) { final a = e.response!.data.first['code'] as String; if (a == 'DuplicateUserName') { 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 8a1f608..f97684e 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 @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:cookify/core/domain/failures/failures.dart'; import 'package:cookify/core/presentation/localize/localized_error_value.dart'; +import 'package:cookify/core/presentation/widgets/app_toast.dart'; import 'package:cookify/di/di.dart'; import 'package:cookify/features/sign_up/dependencies/sign_up_dependency.dart'; import 'package:cookify/features/sign_up/domain/payloads/sign_up_payload.dart'; @@ -137,7 +139,14 @@ class SignUpBloc extends Bloc { if (isClosed) return; result.fold( - (failure) => emit(state.copyWith(isLoading: false, failure: failure)), + (failure) { + emit(state.copyWith(isLoading: false, failure: failure)); + if (failure is NetworkFailure) { + showToast(false, 'Нет подключения к интернету'); + } else if (failure is UnknownFailure) { + showToast(false, 'Повторите попытку'); + } + }, (_) { _signUpNavigator?.goOtp(state.email.value); }, @@ -157,7 +166,10 @@ class SignUpBloc extends Bloc { final String? idToken = auth.idToken; - final response = await Di.dio.post('/api/google', data: {'id_token': idToken}); + final response = await Di.dio.post( + '/api/google', + data: {'id_token': idToken}, + ); final token = TokenMapper.toEntity(TokenModel.fromJson(response.data)); await TokenDi.setTokenUseCase(SetTokenPayload(token: token)); diff --git a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart index 1a507ea..d791599 100644 --- a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart +++ b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/presentation/widgets/app.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_service_button.dart'; @@ -8,6 +9,7 @@ import 'package:cookify/features/sign_up/presentation/bloc/sign_up_event.dart'; import 'package:cookify/features/sign_up/presentation/bloc/sign_up_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; class SignUpWidgetContent extends StatefulWidget { const SignUpWidgetContent({super.key}); @@ -22,8 +24,16 @@ class _SignUpWidgetContentState extends State { final passwordController = TextEditingController(); final confirmPasswordController = TextEditingController(); + @override + void initState() { + super.initState(); + fToast = FToast(); + fToast!.init(context); + } + @override void dispose() { + fToast = null; loginController.dispose(); emailController.dispose(); passwordController.dispose(); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 28f8bc8..618127e 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -469,6 +469,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2" + url: "https://pub.dev" + source: hosted + version: "9.0.0" fpdart: dependency: "direct main" description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index a849796..2297efc 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: shared_preferences: ^2.5.5 flutter_staggered_grid_view: ^0.7.0 google_sign_in: ^6.0.0 + fluttertoast: ^9.0.0 dev_dependencies: flutter_test: From d51a6abd0e168cff13fc670315bb20fb906d5cb3 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Sat, 9 May 2026 22:22:51 +0300 Subject: [PATCH 2/6] fix: remove transitions --- .../pages/change_password_page_content.dart | 2 +- .../pages/recipe_form_page_content.dart | 55 ++++++++++--------- frontend/lib/navigations/navigator.dart | 54 +++++++++++------- .../navigators/sign_up_navigator_impl.dart | 2 +- frontend/lib/navigations/recipe_route.dart | 15 ++++- 5 files changed, 76 insertions(+), 52 deletions(-) diff --git a/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart b/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart index cc624fd..34b66a4 100644 --- a/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart +++ b/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart @@ -138,7 +138,7 @@ class _ChangePasswordPageContentState extends State { ), GestureDetector( - onTap: () => context.go('/auth'), + onTap: () => context.pop(), behavior: HitTestBehavior.opaque, child: Text( 'Назад', 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 ffbbd4e..cf04bfb 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 @@ -349,8 +349,9 @@ class _RecipeFormPageContentState extends State { final userSavedId = UserSavedRecipeId.fromDraft(draft.id); final detail = _buildRecipeDetailEntity(userSavedId); - final photoPath = - detail.photoUrls.isNotEmpty ? detail.photoUrls.first : ''; + final photoPath = detail.photoUrls.isNotEmpty + ? detail.photoUrls.first + : ''; final preview = RecipePreviewEntity( id: userSavedId, @@ -363,7 +364,10 @@ class _RecipeFormPageContentState extends State { ); await GetIt.I().saveRecipe(preview); - await GetIt.I().save(userSavedId, detail); + await GetIt.I().save( + userSavedId, + detail, + ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -694,28 +698,7 @@ class _RecipeFormPageContentState extends State { const SizedBox(height: 20.0), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const _SectionLabel('ИНГРЕДИЕНТЫ'), - TextButton.icon( - onPressed: () { - setState(() { - ingredientDrafts.add( - _IngredientDraft( - controller: IngredientController(), - ), - ); - }); - }, - icon: const Icon( - Icons.add_circle_outline, - color: Color(0xFFE5C9A8), - ), - label: const Text( - 'ADD', - style: TextStyle(color: Color(0xFFE5C9A8)), - ), - ), - ], + children: [const _SectionLabel('ИНГРЕДИЕНТЫ')], ), const SizedBox(height: 8.0), ...ingredientDrafts.asMap().entries.map((entry) { @@ -774,6 +757,25 @@ class _RecipeFormPageContentState extends State { ), ); }), + TextButton.icon( + onPressed: () { + setState(() { + ingredientDrafts.add( + _IngredientDraft( + controller: IngredientController(), + ), + ); + }); + }, + icon: const Icon( + Icons.add_circle_outline, + color: Color(0xFFE5C9A8), + ), + label: const Text( + 'Добавить ингридиент', + style: TextStyle(color: Color(0xFFE5C9A8)), + ), + ), const SizedBox(height: 20.0), const _SectionLabel('ШАГИ ПРИГОТОВЛЕНИЯ'), const SizedBox(height: 8.0), @@ -870,7 +872,8 @@ class _RecipeFormPageContentState extends State { SizedBox( height: 56.0, child: ElevatedButton( - onPressed: state.isPublishing || + onPressed: + state.isPublishing || _isSavingDraft || _isSavingToSaved ? null diff --git a/frontend/lib/navigations/navigator.dart b/frontend/lib/navigations/navigator.dart index 5d7792a..9045402 100644 --- a/frontend/lib/navigations/navigator.dart +++ b/frontend/lib/navigations/navigator.dart @@ -29,46 +29,58 @@ final navigator = GoRouter( GoRoute( path: NavigatorPaths.otp, - builder: (context, state) { + pageBuilder: (context, state) { if (state.extra is! OtpPageArgs) { context.go(NavigatorPaths.auth); - return const SizedBox(); + return NoTransitionPage(child: const SizedBox()); } final args = state.extra as OtpPageArgs; - return OtpPage(args: args); + return NoTransitionPage(child: OtpPage(args: args)); }, ), GoRoute( path: NavigatorPaths.changePassword, - builder: (context, state) { + pageBuilder: (context, state) { if (state.extra is! ChangePasswordPageArgs) { - return ChangePasswordPage( - changePasswordNavigator: ChangePasswordNavigatorImpl(context), - args: ChangePasswordPageArgs(goNext: () { - context.go(NavigatorPaths.recipeFeed); - },), - ); + return NoTransitionPage( + key: state.pageKey, + child: ChangePasswordPage( + changePasswordNavigator: ChangePasswordNavigatorImpl(context), + args: ChangePasswordPageArgs( + goNext: () { + context.go(NavigatorPaths.recipeFeed); + }, + ), + ), + ); } final args = state.extra as ChangePasswordPageArgs; - return ChangePasswordPage( - changePasswordNavigator: ChangePasswordNavigatorImpl(context), - args: args, + return NoTransitionPage( + key: state.pageKey, + child: ChangePasswordPage( + changePasswordNavigator: ChangePasswordNavigatorImpl(context), + args: args, + ), ); }, ), ShellRoute( - builder: (context, state, child) { - return Scaffold( - body: Column( - children: [ - Expanded(child: child), - - CookifyNavigationBar(index: 4), - ], + pageBuilder: (context, state, child) { + return MaterialPage( + child: SafeArea( + child: Scaffold( + body: Column( + children: [ + Expanded(child: child), + + CookifyNavigationBar(index: 4), + ], + ), + ), ), ); }, diff --git a/frontend/lib/navigations/navigators/sign_up_navigator_impl.dart b/frontend/lib/navigations/navigators/sign_up_navigator_impl.dart index d223f85..d8b7c4d 100644 --- a/frontend/lib/navigations/navigators/sign_up_navigator_impl.dart +++ b/frontend/lib/navigations/navigators/sign_up_navigator_impl.dart @@ -11,7 +11,7 @@ final class SignUpNavigatorImpl implements SignUpNavigator { @override void goOtp(String login) { - context.go( + context.replace( NavigatorPaths.otp, extra: OtpPageArgs(login: login, nextPage: NavigatorPaths.recipeFeed), ); diff --git a/frontend/lib/navigations/recipe_route.dart b/frontend/lib/navigations/recipe_route.dart index 6595925..de2a42b 100644 --- a/frontend/lib/navigations/recipe_route.dart +++ b/frontend/lib/navigations/recipe_route.dart @@ -23,7 +23,10 @@ final recipeRoute = [ pageBuilder: (context, state) { final args = state.extra as RecipeDetailPageArgs; - return NoTransitionPage(child: RecipeDetailPage(args: args)); + return NoTransitionPage( + key: state.pageKey, + child: RecipeDetailPage(args: args), + ); }, ), @@ -38,7 +41,10 @@ final recipeRoute = [ pageBuilder: (context, state) { final args = state.extra as RecipeSearchPageArgs; - return NoTransitionPage(child: RecipeSearchPage(args: args)); + return NoTransitionPage( + key: state.pageKey, + child: RecipeSearchPage(args: args), + ); }, ), @@ -48,7 +54,10 @@ final recipeRoute = [ final args = state.extra is RecipeFormPageArgs ? state.extra as RecipeFormPageArgs : const RecipeFormPageArgs(); - return MaterialPage(child: RecipeFormPage(args: args)); + return NoTransitionPage( + key: state.pageKey, + child: RecipeFormPage(args: args), + ); }, ), From 22bc7cf23757048e0d0c7a83dfd46270274c1332 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Sun, 10 May 2026 14:15:04 +0300 Subject: [PATCH 3/6] feat: add new search form --- .../widgets/profile_user_info.dart | 71 +- .../pages/recipe_detail_page_content.dart | 4 - .../pages/recipe_feed_page_content.dart | 18 +- .../recipe_feed_recipe_preview_card.dart | 9 - .../pages/recipe_drafts_page.dart | 11 +- .../presentation/pages/recipe_saved_page.dart | 12 +- .../bloc/recipe_search_form_cubit.dart | 77 ++- .../pages/recipe_search_form_page.dart | 17 +- .../recipe_search_form_page_content.dart | 629 +++++++++++++----- .../pages/recipe_search_page_content.dart | 18 +- .../token/data/consts/token_end_points.dart | 2 +- .../use_cases/refresh_token_use_case.dart | 9 +- .../token/interceptors/token_interceptor.dart | 36 +- frontend/lib/navigations/navigator.dart | 3 +- frontend/lib/navigations/recipe_route.dart | 54 +- 15 files changed, 681 insertions(+), 289 deletions(-) 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 b858de2..0f2f7d1 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart @@ -1,7 +1,13 @@ +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/profile/domain/entities/user_entity.dart'; +import 'package:cookify/features/profile/presentation/bloc/profile_bloc.dart'; +import 'package:cookify/features/profile/presentation/bloc/profile_event.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 { @@ -20,7 +26,6 @@ class ProfileUserInfo extends StatelessWidget { borderRadius: BorderRadius.circular(12.0), ), width: double.infinity, - //height: 56.0, child: Column( spacing: 16.0, children: [ @@ -49,19 +54,57 @@ class _Avatar extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - alignment: Alignment.center, - width: 96.0, - height: 96.0, - decoration: BoxDecoration( - color: Color(0xFFE5C9A8), - border: Border.all(color: Color(0xFF1E100A), width: 4.0), - shape: BoxShape.circle, - ), - child: CachedNetworkImage( - imageUrl: avatarUrl ?? '', - placeholder: (context, url) => _AvatarPlaceholder(login: login), - errorWidget: (context, url, error) => _AvatarPlaceholder(login: login), + return GestureDetector( + onTap: () async { + final image = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + + if (context.mounted && image != null) { + context.read().add( + UpdateAvatar(avatarFile: File(image.path)), + ); + } + }, + child: Stack( + children: [ + Container( + alignment: Alignment.center, + width: 96.0, + height: 96.0, + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + border: Border.all(color: Color(0xFF1E100A), width: 4.0), + shape: BoxShape.circle, + ), + child: CachedNetworkImage( + imageUrl: avatarUrl ?? '', + placeholder: (context, url) => _AvatarPlaceholder(login: login), + errorWidget: (context, url, error) => + _AvatarPlaceholder(login: login), + ), + ), + + Positioned( + right: 0.0, + bottom: 0.0, + child: Container( + alignment: Alignment.center, + width: 28.0, + height: 28.0, + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + border: Border.all(color: Color(0xFF1E100A), width: 1.0), + shape: BoxShape.circle, + ), + child: Icon( + Icons.camera_alt, + color: Color(0xFF3E2D16), + size: 16.0, + ), + ), + ), + ], ), ); } diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart index 05ba5ed..2cbe67e 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart @@ -48,10 +48,6 @@ class RecipeDetailPageContent extends StatelessWidget { ), ), actions: [ - IconButton( - icon: const Icon(Icons.favorite_border, color: Color(0xFFE5C9A8)), - onPressed: () {}, - ), BlocBuilder( builder: (context, state) { if (state is! RecipeDetailLoaded) { diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart index da99925..86e1023 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart @@ -28,15 +28,15 @@ class _RecipeFeedPageContentState extends State { child: Scaffold( appBar: AppBar( title: Text( - 'Cookify', - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 30.0, - fontWeight: FontWeight.w800, - letterSpacing: 0.0, - height: 30.0 / 30.0, - ), - ), + 'Cookify', + style: const TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, + ), + ), centerTitle: true, backgroundColor: Color(0xFF1A0F0A), surfaceTintColor: Color(0xFF1A0F0A), diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart index d89576b..964422b 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart @@ -64,15 +64,6 @@ class RecipeFeedRecipePreviewCard extends StatelessWidget { const SizedBox(width: 8.0), - IconButton( - iconSize: 24.0, - onPressed: () {}, - icon: Icon( - Icons.favorite_border, - color: const Color(0xFFE5C9A8), - ), - ), - ValueListenableBuilder>( valueListenable: GetIt.I() .savedRecipesListenable, diff --git a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart index 6eaa3d8..ce1e6a5 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart @@ -18,13 +18,16 @@ class RecipeDraftsPage extends StatelessWidget { children: [ Scaffold( appBar: AppBar( - title: const Text( + title: Text( 'Черновики', - style: TextStyle( + style: const TextStyle( color: Color(0xFFE5C9A8), - fontSize: 24.0, - fontWeight: FontWeight.w800, + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, ), + ), actions: [ IconButton( diff --git a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart index 5c58b64..9eb9a06 100644 --- a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart +++ b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart @@ -30,13 +30,13 @@ class _RecipeSavedPageState extends State { Scaffold( appBar: AppBar( title: const Text( - 'Сохранённые рецепты', - style: TextStyle( + 'Моя кухня', + style: const TextStyle( color: Color(0xFFE5C9A8), - fontSize: 24.0, - fontWeight: FontWeight.w800, - letterSpacing: 0.0, - height: 30.0 / 24.0, + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, ), ), centerTitle: true, 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 2793fc3..5405b65 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 @@ -1,3 +1,4 @@ +import 'dart:async'; // Добавляем для работы с Timer import 'package:cookify/core/domain/use_cases/results/result.dart'; import 'package:cookify/features/recipe/recipe_common/domain/payloads/search_category_list_payload.dart'; import 'package:cookify/features/recipe/recipe_common/domain/payloads/search_ingredient_list_payload.dart'; @@ -10,40 +11,56 @@ class RecipeSearchFormCubit extends Cubit { RecipeSearchFormCubit({ required SearchCategoryListUseCase searchCategoryListUseCase, required SearchIngredientListUseCase searchIngredientListUseCase, - }) : _searchCategoryListUseCase = searchCategoryListUseCase, - _searchIngredientListUseCase = searchIngredientListUseCase, - super(const RecipeSearchFormState()); + }) : _searchCategoryListUseCase = searchCategoryListUseCase, + _searchIngredientListUseCase = searchIngredientListUseCase, + super(const RecipeSearchFormState()); final SearchCategoryListUseCase _searchCategoryListUseCase; final SearchIngredientListUseCase _searchIngredientListUseCase; - Future searchCategoryList(String name) async { - final result = await _searchCategoryListUseCase( - SearchCategoryListPayload(categories: state.categories, name: name), - ); - if (isClosed) return; - - switch (result) { - case Success(data: final categories): - emit(state.copyWith(categories: categories)); - break; - case Failure(): - break; - } + // Таймеры для debounce + Timer? _categoryDebounce; + Timer? _ingredientDebounce; + + // Категории + void searchCategoryList(String name) { + // Отменяем предыдущий таймер, если пользователь продолжает печатать + _categoryDebounce?.cancel(); + + _categoryDebounce = Timer(const Duration(milliseconds: 500), () async { + final result = await _searchCategoryListUseCase( + SearchCategoryListPayload(categories: state.categories, name: name), + ); + + if (isClosed) return; + + if (result is Success) { + emit(state.copyWith(categories: (result as Success).data)); + } + }); + } + + // Ингредиенты + void searchIngredientList(String name) { + _ingredientDebounce?.cancel(); + + _ingredientDebounce = Timer(const Duration(milliseconds: 500), () async { + final result = await _searchIngredientListUseCase( + SearchIngredientListPayload(ingredients: state.ingredients, name: name), + ); + + if (isClosed) return; + + if (result is Success) { + emit(state.copyWith(ingredients: (result as Success).data)); + } + }); } - Future searchIngredientList(String name) async { - final result = await _searchIngredientListUseCase( - SearchIngredientListPayload(ingredients: state.ingredients, name: name), - ); - if (isClosed) return; - - switch (result) { - case Success(data: final ingredients): - emit(state.copyWith(ingredients: ingredients)); - break; - case Failure(): - break; - } + @override + Future close() { + _categoryDebounce?.cancel(); + _ingredientDebounce?.cancel(); + return super.close(); } -} +} \ No newline at end of file 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 36b93c6..cf635c7 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 @@ -12,22 +12,7 @@ class RecipeSearchFormPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (_) => RecipeSearchDi.getIt(), - child: SafeArea( - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 50.0), - child: const RecipeSearchFormPageContent(), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: CookifyNavigationBar(index: 1), - ), - ], - ), - ), + child: const RecipeSearchFormPageContent(), ); } } 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 cc37c54..f35e77e 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,199 +1,504 @@ -import 'package:cookify/core/presentation/widgets/cookify_text_field.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'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; -import 'package:cookify/features/recipe/recipe_common/presentation/controllers/category_controller.dart'; -import 'package:cookify/features/recipe/recipe_common/presentation/controllers/ingredient_controller.dart'; import 'package:cookify/features/recipe/recipe_search/domain/payloads/search_recipe_list_payload.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_form_state.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_page_args.dart'; -import 'package:cookify/features/recipe/recipe_search/presentation/widgets/recipe_search_category_section.dart'; -import 'package:cookify/features/recipe/recipe_search/presentation/widgets/recipe_search_general_section.dart'; -import 'package:cookify/features/recipe/recipe_search/presentation/widgets/recipe_search_ingredient_section.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +// --- Константы стиля --- +class AppColors { + static const background = Color(0xFF180B06); + static const surface = Color(0xFF2C1C16); + static const accent = Color(0xFFE5C9A8); + static const textPrimary = Color(0xFFFADCD2); + static const textSecondary = Color(0xFFD1C4B9); + static const chipBg = Color(0xFF43312A); + static const buttonBg = Color(0xFFFFE6C9); +} + class RecipeSearchFormPageContent extends StatefulWidget { const RecipeSearchFormPageContent({super.key}); @override - State createState() => - _RecipeSearchFormPageContentState(); + State createState() => _RecipeSearchFormPageContentState(); } -class _RecipeSearchFormPageContentState - extends State { - final recipeController = TextEditingController(); +class _RecipeSearchFormPageContentState extends State { + // Links & Overlays + final LayerLink _categoryLink = LayerLink(); + final LayerLink _ingredientLink = LayerLink(); + OverlayEntry? _activeOverlay; + + // Controllers & Nodes + final nameController = TextEditingController(); + final _categoryFocus = FocusNode(); + final _ingredientFocus = FocusNode(); + + // State + final List difficulties = []; + int cookingTime = 45; + int minCalories = 0, maxCalories = 4000; + int minProteins = 0, maxProteins = 100; + int minFats = 0, maxFats = 100; + int minCarbs = 0, maxCarbs = 100; + + final List selectedCategories = []; + final List selectedIngredients = []; - final difficulties = []; - final maxCookingTimeController = TextEditingController(); - final minCarbohydratesController = TextEditingController(); - final maxCarbohydratesController = TextEditingController(); - final minProteinsController = TextEditingController(); - final maxProteinsController = TextEditingController(); - final minFatsController = TextEditingController(); - final maxFatsController = TextEditingController(); - final minCaloriesController = TextEditingController(); - final maxCaloriesController = TextEditingController(); + @override + void initState() { + super.initState(); + _setupFocusListeners(); + } - final categoryControllers = []; + void _setupFocusListeners() { + _categoryFocus.addListener(() { + if (_categoryFocus.hasFocus) { + _showSearchOverlay( + link: _categoryLink, + isCategory: true, + ); + } else { + _closeOverlay(); + } + }); - final ingredientControllers = []; + _ingredientFocus.addListener(() { + if (_ingredientFocus.hasFocus) { + _showSearchOverlay( + link: _ingredientLink, + isCategory: false, + ); + } else { + _closeOverlay(); + } + }); + } + + void _closeOverlay() { + _activeOverlay?.remove(); + _activeOverlay = null; + } + + @override + void dispose() { + nameController.dispose(); + _categoryFocus.dispose(); + _ingredientFocus.dispose(); + _closeOverlay(); + super.dispose(); + } @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - appBar: AppBar( - title: Text( - 'Поиск рецептов', - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 30.0, - fontWeight: FontWeight.w800, - letterSpacing: 0.0, - height: 30.0 / 30.0, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: CustomScrollView( + slivers: [ + _buildHeader(), + const SliverToBoxAdapter(child: SizedBox(height: 17)), + + // Название + SliverToBoxAdapter( + child: _CustomTextField( + controller: nameController, + hintText: 'Название рецепта', + prefixIcon: Icons.search, ), ), - centerTitle: true, - backgroundColor: Color(0xFF1A0F0A), - surfaceTintColor: Color(0xFF1A0F0A), - ), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: BlocConsumer( - builder: (context, state) { - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: CookifyTextField( - controller: recipeController, - onChanged: (_) {}, - hint: 'Название рецепта', - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), - - SliverToBoxAdapter( - child: RecipeSearchGeneralSection( - difficulties: difficulties, - maxCookingTimeController: maxCookingTimeController, - minCarbohydratesController: minCarbohydratesController, - maxCarbohydratesController: maxCarbohydratesController, - minProteinsController: minProteinsController, - maxProteinsController: maxProteinsController, - minFatsController: minFatsController, - maxFatsController: maxFatsController, - minCaloriesController: minCaloriesController, - maxCaloriesController: maxCaloriesController, - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), + const SliverToBoxAdapter(child: SizedBox(height: 15)), - SliverToBoxAdapter( - child: RecipeSearchCategorySection( - controllers: categoryControllers, - categories: state.categories, - ), - ), + // Сложность + SliverToBoxAdapter( + child: _DifficultySelector( + selected: difficulties, + onChanged: (d) => setState(() => difficulties.contains(d) ? difficulties.remove(d) : difficulties.add(d)), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 28)), + + // Время + SliverToBoxAdapter( + child: _SliderHeader( + title: 'Время приготовления', + valueText: 'до $cookingTime мин', + child: Slider( + value: cookingTime.toDouble(), + min: 10, max: 300, + activeColor: const Color(0xFF615043), + inactiveColor: const Color(0xFF615043), + thumbColor: AppColors.buttonBg, + onChanged: (v) => setState(() => cookingTime = v.toInt()), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 22)), + + // БЖУ и Калории + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Цели в питании', style: TextStyle(color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 24), + _DoubleSlider(label: 'КАЛОРИИ', unit: 'ккал', min: 0, max: 4000, minValue: minCalories, maxValue: maxCalories, onValueChanged: (v1, v2) => setState(() { minCalories = v1; maxCalories = v2; })), + const SizedBox(height: 12), + _DoubleSlider(label: 'БЕЛКИ', unit: 'г', min: 0, max: 100, minValue: minProteins, maxValue: maxProteins, onValueChanged: (v1, v2) => setState(() { minProteins = v1; maxProteins = v2; })), + const SizedBox(height: 12), + _DoubleSlider(label: 'ЖИРЫ', unit: 'г', min: 0, max: 100, minValue: minFats, maxValue: maxFats, onValueChanged: (v1, v2) => setState(() { minFats = v1; maxFats = v2; })), + const SizedBox(height: 12), + _DoubleSlider(label: 'УГЛЕВОДЫ', unit: 'г', min: 0, max: 100, minValue: minCarbs, maxValue: maxCarbs, onValueChanged: (v1, v2) => setState(() { minCarbs = v1; maxCarbs = v2; })), + ], + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 24)), + + // Категории + _buildSearchSection( + title: 'Категории', + hint: 'Добавить категорию...', + link: _categoryLink, + focusNode: _categoryFocus, + items: selectedCategories, + onSearch: (val) => context.read().searchCategoryList(val), + onRemove: (item) => setState(() => selectedCategories.remove(item)), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), + // Ингредиенты + _buildSearchSection( + title: 'Ингредиенты', + hint: 'Добавить ингредиент...', + link: _ingredientLink, + focusNode: _ingredientFocus, + items: selectedIngredients, + onSearch: (val) => context.read().searchIngredientList(val), + onRemove: (item) => setState(() => selectedIngredients.remove(item)), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 28)), + + // Кнопка поиска + SliverToBoxAdapter( + child: _SearchButton(onPressed: _submitForm), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 38)), + ], + ), + ); + } + + // --- Вспомогательные методы построения --- + + Widget _buildHeader() { + return SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + height: 60, + child: const Text('Поиск', style: TextStyle(color: AppColors.accent, fontSize: 18, fontWeight: FontWeight.bold)), + ), + ); + } + + Widget _buildSearchSection({ + required String title, + required String hint, + required LayerLink link, + required FocusNode focusNode, + required List items, + required Function(String) onSearch, + required Function(dynamic) onRemove, + }) { + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 16), + CompositedTransformTarget( + link: link, + child: _CustomTextField( + focusNode: focusNode, + hintText: hint, + onChanged: onSearch, + fontSize: 14, + ), + ), + if (items.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, runSpacing: 8, + children: items.map((e) => _SelectionChip(label: e.name, onRemove: () => onRemove(e))).toList(), + ), + ], + ], + ), + ); + } - SliverToBoxAdapter( - child: RecipeSearchIngredientSection( - controllers: ingredientControllers, - ingredients: state.ingredients, + void _showSearchOverlay({required LayerLink link, required bool isCategory}) { + final cubit = context.read(); + + _activeOverlay = OverlayEntry( + builder: (context) => Positioned( + width: MediaQuery.of(context).size.width - 48, + child: CompositedTransformFollower( + link: link, + offset: const Offset(0, 52), + child: Material( // Чтобы работал GestureDetector и стили текста + color: Colors.transparent, + child: BlocProvider.value( + value: cubit, + child: BlocBuilder( + builder: (context, state) { + final list = isCategory ? state.categories : state.ingredients; + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.05)), ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), - - SliverToBoxAdapter( - child: GestureDetector( - onTap: () { - context.push( - '/search', - extra: RecipeSearchPageArgs( - payload: SearchRecipeListPayload( - name: recipeController.text, - difficulties: difficulties, - categories: categoryControllers - .map((c) => c.category) - .whereType() - .toList(), - ingredients: ingredientControllers - .map((i) => i.ingredient) - .whereType() - .toList(), - maxCookingTime: - maxCookingTimeController.text.isEmpty - ? null - : int.parse(maxCookingTimeController.text), - minCarbohydrates: - minCarbohydratesController.text.isEmpty - ? null - : int.parse(minCarbohydratesController.text), - maxCarbohydrates: - maxCarbohydratesController.text.isEmpty - ? null - : int.parse(maxCarbohydratesController.text), - minProteins: minProteinsController.text.isEmpty - ? null - : int.parse(minProteinsController.text), - maxProteins: maxProteinsController.text.isEmpty - ? null - : int.parse(maxProteinsController.text), - minFats: minFatsController.text.isEmpty - ? null - : int.parse(minFatsController.text), - maxFats: maxFatsController.text.isEmpty - ? null - : int.parse(maxFatsController.text), - minCalories: minCaloriesController.text.isEmpty - ? null - : int.parse(minCaloriesController.text), - maxCalories: maxCaloriesController.text.isEmpty - ? null - : int.parse(maxCaloriesController.text), - ), - ), - ); - }, - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0xFFE5C9A8), - borderRadius: BorderRadius.circular(16.0), - ), - width: double.infinity, - height: 64.0, - child: Text( - 'Поиск', - style: const TextStyle( - color: Color(0xFF1A0F0A), - fontSize: 16.0, - fontWeight: FontWeight.bold, - letterSpacing: -0.4, - height: 24.0 / 16.0, - ), + child: list.isEmpty + ? const Padding(padding: EdgeInsets.all(16), child: Text('Ничего не найдено', textAlign: TextAlign.center, style: TextStyle(color: AppColors.textPrimary))) + : Column( + mainAxisSize: MainAxisSize.min, + children: list.map((item) => _buildOverlayItem(item, isCategory)).toList(), ), - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 32.0)), - ], - ); - }, - listener: (context, state) {}, + ); + }, + ), + ), ), ), - backgroundColor: Color(0xFF1A0F0A), + ), + ); + Overlay.of(context).insert(_activeOverlay!); + } + + Widget _buildOverlayItem(dynamic item, bool isCategory) { + final isSelected = isCategory + ? selectedCategories.any((e) => e.id == item.id) + : selectedIngredients.any((e) => e.id == item.id); + + return InkWell( + onTap: () => setState(() { + if (isCategory) { + isSelected ? selectedCategories.removeWhere((e) => e.id == item.id) : selectedCategories.add(item); + } else { + isSelected ? selectedIngredients.removeWhere((e) => e.id == item.id) : selectedIngredients.add(item); + } + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text(item.name, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14)), + const Spacer(), + Icon(isSelected ? Icons.check_box : Icons.check_box_outline_blank, color: AppColors.accent, size: 20), + ], + ), ), ); } + + void _submitForm() { + context.push('/search', extra: RecipeSearchPageArgs( + payload: SearchRecipeListPayload( + name: nameController.text, + difficulties: difficulties, + categories: selectedCategories, + ingredients: selectedIngredients, + maxCookingTime: cookingTime, + minCarbohydrates: minCarbs, + maxCarbohydrates: maxCarbs, + minProteins: minProteins, + maxProteins: maxProteins, + minFats: minFats, + maxFats: maxFats, + minCalories: minCalories, + maxCalories: maxCalories, + ), + )); + } +} + +// --- Выделенные компоненты --- + +class _CustomTextField extends StatelessWidget { + final TextEditingController? controller; + final FocusNode? focusNode; + final String hintText; + final IconData? prefixIcon; + final Function(String)? onChanged; + final double fontSize; + + const _CustomTextField({this.controller, this.focusNode, required this.hintText, this.prefixIcon, this.onChanged, this.fontSize = 16}); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + cursorColor: AppColors.accent, + style: TextStyle(color: AppColors.textSecondary, fontSize: fontSize), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: AppColors.textSecondary.withOpacity(0.5), fontSize: fontSize), + prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: AppColors.textSecondary) : null, + filled: true, + fillColor: AppColors.background, + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 15), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.accent)), + ), + ); + } +} + +class _SelectionChip extends StatelessWidget { + final String label; + final VoidCallback onRemove; + + const _SelectionChip({required this.label, required this.onRemove}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration(color: AppColors.chipBg, borderRadius: BorderRadius.circular(8)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: const TextStyle(color: AppColors.buttonBg, fontSize: 12, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + GestureDetector(onTap: onRemove, child: const Icon(Icons.close, color: AppColors.buttonBg, size: 14)), + ], + ), + ); + } +} + +class _DifficultySelector extends StatelessWidget { + final List selected; + final Function(RecipeDifficulty) onChanged; + + const _DifficultySelector({required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: RecipeDifficulty.values.map((d) { + final isSel = selected.contains(d); + Color bgColor; + switch(d) { + case RecipeDifficulty.easy: bgColor = const Color(0xFF7FB069); break; + case RecipeDifficulty.medium: bgColor = const Color(0xFFE8B86D); break; + case RecipeDifficulty.hard: bgColor = const Color(0xFFE76F51); break; + } + return GestureDetector( + onTap: () => onChanged(d), + child: Container( + width: 100, height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSel ? bgColor : AppColors.chipBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text(d.name.toUpperCase(), style: TextStyle( + color: isSel ? (d == RecipeDifficulty.hard ? Colors.white : Colors.black) : AppColors.buttonBg, + fontSize: 12, fontWeight: FontWeight.bold, + )), + ), + ); + }).toList(), + ); + } +} + +class _SliderHeader extends StatelessWidget { + final String title; + final String valueText; + final Widget child; + + const _SliderHeader({required this.title, required this.valueText, required this.child}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Expanded(child: Text(title, style: const TextStyle(color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w500))), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: const Color(0xFF37261F), borderRadius: BorderRadius.circular(4)), + child: Text(valueText, style: const TextStyle(color: AppColors.buttonBg, fontSize: 12, fontWeight: FontWeight.bold, fontStyle: FontStyle.italic)), + ), + ], + ), + child, + ], + ); + } +} + +class _DoubleSlider extends StatelessWidget { + final String label, unit; + final int min, max, minValue, maxValue; + final Function(int, int) onValueChanged; + + const _DoubleSlider({required this.label, required this.unit, required this.min, required this.max, required this.minValue, required this.maxValue, required this.onValueChanged}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: AppColors.textPrimary, fontSize: 10, letterSpacing: 1)), + Text('$minValue - $maxValue $unit', style: const TextStyle(color: AppColors.textPrimary, fontSize: 10)), + ], + ), + SliderTheme( + data: const SliderThemeData(thumbColor: AppColors.buttonBg, activeTrackColor: Color(0xFF615043), inactiveTrackColor: Color(0xFF615043), rangeThumbShape: RoundRangeSliderThumbShape(enabledThumbRadius: 8)), + child: RangeSlider( + values: RangeValues(minValue.toDouble(), maxValue.toDouble()), + min: min.toDouble(), max: max.toDouble(), + onChanged: (v) => onValueChanged(v.start.toInt(), v.end.toInt()), + ), + ), + ], + ); + } } + +class _SearchButton extends StatelessWidget { + final VoidCallback onPressed; + const _SearchButton({required this.onPressed}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.buttonBg, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('ПОИСК', style: TextStyle(color: Color(0xFF3E2D16), fontWeight: FontWeight.w800, letterSpacing: 1.6)), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart index 8f28729..1a879be 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart @@ -34,15 +34,15 @@ class _RecipeSearchPageContentState extends State { icon: Icon(Icons.arrow_back, color: const Color(0xFFE5C9A8)), ), title: Text( - 'Поиск рецептов', - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 30.0, - fontWeight: FontWeight.w800, - letterSpacing: 0.0, - height: 30.0 / 30.0, - ), - ), + 'Поиск', + style: const TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, + ), + ), centerTitle: true, backgroundColor: Color(0xFF1A0F0A), surfaceTintColor: Color(0xFF1A0F0A), diff --git a/frontend/lib/features/token/data/consts/token_end_points.dart b/frontend/lib/features/token/data/consts/token_end_points.dart index d2e424b..7624a48 100644 --- a/frontend/lib/features/token/data/consts/token_end_points.dart +++ b/frontend/lib/features/token/data/consts/token_end_points.dart @@ -1 +1 @@ -const refreshTokenEndPoint = '/api/auth/refresh'; \ No newline at end of file +const refreshTokenEndPoint = '/api/refresh'; \ No newline at end of file diff --git a/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart b/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart index a3dda90..789b612 100644 --- a/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart +++ b/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart @@ -1,4 +1,5 @@ import 'package:cookify/core/domain/my_either/my_either.dart'; +import 'package:cookify/features/token/domain/entities/token.dart'; import 'package:cookify/features/token/domain/failures/token_failures.dart'; import 'package:cookify/features/token/domain/payloads/refresh_token_payload.dart'; import 'package:cookify/features/token/domain/payloads/set_token_payload.dart'; @@ -10,7 +11,7 @@ class RefreshTokenUseCase { final TokenRepository _repository; - Future> call() async { + Future> call() async { final tokenResult = await _repository.getToken(); return tokenResult.fold((failure) => Left(failure), (token) async { @@ -29,8 +30,10 @@ class RefreshTokenUseCase { return Left(failure); }, - (newToken) { - return _repository.setToken(SetTokenPayload(token: newToken)); + (newToken) async { + final result = await _repository.setToken(SetTokenPayload(token: newToken)); + + return result.fold((failure) => Left(failure), (_) => Right(newToken)); }, ); }); diff --git a/frontend/lib/features/token/interceptors/token_interceptor.dart b/frontend/lib/features/token/interceptors/token_interceptor.dart index 04eb7da..64707ae 100644 --- a/frontend/lib/features/token/interceptors/token_interceptor.dart +++ b/frontend/lib/features/token/interceptors/token_interceptor.dart @@ -27,10 +27,36 @@ class TokenInterceptor extends Interceptor { } @override - void onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.response?.statusCode == 401) { - await _refreshTokenUseCase(); - } - handler.next(err); +void onError(DioException err, ErrorInterceptorHandler handler) async { + // 1. Проверяем, что ошибка — 401 и у нас есть данные запроса + if (err.response?.statusCode == 401) { + // 2. Пытаемся обновить токен + final refreshResult = await _refreshTokenUseCase(); + + return refreshResult.fold( + (failure) => handler.next(err), // Если рефреш не удался — пробрасываем ошибку дальше + (newToken) async { + try { + // 3. Создаем дубликат запроса с новым токеном + final options = err.requestOptions; + options.headers['Authorization'] = 'Bearer ${newToken.accessToken}'; + + // 4. Повторяем запрос через новый инстанс или тот же dio + // Важно: создайте новый запрос через новый экземпляр Dio, + // чтобы избежать зацикливания или используйте текущий + final dio = Dio(); + final response = await dio.fetch(options); + + // 5. Возвращаем успешный ответ в основной поток + return handler.resolve(response); + } on DioException catch (e) { + return handler.next(e); + } + }, + ); } + + handler.next(err); +} + } diff --git a/frontend/lib/navigations/navigator.dart b/frontend/lib/navigations/navigator.dart index 9045402..7c0f7a6 100644 --- a/frontend/lib/navigations/navigator.dart +++ b/frontend/lib/navigations/navigator.dart @@ -70,7 +70,8 @@ final navigator = GoRouter( ShellRoute( pageBuilder: (context, state, child) { - return MaterialPage( + return NoTransitionPage( + key: state.pageKey, child: SafeArea( child: Scaffold( body: Column( diff --git a/frontend/lib/navigations/recipe_route.dart b/frontend/lib/navigations/recipe_route.dart index de2a42b..8b451bf 100644 --- a/frontend/lib/navigations/recipe_route.dart +++ b/frontend/lib/navigations/recipe_route.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/presentation/widgets/cookify_navigation_bar.dart'; import 'package:cookify/features/recipe/recipe_detail/presentation/pages/recipe_detail_page.dart'; import 'package:cookify/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_args.dart'; import 'package:cookify/features/recipe/recipe_feed/presentation/pages/recipe_feed_page.dart'; @@ -15,7 +16,7 @@ final recipeRoute = [ GoRoute( path: '/', pageBuilder: (context, state) => - MaterialPage(child: const RecipeFeedPage()), + NoTransitionPage(key: state.pageKey, child: const RecipeFeedPage()), ), GoRoute( @@ -30,22 +31,41 @@ final recipeRoute = [ }, ), - GoRoute( - path: '/search-form', - pageBuilder: (context, state) => - MaterialPage(child: const RecipeSearchFormPage()), - ), - - GoRoute( - path: '/search', - pageBuilder: (context, state) { - final args = state.extra as RecipeSearchPageArgs; - + ShellRoute( + pageBuilder: (context, state, child) { return NoTransitionPage( - key: state.pageKey, - child: RecipeSearchPage(args: args), + key: state.pageKey, + child: SafeArea( + child: Scaffold( + body: Column( + children: [ + Expanded(child: child), + + CookifyNavigationBar(index: 1), + ], + ), + ), + ), ); }, + routes: [ + GoRoute( + path: '/search-form', + builder: (context, state) => RecipeSearchFormPage(), + ), + + GoRoute( + path: '/search', + pageBuilder: (context, state) { + final args = state.extra as RecipeSearchPageArgs; + + return NoTransitionPage( + key: state.pageKey, + child: RecipeSearchPage(args: args), + ); + }, + ), + ], ), GoRoute( @@ -64,12 +84,14 @@ final recipeRoute = [ GoRoute( path: '/drafts', pageBuilder: (context, state) => - MaterialPage(child: const RecipeDraftsPage()), + NoTransitionPage( + key: state.pageKey,child: const RecipeDraftsPage()), ), GoRoute( path: '/saved', pageBuilder: (context, state) => - MaterialPage(child: const RecipeSavedPage()), + NoTransitionPage( + key: state.pageKey,child: const RecipeSavedPage()), ), ]; From 3bb4dbadea54ba513d576fb9230d79cbe6720640 Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Sun, 10 May 2026 14:58:09 +0300 Subject: [PATCH 4/6] feat: customize problem pages --- .../pages/recipe_detail_page_content.dart | 96 +++++++++++++++- .../widgets/recipe_detail_info_card.dart | 6 +- .../recipe_detail_ingredients_card.dart | 31 +++--- .../pages/recipe_feed_page_content.dart | 105 ++++++++++++++++-- .../widgets/recipe_feed_recipe_list.dart | 23 ++-- .../pages/recipe_drafts_page.dart | 90 ++++++++++++++- .../presentation/pages/recipe_saved_page.dart | 91 ++++++++++++++- .../pages/sign_in_widget_content.dart | 7 -- .../pages/sign_up_widget_content.dart | 11 +- frontend/lib/main.dart | 13 +++ 10 files changed, 409 insertions(+), 64 deletions(-) diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart index 2cbe67e..a73e0f5 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart @@ -41,10 +41,10 @@ class RecipeDetailPageContent extends StatelessWidget { 'Cookify', style: const TextStyle( color: Color(0xFFE5C9A8), - fontSize: 30.0, - fontWeight: FontWeight.w800, - letterSpacing: 0.0, - height: 30.0 / 30.0, + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, ), ), actions: [ @@ -129,7 +129,93 @@ class RecipeDetailPageContent extends StatelessWidget { ], ); case RecipeDetailError(): - return const Text('Error'); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.wifi_off, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + 'Нет подключения к инернету. Подключитесь к сети и обновите страницу.', + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.read().getRecipeDetail(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + 'Обновить', + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], + ); + } }, listener: (context, state) {}, diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart index d93ce56..827e0c9 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart @@ -280,18 +280,18 @@ class _Calories extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const .symmetric(horizontal: 10.0, vertical: 6.0), + padding: const .symmetric(horizontal: 8.0, vertical: 6.0), decoration: BoxDecoration( color: const Color(0x33E5C9A8), border: Border.all(color: const Color(0x4DE5C9A8)), borderRadius: BorderRadius.circular(8.0), ), child: Row( - spacing: 6.0, + spacing: 4.0, children: [ Icon( Icons.local_fire_department, - size: 16.0, + size: 14.0, color: const Color(0xFFE5C9A8), ), diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart index fd83f8b..a6587ab 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart @@ -44,7 +44,7 @@ class _RecipeDetailIngredientsCardState 'Ингридиенты', style: const TextStyle( color: Color(0xFFE5C9A8), - fontSize: 20.0, + fontSize: 18.0, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 28.0 / 20.0, @@ -114,7 +114,7 @@ class _ServingCount extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - spacing: 4.0, + spacing: 2.0, children: [ IconButton( onPressed: () { @@ -131,7 +131,7 @@ class _ServingCount extends StatelessWidget { }, icon: const Icon( Icons.remove, - size: 20.0, + size: 18.0, color: Color(0xFFE5C9A8), ), ), @@ -140,10 +140,10 @@ class _ServingCount extends StatelessWidget { '$servingCount порций', style: const TextStyle( color: Color(0xFFE5C9A8), - fontSize: 14.0, + fontSize: 12.0, fontWeight: FontWeight.w700, letterSpacing: 0.0, - height: 20.0 / 14.0, + height: 20.0 / 12.0, ), overflow: TextOverflow.ellipsis, ), @@ -161,7 +161,7 @@ class _ServingCount extends StatelessWidget { .toList(); onServingCountChanged(newServingCount, newIngredients); }, - icon: const Icon(Icons.add, size: 20.0, color: Color(0xFFE5C9A8)), + icon: const Icon(Icons.add, size: 18.0, color: Color(0xFFE5C9A8)), ), ], ), @@ -184,14 +184,17 @@ class _Ingredient extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8.0, children: [ - Text( - ingredient.name, - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 16.0, - fontWeight: FontWeight.w600, - letterSpacing: 0.0, - height: 24.0 / 16.0, + Expanded( + child: Text( + ingredient.name, + style: const TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w600, + letterSpacing: 0.0, + height: 24.0 / 16.0, + ), + //overflow: TextOverflow.ellipsis, ), ), diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart index 86e1023..c211ae3 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart @@ -28,15 +28,15 @@ class _RecipeFeedPageContentState extends State { child: Scaffold( appBar: AppBar( title: Text( - 'Cookify', - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 18.0, - fontWeight: FontWeight.bold, - letterSpacing: -0.72, - height: 28.0 / 18.0, - ), - ), + 'Cookify', + style: const TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, + ), + ), centerTitle: true, backgroundColor: Color(0xFF1A0F0A), surfaceTintColor: Color(0xFF1A0F0A), @@ -64,7 +64,92 @@ class _RecipeFeedPageContentState extends State { }, ); case RecipeFeedError(): - return const Text('Error'); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.wifi_off, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + 'Нет подключения к инернету. Подключитесь к сети и обновите страницу.', + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.read().getRecipeList(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + 'Обновить', + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], + ); } }, listener: (context, state) {}, diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_list.dart b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_list.dart index 8e62d8e..bd14178 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_list.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_list.dart @@ -1,7 +1,9 @@ import 'package:cookify/core/presentation/widgets/cookify_pagination_list_view.dart'; import 'package:cookify/features/recipe/recipe_feed/domain/entities/recipe_preview_entity.dart'; +import 'package:cookify/features/recipe/recipe_feed/presentation/bloc/recipe_feed_cubit.dart'; import 'package:cookify/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RecipeFeedRecipeList extends StatelessWidget { const RecipeFeedRecipeList({ @@ -19,13 +21,20 @@ class RecipeFeedRecipeList extends StatelessWidget { @override Widget build(BuildContext context) { - return CookifyPaginationListView( - items: recipes - .map((e) => RecipeFeedRecipePreviewCard(recipe: e)) - .toList(), - isLoading: isLoading, - onAtBottom: onAtBottom ?? () {}, - controller: controller, + return RefreshIndicator( + onRefresh: () async { + await context.read().getRecipeList(); + }, + backgroundColor: Color(0xFF1A0F0A), + color: Color(0xFFE5C9A8), + child: CookifyPaginationListView( + items: recipes + .map((e) => RecipeFeedRecipePreviewCard(recipe: e)) + .toList(), + isLoading: isLoading, + onAtBottom: onAtBottom ?? () {}, + controller: controller, + ), ); } } diff --git a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart index ce1e6a5..5742c43 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart @@ -53,11 +53,91 @@ class RecipeDraftsPage extends StatelessWidget { valueListenable: repo.draftsListenable, builder: (context, drafts, _) { if (drafts.isEmpty) { - return const Center( - child: Text( - 'Пока нет черновиков', - style: TextStyle(color: Color(0xFFE5C9A8)), - ), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.bookmark_border, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + 'Здесь пока пусто. Создавайте свои собственные кулинарные шедевры', + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.push('/create'); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + 'Создать', + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], ); } diff --git a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart index 9eb9a06..7c5e50e 100644 --- a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart +++ b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart @@ -4,6 +4,7 @@ import 'package:cookify/features/recipe/recipe_feed/domain/entities/recipe_previ import 'package:cookify/features/recipe/recipe_saved/presentation/widgets/recipe_saved_wrap.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; class RecipeSavedPage extends StatefulWidget { const RecipeSavedPage({super.key}); @@ -53,11 +54,91 @@ class _RecipeSavedPageState extends State { valueListenable: repository.savedRecipesListenable, builder: (context, recipes, _) { if (recipes.isEmpty) { - return const Center( - child: Text( - 'Пока нет сохранённых рецептов', - style: TextStyle(color: Color(0xFFE5C9A8)), - ), + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.bookmark_border, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + 'Здесь пока пусто. Сохраняйте понравившиеся рецепты из ленты, чтобы не потерять их', + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.go('/'); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + 'Найти', + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], ); } diff --git a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart index c8c2303..020a80a 100644 --- a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart +++ b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart @@ -100,13 +100,6 @@ class _SignInWidgetContentState extends State { imagePath: 'google', ), ), - - Expanded( - child: AuthServiceButton( - onPressed: () {}, - imagePath: 'apple', - ), - ), ], ), ], diff --git a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart index d791599..fb48a7c 100644 --- a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart +++ b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart @@ -133,17 +133,12 @@ class _SignUpWidgetContentState extends State { children: [ Expanded( child: AuthServiceButton( - onPressed: () {}, + onPressed: () { + context.read().add(SignUpWithGoogle()); + }, imagePath: 'google', ), ), - - Expanded( - child: AuthServiceButton( - onPressed: () {}, - imagePath: 'apple', - ), - ), ], ), ], diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index eaa9afc..52ae7d8 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,8 +1,21 @@ import 'package:cookify/core/presentation/widgets/app.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + // Верхняя панель (Status Bar) + statusBarColor: Color(0xFF1E100A), // Сделать прозрачной + statusBarIconBrightness: + Brightness.dark, // Темные иконки (для светлых фонов) + // Нижняя панель (Navigation Bar) + systemNavigationBarColor: Color(0xFF1E100A), // Ваш цвет из кода выше + systemNavigationBarIconBrightness: Brightness.dark, // Светлые иконки + ), + ); + runApp(const App()); } From 4f06ce9cbc39f068808d40127391ffba96d1d01e Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Sun, 10 May 2026 21:02:33 +0300 Subject: [PATCH 5/6] feat: add localization --- frontend/lib/core/l10n/app_en.arb | 189 +++- frontend/lib/core/l10n/app_localizations.dart | 846 ++++++++++++++++ .../lib/core/l10n/app_localizations_en.dart | 443 +++++++++ .../lib/core/l10n/app_localizations_ru.dart | 444 +++++++++ frontend/lib/core/l10n/app_ru.arb | 189 +++- .../widgets/key_board_listener.dart | 130 +++ .../presentation/widgets/auth_bar.dart | 7 +- .../presentation/widgets/auth_divider.dart | 7 +- .../presentation/widgets/auth_top.dart | 9 +- .../pages/change_password_page_content.dart | 27 +- ...ed_confirm_password_validation_status.dart | 10 +- .../localized_email_validation_status.dart | 7 +- .../localized_login_validation_status.dart | 11 +- .../localized_password_validation_status.dart | 17 +- .../presentation/pages/otp_page_content.dart | 23 +- .../local/user_statistic_local_store.dart | 86 ++ .../repositories/profile_repository_impl.dart | 26 +- .../lib/features/profile/di/profile_di.dart | 7 +- .../widgets/profile_settings.dart | 6 +- .../widgets/profile_settings_locale.dart | 4 +- .../widgets/profile_user_info.dart | 37 +- .../lib/features/recipe/di/recipe_di.dart | 6 +- .../saved_recipe_repository_impl.dart | 17 +- .../extensions/styled_recipe_difficulty.dart | 7 +- .../widgets/category_text_field.dart | 26 +- .../widgets/ingredient_text_field.dart | 32 +- .../pages/recipe_detail_page_content.dart | 6 +- .../widgets/recipe_detail_info_card.dart | 18 +- .../recipe_detail_ingredients_card.dart | 5 +- .../widgets/recipe_detail_steps_card.dart | 3 +- .../pages/recipe_feed_page_content.dart | 5 +- .../recipe_feed_recipe_preview_card.dart | 9 +- .../pages/recipe_drafts_page.dart | 30 +- .../presentation/pages/recipe_form_page.dart | 6 +- .../pages/recipe_form_page_content.dart | 906 ++++++++++-------- .../widgets/recipe_form_photo_field.dart | 16 +- .../presentation/pages/recipe_saved_page.dart | 9 +- .../widgets/recipe_saved_preview_card.dart | 7 +- .../recipe_search_form_page_content.dart | 547 +++++++++-- .../pages/recipe_search_page_content.dart | 21 +- .../recipe_search_category_section.dart | 7 +- .../recipe_search_general_section.dart | 17 +- .../recipe_search_ingredient_section.dart | 11 +- .../pages/restore_widget_content.dart | 11 +- .../pages/sign_in_widget_content.dart | 27 +- .../pages/sign_up_widget_content.dart | 26 +- .../data/requests/refresh_token_request.dart | 2 +- frontend/lib/features/token/di/token_di.dart | 2 +- .../use_cases/refresh_token_use_case.dart | 57 +- frontend/lib/main.dart | 2 +- frontend/lib/navigations/recipe_route.dart | 42 +- 51 files changed, 3714 insertions(+), 691 deletions(-) create mode 100644 frontend/lib/core/presentation/widgets/key_board_listener.dart create mode 100644 frontend/lib/features/profile/data/local/user_statistic_local_store.dart diff --git a/frontend/lib/core/l10n/app_en.arb b/frontend/lib/core/l10n/app_en.arb index 5b84bcc..8e77638 100644 --- a/frontend/lib/core/l10n/app_en.arb +++ b/frontend/lib/core/l10n/app_en.arb @@ -10,5 +10,192 @@ "profileSettings": "Settings", "profileSettingsLocale": "Change language", "profileSettingsChangePassword": "Change password", - "profileSettingsSignout": "Sign out" + "profileSettingsSignout": "Sign out", + + "recipeFormTitle": "Create recipe", + "recipeFormAddPhoto": "Add photo", + "recipeFormNameLabel": "RECIPE NAME", + "recipeFormNameHint": "Name of your masterpiece", + "recipeFormDescriptionLabel": "DESCRIPTION", + "recipeFormDescriptionHint": "Tell us why it is delicious...", + "recipeFormNutrition": "NUTRITION", + "recipeFormProtein": "PROTEIN", + "recipeFormFat": "FAT", + "recipeFormCarbs": "CARBS", + "recipeFormCalories": "CALORIES", + "recipeFormDifficulty": "DIFFICULTY", + "recipeFormDifficultyEasy": "EASY", + "recipeFormDifficultyMedium": "MEDIUM", + "recipeFormDifficultyHard": "HARD", + "recipeFormCookingTimeLabel": "COOKING TIME", + "recipeFormCookingTimeHint": "45 minutes", + "recipeFormCategories": "CATEGORIES", + "recipeFormAddCategory": "Add category", + "recipeFormIngredients": "INGREDIENTS", + "recipeFormAddIngredient": "Add ingredient", + "recipeFormSteps": "COOKING STEPS", + "recipeFormAddStep": "ADD STEP", + "recipeFormSaveDraft": "Save draft", + "recipeFormSaveToSaved": "Save to favorites", + "recipeFormPublish": "Publish recipe", + "recipeFormSaving": "Saving...", + "recipeFormPublishing": "Publishing...", + "recipeFormDraftSaved": "Draft saved", + "recipeFormSavedRecipeAdded": "Recipe added to saved", + "recipeFormPublished": "Recipe published", + "recipeFormPublishFailed": "Failed to publish recipe", + "recipeFormFillRequired": "Please fill in required fields", + "recipeFormErrorPhoto": "Add a recipe photo", + "recipeFormErrorName": "Enter recipe name", + "recipeFormErrorDescription": "Enter description", + "recipeFormErrorCookingTime": "Enter cooking time greater than 0", + "recipeFormErrorCategories": "Add at least one category", + "recipeFormErrorIngredients": "Add at least one ingredient with amount and unit", + "recipeFormErrorSteps": "Add at least one step with title and description", + "recipeFormStepTitleHint": "Step title", + "recipeFormStepDescriptionHint": "Step description", + + "commonOfflineMessage": "No internet connection. Connect to the network and refresh the page.", + "commonRefresh": "Refresh", + "commonBack": "Back", + "commonCancel": "Cancel", + "commonDelete": "Delete", + "commonGramShort": "g", + "commonKcalShort": "kcal", + "commonMinutes": "{value} min", + "@commonMinutes": { + "placeholders": { + "value": {} + } + }, + "commonServings": "{value} servings", + "@commonServings": { + "placeholders": { + "value": {} + } + }, + "commonServingsDouble": "{value} servings", + "@commonServingsDouble": { + "placeholders": { + "value": {} + } + }, + + "authBarSignIn": "Sign in", + "authBarSignUp": "Sign up", + "authBarRestore": "Restore", + "authTopSubtitle": "THE ART OF HOME COOKING", + "authDividerOr": "OR WITH", + "authLoginLabel": "LOGIN", + "authLoginHint": "Enter login", + "authEmailHint": "Enter email", + "authPasswordLabel": "PASSWORD", + "authPasswordHint": "Enter password", + "authConfirmPasswordLabel": "CONFIRM PASSWORD", + "authConfirmPasswordHint": "Enter password again", + "authSignInButton": "Sign in", + "authSignInWrongLogin": "Invalid login", + "authSignInWrongPassword": "Invalid password", + "authSignUpButton": "Sign up", + "authSignUpLoginTaken": "Login already taken", + "authSignUpEmailTaken": "Email already taken", + "authRestoreLoginOrEmailLabel": "LOGIN OR EMAIL", + "authRestoreLoginOrEmailHint": "Enter login or email", + "authRestoreWrongLoginOrEmail": "Invalid login or email", + "authRestoreButton": "Restore", + + "changePasswordTitle": "Change password", + "changePasswordNewPasswordLabel": "NEW PASSWORD", + "changePasswordNewPasswordHint": "Enter new password", + "changePasswordSubmit": "Change", + + "otpTitle": "Confirmation code", + "otpInvalidCode": "Invalid code", + "otpResend": "Resend{suffix}", + "@otpResend": { + "placeholders": { + "suffix": {} + } + }, + "otpResendAfter": " in {seconds} seconds", + "@otpResendAfter": { + "placeholders": { + "seconds": {} + } + }, + "otpChangeAccount": "Change account", + + "recipeSavedTitle": "My kitchen", + "recipeSavedEmptyMessage": "Nothing here yet. Save recipes you like from the feed so you do not lose them", + "recipeSavedFind": "Find", + + "recipeDraftsTitle": "Drafts", + "recipeDraftsEmptyMessage": "Nothing here yet. Create your own culinary masterpieces", + "recipeDraftsCreate": "Create", + "recipeDraftsUntitled": "Untitled", + "recipeDraftsDeleteTitle": "Delete draft?", + "recipeDraftsUpdated": "Updated: {value}", + "@recipeDraftsUpdated": { + "placeholders": { + "value": {} + } + }, + + "recipeDetailIngredients": "Ingredients", + "recipeDetailSteps": "Steps", + "recipeDetailTime": "Time", + "recipeDetailProteinSign": "P", + "recipeDetailFatSign": "F", + "recipeDetailCarbsSign": "C", + "recipeDifficultyEasy": "EASY", + "recipeDifficultyMedium": "MEDIUM", + "recipeDifficultyHard": "HARD", + + "commonLanguageRu": "Russian", + "commonLanguageEn": "English", + "commonError": "Error", + + "searchTitle": "Search", + "searchButton": "SEARCH", + "searchNothingFound": "Nothing found", + "searchRecipeNameHint": "Recipe name", + "searchCookingTimeTitle": "Cooking time", + "searchCookingTimeUpTo": "up to {value} min", + "@searchCookingTimeUpTo": { + "placeholders": { + "value": {} + } + }, + "searchNutritionGoals": "Nutrition goals", + "searchCaloriesLabel": "CALORIES", + "searchProteinsLabel": "PROTEINS", + "searchFatsLabel": "FATS", + "searchCarbsLabel": "CARBS", + "searchCategoriesTitle": "Categories", + "searchIngredientsTitle": "Ingredients", + "searchAddCategoryHint": "Add category...", + "searchAddIngredientHint": "Add ingredient...", + "searchAddCategory": "Add category", + "searchAddIngredient": "Add ingredient", + "searchCategoryNotFound": "No categories found", + "searchIngredientNotFound": "No ingredients found", + "searchCategoryHint": "Healthy eating", + "searchIngredientHint": "Chicken", + "searchGeneralTitle": "General", + "searchMaxCookingTimeHint": "Maximum cooking time", + "searchCaloriesTitle": "Calories", + "searchProteinsTitle": "Proteins", + "searchFatsTitle": "Fats", + "searchCarbsTitle": "Carbohydrates", + "searchMinHint": "Min", + "searchMaxHint": "Max", + + "validationFieldRequired": "Field cannot be empty", + "validationLoginTooShort": "Login is too short", + "validationLoginTooLong": "Login is too long", + "validationEmailInvalid": "Invalid email", + "validationPasswordTooShort": "Password is too short", + "validationPasswordTooLong": "Password is too long", + "validationPasswordInvalid": "Password must contain lowercase and uppercase Latin letters, digits, and special symbols (@$!%*?&_)", + "validationConfirmPasswordNotMatch": "Passwords do not match" } diff --git a/frontend/lib/core/l10n/app_localizations.dart b/frontend/lib/core/l10n/app_localizations.dart index 80688e1..635b2a6 100644 --- a/frontend/lib/core/l10n/app_localizations.dart +++ b/frontend/lib/core/l10n/app_localizations.dart @@ -151,6 +151,852 @@ abstract class AppLocalizations { /// In ru, this message translates to: /// **'Выйти из аккаунта'** String get profileSettingsSignout; + + /// No description provided for @recipeFormTitle. + /// + /// In ru, this message translates to: + /// **'Создание рецепта'** + String get recipeFormTitle; + + /// No description provided for @recipeFormAddPhoto. + /// + /// In ru, this message translates to: + /// **'Добавить фото'** + String get recipeFormAddPhoto; + + /// No description provided for @recipeFormNameLabel. + /// + /// In ru, this message translates to: + /// **'НАЗВАНИЕ РЕЦЕПТА'** + String get recipeFormNameLabel; + + /// No description provided for @recipeFormNameHint. + /// + /// In ru, this message translates to: + /// **'Название вашего шедевра'** + String get recipeFormNameHint; + + /// No description provided for @recipeFormDescriptionLabel. + /// + /// In ru, this message translates to: + /// **'ОПИСАНИЕ'** + String get recipeFormDescriptionLabel; + + /// No description provided for @recipeFormDescriptionHint. + /// + /// In ru, this message translates to: + /// **'Расскажите нам почему это вкусно...'** + String get recipeFormDescriptionHint; + + /// No description provided for @recipeFormNutrition. + /// + /// In ru, this message translates to: + /// **'КБЖУ'** + String get recipeFormNutrition; + + /// No description provided for @recipeFormProtein. + /// + /// In ru, this message translates to: + /// **'БЕЛ'** + String get recipeFormProtein; + + /// No description provided for @recipeFormFat. + /// + /// In ru, this message translates to: + /// **'ЖИР'** + String get recipeFormFat; + + /// No description provided for @recipeFormCarbs. + /// + /// In ru, this message translates to: + /// **'УГЛ'** + String get recipeFormCarbs; + + /// No description provided for @recipeFormCalories. + /// + /// In ru, this message translates to: + /// **'КАЛОРИИ'** + String get recipeFormCalories; + + /// No description provided for @recipeFormDifficulty. + /// + /// In ru, this message translates to: + /// **'СЛОЖНОСТЬ'** + String get recipeFormDifficulty; + + /// No description provided for @recipeFormDifficultyEasy. + /// + /// In ru, this message translates to: + /// **'ЛЕГКО'** + String get recipeFormDifficultyEasy; + + /// No description provided for @recipeFormDifficultyMedium. + /// + /// In ru, this message translates to: + /// **'СРЕДНЕ'** + String get recipeFormDifficultyMedium; + + /// No description provided for @recipeFormDifficultyHard. + /// + /// In ru, this message translates to: + /// **'СЛОЖНО'** + String get recipeFormDifficultyHard; + + /// No description provided for @recipeFormCookingTimeLabel. + /// + /// In ru, this message translates to: + /// **'ВРЕМЯ ПРИГОТОВЛЕНИЯ'** + String get recipeFormCookingTimeLabel; + + /// No description provided for @recipeFormCookingTimeHint. + /// + /// In ru, this message translates to: + /// **'45 минут'** + String get recipeFormCookingTimeHint; + + /// No description provided for @recipeFormCategories. + /// + /// In ru, this message translates to: + /// **'КАТЕГОРИИ'** + String get recipeFormCategories; + + /// No description provided for @recipeFormAddCategory. + /// + /// In ru, this message translates to: + /// **'Добавить категорию'** + String get recipeFormAddCategory; + + /// No description provided for @recipeFormIngredients. + /// + /// In ru, this message translates to: + /// **'ИНГРЕДИЕНТЫ'** + String get recipeFormIngredients; + + /// No description provided for @recipeFormAddIngredient. + /// + /// In ru, this message translates to: + /// **'Добавить ингредиент'** + String get recipeFormAddIngredient; + + /// No description provided for @recipeFormSteps. + /// + /// In ru, this message translates to: + /// **'ШАГИ ПРИГОТОВЛЕНИЯ'** + String get recipeFormSteps; + + /// No description provided for @recipeFormAddStep. + /// + /// In ru, this message translates to: + /// **'ДОБАВИТЬ ШАГ'** + String get recipeFormAddStep; + + /// No description provided for @recipeFormSaveDraft. + /// + /// In ru, this message translates to: + /// **'Сохранить черновик'** + String get recipeFormSaveDraft; + + /// No description provided for @recipeFormSaveToSaved. + /// + /// In ru, this message translates to: + /// **'В сохранённые'** + String get recipeFormSaveToSaved; + + /// No description provided for @recipeFormPublish. + /// + /// In ru, this message translates to: + /// **'Опубликовать рецепт'** + String get recipeFormPublish; + + /// No description provided for @recipeFormSaving. + /// + /// In ru, this message translates to: + /// **'Сохранение...'** + String get recipeFormSaving; + + /// No description provided for @recipeFormPublishing. + /// + /// In ru, this message translates to: + /// **'Публикация...'** + String get recipeFormPublishing; + + /// No description provided for @recipeFormDraftSaved. + /// + /// In ru, this message translates to: + /// **'Черновик сохранён'** + String get recipeFormDraftSaved; + + /// No description provided for @recipeFormSavedRecipeAdded. + /// + /// In ru, this message translates to: + /// **'Рецепт добавлен в сохранённые'** + String get recipeFormSavedRecipeAdded; + + /// No description provided for @recipeFormPublished. + /// + /// In ru, this message translates to: + /// **'Рецепт опубликован'** + String get recipeFormPublished; + + /// No description provided for @recipeFormPublishFailed. + /// + /// In ru, this message translates to: + /// **'Не удалось опубликовать рецепт'** + String get recipeFormPublishFailed; + + /// No description provided for @recipeFormFillRequired. + /// + /// In ru, this message translates to: + /// **'Заполните обязательные поля'** + String get recipeFormFillRequired; + + /// No description provided for @recipeFormErrorPhoto. + /// + /// In ru, this message translates to: + /// **'Добавьте фото рецепта'** + String get recipeFormErrorPhoto; + + /// No description provided for @recipeFormErrorName. + /// + /// In ru, this message translates to: + /// **'Введите название рецепта'** + String get recipeFormErrorName; + + /// No description provided for @recipeFormErrorDescription. + /// + /// In ru, this message translates to: + /// **'Введите описание'** + String get recipeFormErrorDescription; + + /// No description provided for @recipeFormErrorCookingTime. + /// + /// In ru, this message translates to: + /// **'Введите время приготовления больше 0'** + String get recipeFormErrorCookingTime; + + /// No description provided for @recipeFormErrorCategories. + /// + /// In ru, this message translates to: + /// **'Добавьте хотя бы одну категорию'** + String get recipeFormErrorCategories; + + /// No description provided for @recipeFormErrorIngredients. + /// + /// In ru, this message translates to: + /// **'Добавьте хотя бы один ингредиент с количеством и единицей'** + String get recipeFormErrorIngredients; + + /// No description provided for @recipeFormErrorSteps. + /// + /// In ru, this message translates to: + /// **'Добавьте хотя бы один шаг с заголовком и описанием'** + String get recipeFormErrorSteps; + + /// No description provided for @recipeFormStepTitleHint. + /// + /// In ru, this message translates to: + /// **'Заголовок шага'** + String get recipeFormStepTitleHint; + + /// No description provided for @recipeFormStepDescriptionHint. + /// + /// In ru, this message translates to: + /// **'Описание шага'** + String get recipeFormStepDescriptionHint; + + /// No description provided for @commonOfflineMessage. + /// + /// In ru, this message translates to: + /// **'Нет подключения к интернету. Подключитесь к сети и обновите страницу.'** + String get commonOfflineMessage; + + /// No description provided for @commonRefresh. + /// + /// In ru, this message translates to: + /// **'Обновить'** + String get commonRefresh; + + /// No description provided for @commonBack. + /// + /// In ru, this message translates to: + /// **'Назад'** + String get commonBack; + + /// No description provided for @commonCancel. + /// + /// In ru, this message translates to: + /// **'Отмена'** + String get commonCancel; + + /// No description provided for @commonDelete. + /// + /// In ru, this message translates to: + /// **'Удалить'** + String get commonDelete; + + /// No description provided for @commonGramShort. + /// + /// In ru, this message translates to: + /// **'г'** + String get commonGramShort; + + /// No description provided for @commonKcalShort. + /// + /// In ru, this message translates to: + /// **'ккал'** + String get commonKcalShort; + + /// No description provided for @commonMinutes. + /// + /// In ru, this message translates to: + /// **'{value} мин'** + String commonMinutes(Object value); + + /// No description provided for @commonServings. + /// + /// In ru, this message translates to: + /// **'{value} порций'** + String commonServings(Object value); + + /// No description provided for @commonServingsDouble. + /// + /// In ru, this message translates to: + /// **'{value} порций'** + String commonServingsDouble(Object value); + + /// No description provided for @authBarSignIn. + /// + /// In ru, this message translates to: + /// **'Вход'** + String get authBarSignIn; + + /// No description provided for @authBarSignUp. + /// + /// In ru, this message translates to: + /// **'Регистрация'** + String get authBarSignUp; + + /// No description provided for @authBarRestore. + /// + /// In ru, this message translates to: + /// **'Восстановление'** + String get authBarRestore; + + /// No description provided for @authTopSubtitle. + /// + /// In ru, this message translates to: + /// **'ИСКУССТВО ДОМАШНЕЙ КУХНИ'** + String get authTopSubtitle; + + /// No description provided for @authDividerOr. + /// + /// In ru, this message translates to: + /// **'ИЛИ ЧЕРЕЗ'** + String get authDividerOr; + + /// No description provided for @authLoginLabel. + /// + /// In ru, this message translates to: + /// **'ЛОГИН'** + String get authLoginLabel; + + /// No description provided for @authLoginHint. + /// + /// In ru, this message translates to: + /// **'Введите логин'** + String get authLoginHint; + + /// No description provided for @authEmailHint. + /// + /// In ru, this message translates to: + /// **'Введите email'** + String get authEmailHint; + + /// No description provided for @authPasswordLabel. + /// + /// In ru, this message translates to: + /// **'ПАРОЛЬ'** + String get authPasswordLabel; + + /// No description provided for @authPasswordHint. + /// + /// In ru, this message translates to: + /// **'Введите пароль'** + String get authPasswordHint; + + /// No description provided for @authConfirmPasswordLabel. + /// + /// In ru, this message translates to: + /// **'ПОВТОРИТЕ ПАРОЛЬ'** + String get authConfirmPasswordLabel; + + /// No description provided for @authConfirmPasswordHint. + /// + /// In ru, this message translates to: + /// **'Введите пароль повторно'** + String get authConfirmPasswordHint; + + /// No description provided for @authSignInButton. + /// + /// In ru, this message translates to: + /// **'Войти'** + String get authSignInButton; + + /// No description provided for @authSignInWrongLogin. + /// + /// In ru, this message translates to: + /// **'Неправильный логин'** + String get authSignInWrongLogin; + + /// No description provided for @authSignInWrongPassword. + /// + /// In ru, this message translates to: + /// **'Неправильный пароль'** + String get authSignInWrongPassword; + + /// No description provided for @authSignUpButton. + /// + /// In ru, this message translates to: + /// **'Зарегистрироваться'** + String get authSignUpButton; + + /// No description provided for @authSignUpLoginTaken. + /// + /// In ru, this message translates to: + /// **'Логин занят'** + String get authSignUpLoginTaken; + + /// No description provided for @authSignUpEmailTaken. + /// + /// In ru, this message translates to: + /// **'Почта занята'** + String get authSignUpEmailTaken; + + /// No description provided for @authRestoreLoginOrEmailLabel. + /// + /// In ru, this message translates to: + /// **'ЛОГИН ИЛИ EMAIL'** + String get authRestoreLoginOrEmailLabel; + + /// No description provided for @authRestoreLoginOrEmailHint. + /// + /// In ru, this message translates to: + /// **'Введите логин или email'** + String get authRestoreLoginOrEmailHint; + + /// No description provided for @authRestoreWrongLoginOrEmail. + /// + /// In ru, this message translates to: + /// **'Неверный логин или email'** + String get authRestoreWrongLoginOrEmail; + + /// No description provided for @authRestoreButton. + /// + /// In ru, this message translates to: + /// **'Восстановить'** + String get authRestoreButton; + + /// No description provided for @changePasswordTitle. + /// + /// In ru, this message translates to: + /// **'Смена пароля'** + String get changePasswordTitle; + + /// No description provided for @changePasswordNewPasswordLabel. + /// + /// In ru, this message translates to: + /// **'НОВЫЙ ПАРОЛЬ'** + String get changePasswordNewPasswordLabel; + + /// No description provided for @changePasswordNewPasswordHint. + /// + /// In ru, this message translates to: + /// **'Введите новый пароль'** + String get changePasswordNewPasswordHint; + + /// No description provided for @changePasswordSubmit. + /// + /// In ru, this message translates to: + /// **'Сменить'** + String get changePasswordSubmit; + + /// No description provided for @otpTitle. + /// + /// In ru, this message translates to: + /// **'Код подтверждения'** + String get otpTitle; + + /// No description provided for @otpInvalidCode. + /// + /// In ru, this message translates to: + /// **'Неверный код'** + String get otpInvalidCode; + + /// No description provided for @otpResend. + /// + /// In ru, this message translates to: + /// **'Отправить повторно{suffix}'** + String otpResend(Object suffix); + + /// No description provided for @otpResendAfter. + /// + /// In ru, this message translates to: + /// **' через {seconds} секунд'** + String otpResendAfter(Object seconds); + + /// No description provided for @otpChangeAccount. + /// + /// In ru, this message translates to: + /// **'Сменить аккаунт'** + String get otpChangeAccount; + + /// No description provided for @recipeSavedTitle. + /// + /// In ru, this message translates to: + /// **'Моя кухня'** + String get recipeSavedTitle; + + /// No description provided for @recipeSavedEmptyMessage. + /// + /// In ru, this message translates to: + /// **'Здесь пока пусто. Сохраняйте понравившиеся рецепты из ленты, чтобы не потерять их'** + String get recipeSavedEmptyMessage; + + /// No description provided for @recipeSavedFind. + /// + /// In ru, this message translates to: + /// **'Найти'** + String get recipeSavedFind; + + /// No description provided for @recipeDraftsTitle. + /// + /// In ru, this message translates to: + /// **'Черновики'** + String get recipeDraftsTitle; + + /// No description provided for @recipeDraftsEmptyMessage. + /// + /// In ru, this message translates to: + /// **'Здесь пока пусто. Создавайте свои собственные кулинарные шедевры'** + String get recipeDraftsEmptyMessage; + + /// No description provided for @recipeDraftsCreate. + /// + /// In ru, this message translates to: + /// **'Создать'** + String get recipeDraftsCreate; + + /// No description provided for @recipeDraftsUntitled. + /// + /// In ru, this message translates to: + /// **'Без названия'** + String get recipeDraftsUntitled; + + /// No description provided for @recipeDraftsDeleteTitle. + /// + /// In ru, this message translates to: + /// **'Удалить черновик?'** + String get recipeDraftsDeleteTitle; + + /// No description provided for @recipeDraftsUpdated. + /// + /// In ru, this message translates to: + /// **'Обновлено: {value}'** + String recipeDraftsUpdated(Object value); + + /// No description provided for @recipeDetailIngredients. + /// + /// In ru, this message translates to: + /// **'Ингредиенты'** + String get recipeDetailIngredients; + + /// No description provided for @recipeDetailSteps. + /// + /// In ru, this message translates to: + /// **'Шаги'** + String get recipeDetailSteps; + + /// No description provided for @recipeDetailTime. + /// + /// In ru, this message translates to: + /// **'Время'** + String get recipeDetailTime; + + /// No description provided for @recipeDetailProteinSign. + /// + /// In ru, this message translates to: + /// **'Б'** + String get recipeDetailProteinSign; + + /// No description provided for @recipeDetailFatSign. + /// + /// In ru, this message translates to: + /// **'Ж'** + String get recipeDetailFatSign; + + /// No description provided for @recipeDetailCarbsSign. + /// + /// In ru, this message translates to: + /// **'У'** + String get recipeDetailCarbsSign; + + /// No description provided for @recipeDifficultyEasy. + /// + /// In ru, this message translates to: + /// **'ЛЕГКО'** + String get recipeDifficultyEasy; + + /// No description provided for @recipeDifficultyMedium. + /// + /// In ru, this message translates to: + /// **'СРЕДНЕ'** + String get recipeDifficultyMedium; + + /// No description provided for @recipeDifficultyHard. + /// + /// In ru, this message translates to: + /// **'СЛОЖНО'** + String get recipeDifficultyHard; + + /// No description provided for @commonLanguageRu. + /// + /// In ru, this message translates to: + /// **'Русский'** + String get commonLanguageRu; + + /// No description provided for @commonLanguageEn. + /// + /// In ru, this message translates to: + /// **'English'** + String get commonLanguageEn; + + /// No description provided for @commonError. + /// + /// In ru, this message translates to: + /// **'Ошибка'** + String get commonError; + + /// No description provided for @searchTitle. + /// + /// In ru, this message translates to: + /// **'Поиск'** + String get searchTitle; + + /// No description provided for @searchButton. + /// + /// In ru, this message translates to: + /// **'ПОИСК'** + String get searchButton; + + /// No description provided for @searchNothingFound. + /// + /// In ru, this message translates to: + /// **'Ничего не найдено'** + String get searchNothingFound; + + /// No description provided for @searchRecipeNameHint. + /// + /// In ru, this message translates to: + /// **'Название рецепта'** + String get searchRecipeNameHint; + + /// No description provided for @searchCookingTimeTitle. + /// + /// In ru, this message translates to: + /// **'Время приготовления'** + String get searchCookingTimeTitle; + + /// No description provided for @searchCookingTimeUpTo. + /// + /// In ru, this message translates to: + /// **'до {value} мин'** + String searchCookingTimeUpTo(Object value); + + /// No description provided for @searchNutritionGoals. + /// + /// In ru, this message translates to: + /// **'Цели в питании'** + String get searchNutritionGoals; + + /// No description provided for @searchCaloriesLabel. + /// + /// In ru, this message translates to: + /// **'КАЛОРИИ'** + String get searchCaloriesLabel; + + /// No description provided for @searchProteinsLabel. + /// + /// In ru, this message translates to: + /// **'БЕЛКИ'** + String get searchProteinsLabel; + + /// No description provided for @searchFatsLabel. + /// + /// In ru, this message translates to: + /// **'ЖИРЫ'** + String get searchFatsLabel; + + /// No description provided for @searchCarbsLabel. + /// + /// In ru, this message translates to: + /// **'УГЛЕВОДЫ'** + String get searchCarbsLabel; + + /// No description provided for @searchCategoriesTitle. + /// + /// In ru, this message translates to: + /// **'Категории'** + String get searchCategoriesTitle; + + /// No description provided for @searchIngredientsTitle. + /// + /// In ru, this message translates to: + /// **'Ингредиенты'** + String get searchIngredientsTitle; + + /// No description provided for @searchAddCategoryHint. + /// + /// In ru, this message translates to: + /// **'Добавить категорию...'** + String get searchAddCategoryHint; + + /// No description provided for @searchAddIngredientHint. + /// + /// In ru, this message translates to: + /// **'Добавить ингредиент...'** + String get searchAddIngredientHint; + + /// No description provided for @searchAddCategory. + /// + /// In ru, this message translates to: + /// **'Добавить категорию'** + String get searchAddCategory; + + /// No description provided for @searchAddIngredient. + /// + /// In ru, this message translates to: + /// **'Добавить ингредиент'** + String get searchAddIngredient; + + /// No description provided for @searchCategoryNotFound. + /// + /// In ru, this message translates to: + /// **'Категории не найдены'** + String get searchCategoryNotFound; + + /// No description provided for @searchIngredientNotFound. + /// + /// In ru, this message translates to: + /// **'Ингредиенты не найдены'** + String get searchIngredientNotFound; + + /// No description provided for @searchCategoryHint. + /// + /// In ru, this message translates to: + /// **'Здоровое питание'** + String get searchCategoryHint; + + /// No description provided for @searchIngredientHint. + /// + /// In ru, this message translates to: + /// **'Курица'** + String get searchIngredientHint; + + /// No description provided for @searchGeneralTitle. + /// + /// In ru, this message translates to: + /// **'Общее'** + String get searchGeneralTitle; + + /// No description provided for @searchMaxCookingTimeHint. + /// + /// In ru, this message translates to: + /// **'Максимальное время приготовления'** + String get searchMaxCookingTimeHint; + + /// No description provided for @searchCaloriesTitle. + /// + /// In ru, this message translates to: + /// **'Калории'** + String get searchCaloriesTitle; + + /// No description provided for @searchProteinsTitle. + /// + /// In ru, this message translates to: + /// **'Белки'** + String get searchProteinsTitle; + + /// No description provided for @searchFatsTitle. + /// + /// In ru, this message translates to: + /// **'Жиры'** + String get searchFatsTitle; + + /// No description provided for @searchCarbsTitle. + /// + /// In ru, this message translates to: + /// **'Углеводы'** + String get searchCarbsTitle; + + /// No description provided for @searchMinHint. + /// + /// In ru, this message translates to: + /// **'Мин'** + String get searchMinHint; + + /// No description provided for @searchMaxHint. + /// + /// In ru, this message translates to: + /// **'Макс'** + String get searchMaxHint; + + /// No description provided for @validationFieldRequired. + /// + /// In ru, this message translates to: + /// **'Поле не может быть пустым'** + String get validationFieldRequired; + + /// No description provided for @validationLoginTooShort. + /// + /// In ru, this message translates to: + /// **'Логин слишком короткий'** + String get validationLoginTooShort; + + /// No description provided for @validationLoginTooLong. + /// + /// In ru, this message translates to: + /// **'Логин слишком длинный'** + String get validationLoginTooLong; + + /// No description provided for @validationEmailInvalid. + /// + /// In ru, this message translates to: + /// **'Некорректный email'** + String get validationEmailInvalid; + + /// No description provided for @validationPasswordTooShort. + /// + /// In ru, this message translates to: + /// **'Пароль слишком короткий'** + String get validationPasswordTooShort; + + /// No description provided for @validationPasswordTooLong. + /// + /// In ru, this message translates to: + /// **'Пароль слишком длинный'** + String get validationPasswordTooLong; + + /// No description provided for @validationPasswordInvalid. + /// + /// In ru, this message translates to: + /// **'Пароль должен содержать строчные и заглавные латинские буквы, цифры и спец символы (@\$!%*?&_)'** + String get validationPasswordInvalid; + + /// No description provided for @validationConfirmPasswordNotMatch. + /// + /// In ru, this message translates to: + /// **'Пароли не совпадают'** + String get validationConfirmPasswordNotMatch; } class _AppLocalizationsDelegate diff --git a/frontend/lib/core/l10n/app_localizations_en.dart b/frontend/lib/core/l10n/app_localizations_en.dart index 6e8ee35..6bce5b9 100644 --- a/frontend/lib/core/l10n/app_localizations_en.dart +++ b/frontend/lib/core/l10n/app_localizations_en.dart @@ -36,4 +36,447 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileSettingsSignout => 'Sign out'; + + @override + String get recipeFormTitle => 'Create recipe'; + + @override + String get recipeFormAddPhoto => 'Add photo'; + + @override + String get recipeFormNameLabel => 'RECIPE NAME'; + + @override + String get recipeFormNameHint => 'Name of your masterpiece'; + + @override + String get recipeFormDescriptionLabel => 'DESCRIPTION'; + + @override + String get recipeFormDescriptionHint => 'Tell us why it is delicious...'; + + @override + String get recipeFormNutrition => 'NUTRITION'; + + @override + String get recipeFormProtein => 'PROTEIN'; + + @override + String get recipeFormFat => 'FAT'; + + @override + String get recipeFormCarbs => 'CARBS'; + + @override + String get recipeFormCalories => 'CALORIES'; + + @override + String get recipeFormDifficulty => 'DIFFICULTY'; + + @override + String get recipeFormDifficultyEasy => 'EASY'; + + @override + String get recipeFormDifficultyMedium => 'MEDIUM'; + + @override + String get recipeFormDifficultyHard => 'HARD'; + + @override + String get recipeFormCookingTimeLabel => 'COOKING TIME'; + + @override + String get recipeFormCookingTimeHint => '45 minutes'; + + @override + String get recipeFormCategories => 'CATEGORIES'; + + @override + String get recipeFormAddCategory => 'Add category'; + + @override + String get recipeFormIngredients => 'INGREDIENTS'; + + @override + String get recipeFormAddIngredient => 'Add ingredient'; + + @override + String get recipeFormSteps => 'COOKING STEPS'; + + @override + String get recipeFormAddStep => 'ADD STEP'; + + @override + String get recipeFormSaveDraft => 'Save draft'; + + @override + String get recipeFormSaveToSaved => 'Save to favorites'; + + @override + String get recipeFormPublish => 'Publish recipe'; + + @override + String get recipeFormSaving => 'Saving...'; + + @override + String get recipeFormPublishing => 'Publishing...'; + + @override + String get recipeFormDraftSaved => 'Draft saved'; + + @override + String get recipeFormSavedRecipeAdded => 'Recipe added to saved'; + + @override + String get recipeFormPublished => 'Recipe published'; + + @override + String get recipeFormPublishFailed => 'Failed to publish recipe'; + + @override + String get recipeFormFillRequired => 'Please fill in required fields'; + + @override + String get recipeFormErrorPhoto => 'Add a recipe photo'; + + @override + String get recipeFormErrorName => 'Enter recipe name'; + + @override + String get recipeFormErrorDescription => 'Enter description'; + + @override + String get recipeFormErrorCookingTime => 'Enter cooking time greater than 0'; + + @override + String get recipeFormErrorCategories => 'Add at least one category'; + + @override + String get recipeFormErrorIngredients => + 'Add at least one ingredient with amount and unit'; + + @override + String get recipeFormErrorSteps => + 'Add at least one step with title and description'; + + @override + String get recipeFormStepTitleHint => 'Step title'; + + @override + String get recipeFormStepDescriptionHint => 'Step description'; + + @override + String get commonOfflineMessage => + 'No internet connection. Connect to the network and refresh the page.'; + + @override + String get commonRefresh => 'Refresh'; + + @override + String get commonBack => 'Back'; + + @override + String get commonCancel => 'Cancel'; + + @override + String get commonDelete => 'Delete'; + + @override + String get commonGramShort => 'g'; + + @override + String get commonKcalShort => 'kcal'; + + @override + String commonMinutes(Object value) { + return '$value min'; + } + + @override + String commonServings(Object value) { + return '$value servings'; + } + + @override + String commonServingsDouble(Object value) { + return '$value servings'; + } + + @override + String get authBarSignIn => 'Sign in'; + + @override + String get authBarSignUp => 'Sign up'; + + @override + String get authBarRestore => 'Restore'; + + @override + String get authTopSubtitle => 'THE ART OF HOME COOKING'; + + @override + String get authDividerOr => 'OR WITH'; + + @override + String get authLoginLabel => 'LOGIN'; + + @override + String get authLoginHint => 'Enter login'; + + @override + String get authEmailHint => 'Enter email'; + + @override + String get authPasswordLabel => 'PASSWORD'; + + @override + String get authPasswordHint => 'Enter password'; + + @override + String get authConfirmPasswordLabel => 'CONFIRM PASSWORD'; + + @override + String get authConfirmPasswordHint => 'Enter password again'; + + @override + String get authSignInButton => 'Sign in'; + + @override + String get authSignInWrongLogin => 'Invalid login'; + + @override + String get authSignInWrongPassword => 'Invalid password'; + + @override + String get authSignUpButton => 'Sign up'; + + @override + String get authSignUpLoginTaken => 'Login already taken'; + + @override + String get authSignUpEmailTaken => 'Email already taken'; + + @override + String get authRestoreLoginOrEmailLabel => 'LOGIN OR EMAIL'; + + @override + String get authRestoreLoginOrEmailHint => 'Enter login or email'; + + @override + String get authRestoreWrongLoginOrEmail => 'Invalid login or email'; + + @override + String get authRestoreButton => 'Restore'; + + @override + String get changePasswordTitle => 'Change password'; + + @override + String get changePasswordNewPasswordLabel => 'NEW PASSWORD'; + + @override + String get changePasswordNewPasswordHint => 'Enter new password'; + + @override + String get changePasswordSubmit => 'Change'; + + @override + String get otpTitle => 'Confirmation code'; + + @override + String get otpInvalidCode => 'Invalid code'; + + @override + String otpResend(Object suffix) { + return 'Resend$suffix'; + } + + @override + String otpResendAfter(Object seconds) { + return ' in $seconds seconds'; + } + + @override + String get otpChangeAccount => 'Change account'; + + @override + String get recipeSavedTitle => 'My kitchen'; + + @override + String get recipeSavedEmptyMessage => + 'Nothing here yet. Save recipes you like from the feed so you do not lose them'; + + @override + String get recipeSavedFind => 'Find'; + + @override + String get recipeDraftsTitle => 'Drafts'; + + @override + String get recipeDraftsEmptyMessage => + 'Nothing here yet. Create your own culinary masterpieces'; + + @override + String get recipeDraftsCreate => 'Create'; + + @override + String get recipeDraftsUntitled => 'Untitled'; + + @override + String get recipeDraftsDeleteTitle => 'Delete draft?'; + + @override + String recipeDraftsUpdated(Object value) { + return 'Updated: $value'; + } + + @override + String get recipeDetailIngredients => 'Ingredients'; + + @override + String get recipeDetailSteps => 'Steps'; + + @override + String get recipeDetailTime => 'Time'; + + @override + String get recipeDetailProteinSign => 'P'; + + @override + String get recipeDetailFatSign => 'F'; + + @override + String get recipeDetailCarbsSign => 'C'; + + @override + String get recipeDifficultyEasy => 'EASY'; + + @override + String get recipeDifficultyMedium => 'MEDIUM'; + + @override + String get recipeDifficultyHard => 'HARD'; + + @override + String get commonLanguageRu => 'Russian'; + + @override + String get commonLanguageEn => 'English'; + + @override + String get commonError => 'Error'; + + @override + String get searchTitle => 'Search'; + + @override + String get searchButton => 'SEARCH'; + + @override + String get searchNothingFound => 'Nothing found'; + + @override + String get searchRecipeNameHint => 'Recipe name'; + + @override + String get searchCookingTimeTitle => 'Cooking time'; + + @override + String searchCookingTimeUpTo(Object value) { + return 'up to $value min'; + } + + @override + String get searchNutritionGoals => 'Nutrition goals'; + + @override + String get searchCaloriesLabel => 'CALORIES'; + + @override + String get searchProteinsLabel => 'PROTEINS'; + + @override + String get searchFatsLabel => 'FATS'; + + @override + String get searchCarbsLabel => 'CARBS'; + + @override + String get searchCategoriesTitle => 'Categories'; + + @override + String get searchIngredientsTitle => 'Ingredients'; + + @override + String get searchAddCategoryHint => 'Add category...'; + + @override + String get searchAddIngredientHint => 'Add ingredient...'; + + @override + String get searchAddCategory => 'Add category'; + + @override + String get searchAddIngredient => 'Add ingredient'; + + @override + String get searchCategoryNotFound => 'No categories found'; + + @override + String get searchIngredientNotFound => 'No ingredients found'; + + @override + String get searchCategoryHint => 'Healthy eating'; + + @override + String get searchIngredientHint => 'Chicken'; + + @override + String get searchGeneralTitle => 'General'; + + @override + String get searchMaxCookingTimeHint => 'Maximum cooking time'; + + @override + String get searchCaloriesTitle => 'Calories'; + + @override + String get searchProteinsTitle => 'Proteins'; + + @override + String get searchFatsTitle => 'Fats'; + + @override + String get searchCarbsTitle => 'Carbohydrates'; + + @override + String get searchMinHint => 'Min'; + + @override + String get searchMaxHint => 'Max'; + + @override + String get validationFieldRequired => 'Field cannot be empty'; + + @override + String get validationLoginTooShort => 'Login is too short'; + + @override + String get validationLoginTooLong => 'Login is too long'; + + @override + String get validationEmailInvalid => 'Invalid email'; + + @override + String get validationPasswordTooShort => 'Password is too short'; + + @override + String get validationPasswordTooLong => 'Password is too long'; + + @override + String get validationPasswordInvalid => + 'Password must contain lowercase and uppercase Latin letters, digits, and special symbols (@\$!%*?&_)'; + + @override + String get validationConfirmPasswordNotMatch => 'Passwords do not match'; } diff --git a/frontend/lib/core/l10n/app_localizations_ru.dart b/frontend/lib/core/l10n/app_localizations_ru.dart index 0da0550..c76ae78 100644 --- a/frontend/lib/core/l10n/app_localizations_ru.dart +++ b/frontend/lib/core/l10n/app_localizations_ru.dart @@ -36,4 +36,448 @@ class AppLocalizationsRu extends AppLocalizations { @override String get profileSettingsSignout => 'Выйти из аккаунта'; + + @override + String get recipeFormTitle => 'Создание рецепта'; + + @override + String get recipeFormAddPhoto => 'Добавить фото'; + + @override + String get recipeFormNameLabel => 'НАЗВАНИЕ РЕЦЕПТА'; + + @override + String get recipeFormNameHint => 'Название вашего шедевра'; + + @override + String get recipeFormDescriptionLabel => 'ОПИСАНИЕ'; + + @override + String get recipeFormDescriptionHint => 'Расскажите нам почему это вкусно...'; + + @override + String get recipeFormNutrition => 'КБЖУ'; + + @override + String get recipeFormProtein => 'БЕЛ'; + + @override + String get recipeFormFat => 'ЖИР'; + + @override + String get recipeFormCarbs => 'УГЛ'; + + @override + String get recipeFormCalories => 'КАЛОРИИ'; + + @override + String get recipeFormDifficulty => 'СЛОЖНОСТЬ'; + + @override + String get recipeFormDifficultyEasy => 'ЛЕГКО'; + + @override + String get recipeFormDifficultyMedium => 'СРЕДНЕ'; + + @override + String get recipeFormDifficultyHard => 'СЛОЖНО'; + + @override + String get recipeFormCookingTimeLabel => 'ВРЕМЯ ПРИГОТОВЛЕНИЯ'; + + @override + String get recipeFormCookingTimeHint => '45 минут'; + + @override + String get recipeFormCategories => 'КАТЕГОРИИ'; + + @override + String get recipeFormAddCategory => 'Добавить категорию'; + + @override + String get recipeFormIngredients => 'ИНГРЕДИЕНТЫ'; + + @override + String get recipeFormAddIngredient => 'Добавить ингредиент'; + + @override + String get recipeFormSteps => 'ШАГИ ПРИГОТОВЛЕНИЯ'; + + @override + String get recipeFormAddStep => 'ДОБАВИТЬ ШАГ'; + + @override + String get recipeFormSaveDraft => 'Сохранить черновик'; + + @override + String get recipeFormSaveToSaved => 'В сохранённые'; + + @override + String get recipeFormPublish => 'Опубликовать рецепт'; + + @override + String get recipeFormSaving => 'Сохранение...'; + + @override + String get recipeFormPublishing => 'Публикация...'; + + @override + String get recipeFormDraftSaved => 'Черновик сохранён'; + + @override + String get recipeFormSavedRecipeAdded => 'Рецепт добавлен в сохранённые'; + + @override + String get recipeFormPublished => 'Рецепт опубликован'; + + @override + String get recipeFormPublishFailed => 'Не удалось опубликовать рецепт'; + + @override + String get recipeFormFillRequired => 'Заполните обязательные поля'; + + @override + String get recipeFormErrorPhoto => 'Добавьте фото рецепта'; + + @override + String get recipeFormErrorName => 'Введите название рецепта'; + + @override + String get recipeFormErrorDescription => 'Введите описание'; + + @override + String get recipeFormErrorCookingTime => + 'Введите время приготовления больше 0'; + + @override + String get recipeFormErrorCategories => 'Добавьте хотя бы одну категорию'; + + @override + String get recipeFormErrorIngredients => + 'Добавьте хотя бы один ингредиент с количеством и единицей'; + + @override + String get recipeFormErrorSteps => + 'Добавьте хотя бы один шаг с заголовком и описанием'; + + @override + String get recipeFormStepTitleHint => 'Заголовок шага'; + + @override + String get recipeFormStepDescriptionHint => 'Описание шага'; + + @override + String get commonOfflineMessage => + 'Нет подключения к интернету. Подключитесь к сети и обновите страницу.'; + + @override + String get commonRefresh => 'Обновить'; + + @override + String get commonBack => 'Назад'; + + @override + String get commonCancel => 'Отмена'; + + @override + String get commonDelete => 'Удалить'; + + @override + String get commonGramShort => 'г'; + + @override + String get commonKcalShort => 'ккал'; + + @override + String commonMinutes(Object value) { + return '$value мин'; + } + + @override + String commonServings(Object value) { + return '$value порций'; + } + + @override + String commonServingsDouble(Object value) { + return '$value порций'; + } + + @override + String get authBarSignIn => 'Вход'; + + @override + String get authBarSignUp => 'Регистрация'; + + @override + String get authBarRestore => 'Восстановление'; + + @override + String get authTopSubtitle => 'ИСКУССТВО ДОМАШНЕЙ КУХНИ'; + + @override + String get authDividerOr => 'ИЛИ ЧЕРЕЗ'; + + @override + String get authLoginLabel => 'ЛОГИН'; + + @override + String get authLoginHint => 'Введите логин'; + + @override + String get authEmailHint => 'Введите email'; + + @override + String get authPasswordLabel => 'ПАРОЛЬ'; + + @override + String get authPasswordHint => 'Введите пароль'; + + @override + String get authConfirmPasswordLabel => 'ПОВТОРИТЕ ПАРОЛЬ'; + + @override + String get authConfirmPasswordHint => 'Введите пароль повторно'; + + @override + String get authSignInButton => 'Войти'; + + @override + String get authSignInWrongLogin => 'Неправильный логин'; + + @override + String get authSignInWrongPassword => 'Неправильный пароль'; + + @override + String get authSignUpButton => 'Зарегистрироваться'; + + @override + String get authSignUpLoginTaken => 'Логин занят'; + + @override + String get authSignUpEmailTaken => 'Почта занята'; + + @override + String get authRestoreLoginOrEmailLabel => 'ЛОГИН ИЛИ EMAIL'; + + @override + String get authRestoreLoginOrEmailHint => 'Введите логин или email'; + + @override + String get authRestoreWrongLoginOrEmail => 'Неверный логин или email'; + + @override + String get authRestoreButton => 'Восстановить'; + + @override + String get changePasswordTitle => 'Смена пароля'; + + @override + String get changePasswordNewPasswordLabel => 'НОВЫЙ ПАРОЛЬ'; + + @override + String get changePasswordNewPasswordHint => 'Введите новый пароль'; + + @override + String get changePasswordSubmit => 'Сменить'; + + @override + String get otpTitle => 'Код подтверждения'; + + @override + String get otpInvalidCode => 'Неверный код'; + + @override + String otpResend(Object suffix) { + return 'Отправить повторно$suffix'; + } + + @override + String otpResendAfter(Object seconds) { + return ' через $seconds секунд'; + } + + @override + String get otpChangeAccount => 'Сменить аккаунт'; + + @override + String get recipeSavedTitle => 'Моя кухня'; + + @override + String get recipeSavedEmptyMessage => + 'Здесь пока пусто. Сохраняйте понравившиеся рецепты из ленты, чтобы не потерять их'; + + @override + String get recipeSavedFind => 'Найти'; + + @override + String get recipeDraftsTitle => 'Черновики'; + + @override + String get recipeDraftsEmptyMessage => + 'Здесь пока пусто. Создавайте свои собственные кулинарные шедевры'; + + @override + String get recipeDraftsCreate => 'Создать'; + + @override + String get recipeDraftsUntitled => 'Без названия'; + + @override + String get recipeDraftsDeleteTitle => 'Удалить черновик?'; + + @override + String recipeDraftsUpdated(Object value) { + return 'Обновлено: $value'; + } + + @override + String get recipeDetailIngredients => 'Ингредиенты'; + + @override + String get recipeDetailSteps => 'Шаги'; + + @override + String get recipeDetailTime => 'Время'; + + @override + String get recipeDetailProteinSign => 'Б'; + + @override + String get recipeDetailFatSign => 'Ж'; + + @override + String get recipeDetailCarbsSign => 'У'; + + @override + String get recipeDifficultyEasy => 'ЛЕГКО'; + + @override + String get recipeDifficultyMedium => 'СРЕДНЕ'; + + @override + String get recipeDifficultyHard => 'СЛОЖНО'; + + @override + String get commonLanguageRu => 'Русский'; + + @override + String get commonLanguageEn => 'English'; + + @override + String get commonError => 'Ошибка'; + + @override + String get searchTitle => 'Поиск'; + + @override + String get searchButton => 'ПОИСК'; + + @override + String get searchNothingFound => 'Ничего не найдено'; + + @override + String get searchRecipeNameHint => 'Название рецепта'; + + @override + String get searchCookingTimeTitle => 'Время приготовления'; + + @override + String searchCookingTimeUpTo(Object value) { + return 'до $value мин'; + } + + @override + String get searchNutritionGoals => 'Цели в питании'; + + @override + String get searchCaloriesLabel => 'КАЛОРИИ'; + + @override + String get searchProteinsLabel => 'БЕЛКИ'; + + @override + String get searchFatsLabel => 'ЖИРЫ'; + + @override + String get searchCarbsLabel => 'УГЛЕВОДЫ'; + + @override + String get searchCategoriesTitle => 'Категории'; + + @override + String get searchIngredientsTitle => 'Ингредиенты'; + + @override + String get searchAddCategoryHint => 'Добавить категорию...'; + + @override + String get searchAddIngredientHint => 'Добавить ингредиент...'; + + @override + String get searchAddCategory => 'Добавить категорию'; + + @override + String get searchAddIngredient => 'Добавить ингредиент'; + + @override + String get searchCategoryNotFound => 'Категории не найдены'; + + @override + String get searchIngredientNotFound => 'Ингредиенты не найдены'; + + @override + String get searchCategoryHint => 'Здоровое питание'; + + @override + String get searchIngredientHint => 'Курица'; + + @override + String get searchGeneralTitle => 'Общее'; + + @override + String get searchMaxCookingTimeHint => 'Максимальное время приготовления'; + + @override + String get searchCaloriesTitle => 'Калории'; + + @override + String get searchProteinsTitle => 'Белки'; + + @override + String get searchFatsTitle => 'Жиры'; + + @override + String get searchCarbsTitle => 'Углеводы'; + + @override + String get searchMinHint => 'Мин'; + + @override + String get searchMaxHint => 'Макс'; + + @override + String get validationFieldRequired => 'Поле не может быть пустым'; + + @override + String get validationLoginTooShort => 'Логин слишком короткий'; + + @override + String get validationLoginTooLong => 'Логин слишком длинный'; + + @override + String get validationEmailInvalid => 'Некорректный email'; + + @override + String get validationPasswordTooShort => 'Пароль слишком короткий'; + + @override + String get validationPasswordTooLong => 'Пароль слишком длинный'; + + @override + String get validationPasswordInvalid => + 'Пароль должен содержать строчные и заглавные латинские буквы, цифры и спец символы (@\$!%*?&_)'; + + @override + String get validationConfirmPasswordNotMatch => 'Пароли не совпадают'; } diff --git a/frontend/lib/core/l10n/app_ru.arb b/frontend/lib/core/l10n/app_ru.arb index 94f1b0d..c23575e 100644 --- a/frontend/lib/core/l10n/app_ru.arb +++ b/frontend/lib/core/l10n/app_ru.arb @@ -10,5 +10,192 @@ "profileSettings": "Настройки", "profileSettingsLocale": "Сменить язык", "profileSettingsChangePassword": "Смена пароля", - "profileSettingsSignout": "Выйти из аккаунта" + "profileSettingsSignout": "Выйти из аккаунта", + + "recipeFormTitle": "Создание рецепта", + "recipeFormAddPhoto": "Добавить фото", + "recipeFormNameLabel": "НАЗВАНИЕ РЕЦЕПТА", + "recipeFormNameHint": "Название вашего шедевра", + "recipeFormDescriptionLabel": "ОПИСАНИЕ", + "recipeFormDescriptionHint": "Расскажите нам почему это вкусно...", + "recipeFormNutrition": "КБЖУ", + "recipeFormProtein": "БЕЛ", + "recipeFormFat": "ЖИР", + "recipeFormCarbs": "УГЛ", + "recipeFormCalories": "КАЛОРИИ", + "recipeFormDifficulty": "СЛОЖНОСТЬ", + "recipeFormDifficultyEasy": "ЛЕГКО", + "recipeFormDifficultyMedium": "СРЕДНЕ", + "recipeFormDifficultyHard": "СЛОЖНО", + "recipeFormCookingTimeLabel": "ВРЕМЯ ПРИГОТОВЛЕНИЯ", + "recipeFormCookingTimeHint": "45 минут", + "recipeFormCategories": "КАТЕГОРИИ", + "recipeFormAddCategory": "Добавить категорию", + "recipeFormIngredients": "ИНГРЕДИЕНТЫ", + "recipeFormAddIngredient": "Добавить ингредиент", + "recipeFormSteps": "ШАГИ ПРИГОТОВЛЕНИЯ", + "recipeFormAddStep": "ДОБАВИТЬ ШАГ", + "recipeFormSaveDraft": "Сохранить черновик", + "recipeFormSaveToSaved": "В сохранённые", + "recipeFormPublish": "Опубликовать рецепт", + "recipeFormSaving": "Сохранение...", + "recipeFormPublishing": "Публикация...", + "recipeFormDraftSaved": "Черновик сохранён", + "recipeFormSavedRecipeAdded": "Рецепт добавлен в сохранённые", + "recipeFormPublished": "Рецепт опубликован", + "recipeFormPublishFailed": "Не удалось опубликовать рецепт", + "recipeFormFillRequired": "Заполните обязательные поля", + "recipeFormErrorPhoto": "Добавьте фото рецепта", + "recipeFormErrorName": "Введите название рецепта", + "recipeFormErrorDescription": "Введите описание", + "recipeFormErrorCookingTime": "Введите время приготовления больше 0", + "recipeFormErrorCategories": "Добавьте хотя бы одну категорию", + "recipeFormErrorIngredients": "Добавьте хотя бы один ингредиент с количеством и единицей", + "recipeFormErrorSteps": "Добавьте хотя бы один шаг с заголовком и описанием", + "recipeFormStepTitleHint": "Заголовок шага", + "recipeFormStepDescriptionHint": "Описание шага", + + "commonOfflineMessage": "Нет подключения к интернету. Подключитесь к сети и обновите страницу.", + "commonRefresh": "Обновить", + "commonBack": "Назад", + "commonCancel": "Отмена", + "commonDelete": "Удалить", + "commonGramShort": "г", + "commonKcalShort": "ккал", + "commonMinutes": "{value} мин", + "@commonMinutes": { + "placeholders": { + "value": {} + } + }, + "commonServings": "{value} порций", + "@commonServings": { + "placeholders": { + "value": {} + } + }, + "commonServingsDouble": "{value} порций", + "@commonServingsDouble": { + "placeholders": { + "value": {} + } + }, + + "authBarSignIn": "Вход", + "authBarSignUp": "Регистрация", + "authBarRestore": "Восстановление", + "authTopSubtitle": "ИСКУССТВО ДОМАШНЕЙ КУХНИ", + "authDividerOr": "ИЛИ ЧЕРЕЗ", + "authLoginLabel": "ЛОГИН", + "authLoginHint": "Введите логин", + "authEmailHint": "Введите email", + "authPasswordLabel": "ПАРОЛЬ", + "authPasswordHint": "Введите пароль", + "authConfirmPasswordLabel": "ПОВТОРИТЕ ПАРОЛЬ", + "authConfirmPasswordHint": "Введите пароль повторно", + "authSignInButton": "Войти", + "authSignInWrongLogin": "Неправильный логин", + "authSignInWrongPassword": "Неправильный пароль", + "authSignUpButton": "Зарегистрироваться", + "authSignUpLoginTaken": "Логин занят", + "authSignUpEmailTaken": "Почта занята", + "authRestoreLoginOrEmailLabel": "ЛОГИН ИЛИ EMAIL", + "authRestoreLoginOrEmailHint": "Введите логин или email", + "authRestoreWrongLoginOrEmail": "Неверный логин или email", + "authRestoreButton": "Восстановить", + + "changePasswordTitle": "Смена пароля", + "changePasswordNewPasswordLabel": "НОВЫЙ ПАРОЛЬ", + "changePasswordNewPasswordHint": "Введите новый пароль", + "changePasswordSubmit": "Сменить", + + "otpTitle": "Код подтверждения", + "otpInvalidCode": "Неверный код", + "otpResend": "Отправить повторно{suffix}", + "@otpResend": { + "placeholders": { + "suffix": {} + } + }, + "otpResendAfter": " через {seconds} секунд", + "@otpResendAfter": { + "placeholders": { + "seconds": {} + } + }, + "otpChangeAccount": "Сменить аккаунт", + + "recipeSavedTitle": "Моя кухня", + "recipeSavedEmptyMessage": "Здесь пока пусто. Сохраняйте понравившиеся рецепты из ленты, чтобы не потерять их", + "recipeSavedFind": "Найти", + + "recipeDraftsTitle": "Черновики", + "recipeDraftsEmptyMessage": "Здесь пока пусто. Создавайте свои собственные кулинарные шедевры", + "recipeDraftsCreate": "Создать", + "recipeDraftsUntitled": "Без названия", + "recipeDraftsDeleteTitle": "Удалить черновик?", + "recipeDraftsUpdated": "Обновлено: {value}", + "@recipeDraftsUpdated": { + "placeholders": { + "value": {} + } + }, + + "recipeDetailIngredients": "Ингредиенты", + "recipeDetailSteps": "Шаги", + "recipeDetailTime": "Время", + "recipeDetailProteinSign": "Б", + "recipeDetailFatSign": "Ж", + "recipeDetailCarbsSign": "У", + "recipeDifficultyEasy": "ЛЕГКО", + "recipeDifficultyMedium": "СРЕДНЕ", + "recipeDifficultyHard": "СЛОЖНО", + + "commonLanguageRu": "Русский", + "commonLanguageEn": "English", + "commonError": "Ошибка", + + "searchTitle": "Поиск", + "searchButton": "ПОИСК", + "searchNothingFound": "Ничего не найдено", + "searchRecipeNameHint": "Название рецепта", + "searchCookingTimeTitle": "Время приготовления", + "searchCookingTimeUpTo": "до {value} мин", + "@searchCookingTimeUpTo": { + "placeholders": { + "value": {} + } + }, + "searchNutritionGoals": "Цели в питании", + "searchCaloriesLabel": "КАЛОРИИ", + "searchProteinsLabel": "БЕЛКИ", + "searchFatsLabel": "ЖИРЫ", + "searchCarbsLabel": "УГЛЕВОДЫ", + "searchCategoriesTitle": "Категории", + "searchIngredientsTitle": "Ингредиенты", + "searchAddCategoryHint": "Добавить категорию...", + "searchAddIngredientHint": "Добавить ингредиент...", + "searchAddCategory": "Добавить категорию", + "searchAddIngredient": "Добавить ингредиент", + "searchCategoryNotFound": "Категории не найдены", + "searchIngredientNotFound": "Ингредиенты не найдены", + "searchCategoryHint": "Здоровое питание", + "searchIngredientHint": "Курица", + "searchGeneralTitle": "Общее", + "searchMaxCookingTimeHint": "Максимальное время приготовления", + "searchCaloriesTitle": "Калории", + "searchProteinsTitle": "Белки", + "searchFatsTitle": "Жиры", + "searchCarbsTitle": "Углеводы", + "searchMinHint": "Мин", + "searchMaxHint": "Макс", + + "validationFieldRequired": "Поле не может быть пустым", + "validationLoginTooShort": "Логин слишком короткий", + "validationLoginTooLong": "Логин слишком длинный", + "validationEmailInvalid": "Некорректный email", + "validationPasswordTooShort": "Пароль слишком короткий", + "validationPasswordTooLong": "Пароль слишком длинный", + "validationPasswordInvalid": "Пароль должен содержать строчные и заглавные латинские буквы, цифры и спец символы (@$!%*?&_)", + "validationConfirmPasswordNotMatch": "Пароли не совпадают" } diff --git a/frontend/lib/core/presentation/widgets/key_board_listener.dart b/frontend/lib/core/presentation/widgets/key_board_listener.dart new file mode 100644 index 0000000..35b3abe --- /dev/null +++ b/frontend/lib/core/presentation/widgets/key_board_listener.dart @@ -0,0 +1,130 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +typedef KeyboardChangeListener = Function(bool isVisible); + +class KeyboardListener with WidgetsBindingObserver { + static final Random _random = Random(); + + + /// Колбэки, вызывающиеся при появлении и сокрытии клавиатуры + final Map _changeListeners = {}; + /// Колбэки, вызывающиеся при появлении клавиатуры + final Map _showListeners = {}; + /// Колбэки, вызывающиеся при сокрытии клавиатуры + final Map _hideListeners = {}; + + bool get isVisibleKeyboard => + WidgetsBinding.instance.window.viewInsets.bottom > 0; + + KeyboardListener() { + _init(); + } + + + + void dispose() { + // Удаляем текущий класс из списка наблюдателей + WidgetsBinding.instance.removeObserver(this); + // Очищаем списки колбэков + _changeListeners.clear(); + _showListeners.clear(); + _hideListeners.clear(); + } + + + /// При изменениях системного UI вызываем слушателей + @override + void didChangeMetrics() { + _listener(); + } + + + /// Метод добавления слушателей + String addListener({ + String? id, + KeyboardChangeListener? onChange, + VoidCallback? onShow, + VoidCallback? onHide, + }) { + assert(onChange != null || onShow != null || onHide != null); + /// Для более удобного доступа к слушателям используются идентификаторы + id ??= _generateId(); + + if (onChange != null) _changeListeners[id] = onChange; + if (onShow != null) _showListeners[id] = onShow; + if (onHide != null) _hideListeners[id] = onHide; + return id; + } + + /// Методы удаления слушателей + void removeChangeListener(KeyboardChangeListener listener) { + _removeListener(_changeListeners, listener); + } + + void removeShowListener(VoidCallback listener) { + _removeListener(_showListeners, listener); + } + + void removeHideListener(VoidCallback listener) { + _removeListener(_hideListeners, listener); + } + + void removeAtChangeListener(String id) { + _removeAtListener(_changeListeners, id); + } + + void removeAtShowListener(String id) { + _removeAtListener(_changeListeners, id); + } + + void removeAtHideListener(String id) { + _removeAtListener(_changeListeners, id); + } + + void _removeAtListener(Map listeners, String id) { + listeners.remove(id); + } + + void _removeListener(Map listeners, Function listener) { + listeners.removeWhere((key, value) => value == listener); + } + + String _generateId() { + return _random.nextDouble().toString(); + } + + void _init() { + WidgetsBinding.instance.addObserver(this); // Регистрируем наблюдателя + } + + void _listener() { + if (isVisibleKeyboard) { + _onShow(); + _onChange(true); + } else { + _onHide(); + _onChange(false); + } + } + + void _onChange(bool isOpen) { + for (KeyboardChangeListener listener in _changeListeners.values) { + listener(isOpen); + } + } + + void _onShow() { + for (VoidCallback listener in _showListeners.values) { + listener(); + } + } + + void _onHide() { + for (VoidCallback listener in _hideListeners.values) { + listener(); + } + } +} \ No newline at end of file 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 40c6834..312cef2 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 @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/auth/auth_common/presentation/pages/auth_page_content.dart'; import 'package:flutter/material.dart'; @@ -23,21 +24,21 @@ class AuthBar extends StatelessWidget { onTypeChanged(AuthPageContentType.signIn); }, isSelected: type == AuthPageContentType.signIn, - title: 'Вход', + title: MyLocale.of(context).authBarSignIn, ), AuthBarItem( onTap: () { onTypeChanged(AuthPageContentType.signUp); }, isSelected: type == AuthPageContentType.signUp, - title: 'Регистрация', + title: MyLocale.of(context).authBarSignUp, ), AuthBarItem( onTap: () { onTypeChanged(AuthPageContentType.restore); }, isSelected: type == AuthPageContentType.restore, - title: 'Восстановление', + title: MyLocale.of(context).authBarRestore, ), ], ), diff --git a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_divider.dart b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_divider.dart index 500e75c..b8ac55f 100644 --- a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_divider.dart +++ b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_divider.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:flutter/material.dart'; class AuthDivider extends StatelessWidget { @@ -5,15 +6,15 @@ class AuthDivider extends StatelessWidget { @override Widget build(BuildContext context) { - return const Row( + return Row( mainAxisAlignment: MainAxisAlignment.center, spacing: 16.0, children: [ Expanded(child: _Line()), Text( - 'ИЛИ ЧЕРЕЗ', - style: TextStyle( + MyLocale.of(context).authDividerOr, + style: const TextStyle( color: Color(0x4DE5C9A8), fontSize: 10.0, fontWeight: FontWeight.w700, diff --git a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_top.dart b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_top.dart index a1fd04b..2651902 100644 --- a/frontend/lib/features/auth/auth_common/presentation/widgets/auth_top.dart +++ b/frontend/lib/features/auth/auth_common/presentation/widgets/auth_top.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:flutter/material.dart'; class AuthTop extends StatelessWidget { @@ -5,10 +6,10 @@ class AuthTop extends StatelessWidget { @override Widget build(BuildContext context) { - return const Column( + return Column( spacing: 8.0, children: [ - Text( + const Text( 'Cookify', style: TextStyle( color: Color(0xFFE5C9A8), @@ -20,8 +21,8 @@ class AuthTop extends StatelessWidget { ), Text( - 'ИСКУССТВО ДОМАШНЕЙ КУХНИ', - style: TextStyle( + MyLocale.of(context).authTopSubtitle, + style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 14.0, fontWeight: FontWeight.normal, diff --git a/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart b/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart index 34b66a4..92961f5 100644 --- a/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart +++ b/frontend/lib/features/change_password/presentation/pages/change_password_page_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_bottom.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_text_field.dart'; @@ -65,7 +66,9 @@ class _ChangePasswordPageContentState extends State { spacing: 24.0, children: [ Text( - 'Смена пароля', + MyLocale.of( + context, + ).changePasswordTitle, style: const TextStyle( color: Color(0x80FFE6C9), fontSize: 20.0, @@ -98,8 +101,12 @@ class _ChangePasswordPageContentState extends State { }, inputType: TextInputType.visiblePassword, - label: 'НОВЫЙ ПАРОЛЬ', - hint: 'Введите новый пароль', + label: MyLocale.of( + context, + ).changePasswordNewPasswordLabel, + hint: MyLocale.of( + context, + ).changePasswordNewPasswordHint, isPassword: true, failureMessage: state .password @@ -118,8 +125,12 @@ class _ChangePasswordPageContentState extends State { ), inputType: TextInputType.visiblePassword, - label: 'ПОВТОРИТЕ ПАРОЛЬ', - hint: 'Введите пароль повторно', + label: MyLocale.of( + context, + ).authConfirmPasswordLabel, + hint: MyLocale.of( + context, + ).authConfirmPasswordHint, isPassword: true, failureMessage: state .confirmPassword @@ -133,7 +144,9 @@ class _ChangePasswordPageContentState extends State { .read() .add(ChangePassword()); }, - title: 'Сменить', + title: MyLocale.of( + context, + ).changePasswordSubmit, isLoading: state.isLoading, ), @@ -141,7 +154,7 @@ class _ChangePasswordPageContentState extends State { onTap: () => context.pop(), behavior: HitTestBehavior.opaque, child: Text( - 'Назад', + MyLocale.of(context).commonBack, style: const TextStyle( color: Color(0x80FFE6C9), fontSize: 14.0, diff --git a/frontend/lib/features/credentials_validation/presentation/extensions/localized_confirm_password_validation_status.dart b/frontend/lib/features/credentials_validation/presentation/extensions/localized_confirm_password_validation_status.dart index 9da0ad3..923d778 100644 --- a/frontend/lib/features/credentials_validation/presentation/extensions/localized_confirm_password_validation_status.dart +++ b/frontend/lib/features/credentials_validation/presentation/extensions/localized_confirm_password_validation_status.dart @@ -1,9 +1,13 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/credentials_validation/domain/enums/confirm_password_validation_status.dart'; import 'package:flutter/material.dart'; -extension LocalizedConfirmPasswordValidationStatus on ConfirmPasswordValidationStatus { +extension LocalizedConfirmPasswordValidationStatus + on ConfirmPasswordValidationStatus { String localize(BuildContext context) => switch (this) { - ConfirmPasswordValidationStatus.notMatch => 'Пароли не совпадают', + ConfirmPasswordValidationStatus.notMatch => MyLocale.of( + context, + ).validationConfirmPasswordNotMatch, ConfirmPasswordValidationStatus.valid => '', }; -} \ No newline at end of file +} diff --git a/frontend/lib/features/credentials_validation/presentation/extensions/localized_email_validation_status.dart b/frontend/lib/features/credentials_validation/presentation/extensions/localized_email_validation_status.dart index 2e81d1e..92131dc 100644 --- a/frontend/lib/features/credentials_validation/presentation/extensions/localized_email_validation_status.dart +++ b/frontend/lib/features/credentials_validation/presentation/extensions/localized_email_validation_status.dart @@ -1,10 +1,13 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/credentials_validation/domain/enums/email_validation_status.dart'; import 'package:flutter/material.dart'; extension LocalizedEmailValidationStatus on EmailValidationStatus { String localize(BuildContext context) => switch (this) { - EmailValidationStatus.empty => 'Поле не может быть пустым', - EmailValidationStatus.invalid => 'Некорректный email', + EmailValidationStatus.empty => MyLocale.of(context).validationFieldRequired, + EmailValidationStatus.invalid => MyLocale.of( + context, + ).validationEmailInvalid, EmailValidationStatus.valid => '', }; } diff --git a/frontend/lib/features/credentials_validation/presentation/extensions/localized_login_validation_status.dart b/frontend/lib/features/credentials_validation/presentation/extensions/localized_login_validation_status.dart index 6b9ca81..189fdeb 100644 --- a/frontend/lib/features/credentials_validation/presentation/extensions/localized_login_validation_status.dart +++ b/frontend/lib/features/credentials_validation/presentation/extensions/localized_login_validation_status.dart @@ -1,11 +1,16 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/credentials_validation/domain/enums/login_validation_status.dart'; import 'package:flutter/material.dart'; extension LocalizedLoginValidationStatus on LoginValidationStatus { String localize(BuildContext context) => switch (this) { - LoginValidationStatus.empty => 'Поле не может быть пустым', - LoginValidationStatus.tooShort => 'Логин слишком короткий', - LoginValidationStatus.tooLong => 'Логин слишком длинный', + LoginValidationStatus.empty => MyLocale.of(context).validationFieldRequired, + LoginValidationStatus.tooShort => MyLocale.of( + context, + ).validationLoginTooShort, + LoginValidationStatus.tooLong => MyLocale.of( + context, + ).validationLoginTooLong, LoginValidationStatus.valid => '', }; } diff --git a/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart b/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart index 44e3bcc..1073bb8 100644 --- a/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart +++ b/frontend/lib/features/credentials_validation/presentation/extensions/localized_password_validation_status.dart @@ -1,12 +1,21 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/credentials_validation/domain/enums/password_validation_status.dart'; import 'package:flutter/material.dart'; extension LocalizedPasswordValidationStatus on PasswordValidationStatus { String localize(BuildContext context) => switch (this) { - PasswordValidationStatus.empty => 'Поле не может быть пустым', - PasswordValidationStatus.tooShort => 'Пароль слишком короткий', - PasswordValidationStatus.tooLong => 'Пароль слишком длинный', - PasswordValidationStatus.invalid => r'Пароль должен содержать строчные и заглавные латинские буквы, цифры и спец символы (@$!%*?&_)', + PasswordValidationStatus.empty => MyLocale.of( + context, + ).validationFieldRequired, + PasswordValidationStatus.tooShort => MyLocale.of( + context, + ).validationPasswordTooShort, + PasswordValidationStatus.tooLong => MyLocale.of( + context, + ).validationPasswordTooLong, + PasswordValidationStatus.invalid => MyLocale.of( + context, + ).validationPasswordInvalid, PasswordValidationStatus.valid => '', }; } diff --git a/frontend/lib/features/otp/presentation/pages/otp_page_content.dart b/frontend/lib/features/otp/presentation/pages/otp_page_content.dart index d6dd769..961b23f 100644 --- a/frontend/lib/features/otp/presentation/pages/otp_page_content.dart +++ b/frontend/lib/features/otp/presentation/pages/otp_page_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_bottom.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_top.dart'; import 'package:cookify/features/otp/presentation/bloc/otp_bloc.dart'; @@ -59,7 +60,7 @@ class _OtpPageContentState extends State { spacing: 24.0, children: [ Text( - 'Код подтверждения', + MyLocale.of(context).otpTitle, style: const TextStyle( color: Color(0x80FFE6C9), fontSize: 20.0, @@ -112,12 +113,14 @@ class _OtpPageContentState extends State { RegExp(r'[0-9]'), ), ], - contentPadding: const EdgeInsets.all(0.0), + contentPadding: const EdgeInsets.all( + 0.0, + ), ), if (state is OtpError) Text( - 'Неверный код', + MyLocale.of(context).otpInvalidCode, style: const TextStyle( color: Color(0xFF83260E), fontSize: 14.0, @@ -143,7 +146,17 @@ class _OtpPageContentState extends State { : null, behavior: HitTestBehavior.opaque, child: Text( - 'Отправить повторно${context.read().canResendCode ? '' : ' через ${context.read().remainingSeconds} секунд'}', + MyLocale.of(context).otpResend( + context.read().canResendCode + ? '' + : MyLocale.of( + context, + ).otpResendAfter( + context + .read() + .remainingSeconds, + ), + ), style: const TextStyle( color: Color(0x80FFE6C9), fontSize: 14.0, @@ -158,7 +171,7 @@ class _OtpPageContentState extends State { onTap: () => context.go('/auth'), behavior: HitTestBehavior.opaque, child: Text( - 'Сменить аккаунт', + MyLocale.of(context).otpChangeAccount, style: const TextStyle( color: Color(0x80FFE6C9), fontSize: 14.0, diff --git a/frontend/lib/features/profile/data/local/user_statistic_local_store.dart b/frontend/lib/features/profile/data/local/user_statistic_local_store.dart new file mode 100644 index 0000000..76a12ac --- /dev/null +++ b/frontend/lib/features/profile/data/local/user_statistic_local_store.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:cookify/features/profile/domain/entities/user_statistic_entity.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +final class UserStatisticLocalStore { + UserStatisticLocalStore({required FlutterSecureStorage storage}) + : _storage = storage; + + final FlutterSecureStorage _storage; + + static const _key = 'user_statistic_delta_v1'; + + Future getDelta() async { + final raw = await _storage.read(key: _key); + if (raw == null || raw.trim().isEmpty) { + return _zero; + } + + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return _zero; + } + + return UserStatisticEntity( + favoriteRecipesCount: + (decoded['favoriteRecipesCount'] as num?)?.toInt() ?? 0, + createdRecipesCount: + (decoded['createdRecipesCount'] as num?)?.toInt() ?? 0, + publishedRecipesCount: + (decoded['publishedRecipesCount'] as num?)?.toInt() ?? 0, + ); + } + + Future incrementFavoriteRecipesCount() async { + await _increment( + (current) => current.copyWith( + favoriteRecipesCount: current.favoriteRecipesCount + 1, + ), + ); + } + + Future decrementFavoriteRecipesCount() async { + await _increment( + (current) => current.copyWith( + favoriteRecipesCount: current.favoriteRecipesCount - 1, + ), + ); + } + + Future incrementCreatedRecipesCount() async { + await _increment( + (current) => current.copyWith( + createdRecipesCount: current.createdRecipesCount + 1, + ), + ); + } + + Future incrementPublishedRecipesCount() async { + await _increment( + (current) => current.copyWith( + publishedRecipesCount: current.publishedRecipesCount + 1, + ), + ); + } + + Future _increment( + UserStatisticEntity Function(UserStatisticEntity current) update, + ) async { + final next = update(await getDelta()); + await _storage.write( + key: _key, + value: jsonEncode({ + 'favoriteRecipesCount': next.favoriteRecipesCount, + 'createdRecipesCount': next.createdRecipesCount, + 'publishedRecipesCount': next.publishedRecipesCount, + }), + ); + } + + static const _zero = UserStatisticEntity( + favoriteRecipesCount: 0, + createdRecipesCount: 0, + publishedRecipesCount: 0, + ); +} 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 e67e4a5..0edbe3e 100644 --- a/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/frontend/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -1,5 +1,6 @@ import 'package:cookify/core/data/mappers/failure_mapper.dart'; import 'package:cookify/core/domain/my_either/my_either.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'; @@ -9,19 +10,36 @@ import 'package:cookify/features/profile/domain/repositories/profile_repository. import 'package:fpdart/fpdart.dart'; final class ProfileRepositoryImpl implements ProfileRepository { - ProfileRepositoryImpl({required ProfileRemoteDataSource remoteDataSource}) - : _remoteDataSource = remoteDataSource; + ProfileRepositoryImpl({ + required ProfileRemoteDataSource remoteDataSource, + required UserStatisticLocalStore userStatisticLocalStore, + }) : _remoteDataSource = remoteDataSource, + _userStatisticLocalStore = userStatisticLocalStore; final ProfileRemoteDataSource _remoteDataSource; + final UserStatisticLocalStore _userStatisticLocalStore; @override Future> getUser() async { try { final userModel = await _remoteDataSource.getUser(); - final userEntity = UserMapper.toEntity(userModel); + final statisticDelta = await _userStatisticLocalStore.getDelta(); + final actualUserEntity = userEntity.copyWith( + statistic: userEntity.statistic.copyWith( + favoriteRecipesCount: + userEntity.statistic.favoriteRecipesCount + + statisticDelta.favoriteRecipesCount, + createdRecipesCount: + userEntity.statistic.createdRecipesCount + + statisticDelta.createdRecipesCount, + publishedRecipesCount: + userEntity.statistic.publishedRecipesCount + + statisticDelta.publishedRecipesCount, + ), + ); - return Right(userEntity); + return Right(actualUserEntity); } on Exception catch (e) { return Left(FailureMapper.toFailure(e)); } diff --git a/frontend/lib/features/profile/di/profile_di.dart b/frontend/lib/features/profile/di/profile_di.dart index 3766845..5851b5c 100644 --- a/frontend/lib/features/profile/di/profile_di.dart +++ b/frontend/lib/features/profile/di/profile_di.dart @@ -1,4 +1,5 @@ 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/data_sources/profile_remote_data_source_impl.dart'; import 'package:cookify/features/profile/data/repositories/profile_repository_impl.dart'; @@ -10,8 +11,10 @@ abstract class ProfileDi { static ProfileRemoteDataSource get _profileRemoteDataSource => ProfileRemoteDataSourceImpl(dio: Di.dio); - static ProfileRepository get _profileRepository => - ProfileRepositoryImpl(remoteDataSource: _profileRemoteDataSource); + static ProfileRepository get _profileRepository => ProfileRepositoryImpl( + remoteDataSource: _profileRemoteDataSource, + userStatisticLocalStore: UserStatisticLocalStore(storage: Di.secureStorage), + ); static GetUserUseCase get getUserUseCase => GetUserUseCase(_profileRepository); diff --git a/frontend/lib/features/profile/presentation/widgets/profile_settings.dart b/frontend/lib/features/profile/presentation/widgets/profile_settings.dart index 7a01d0d..7a3488d 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_settings.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_settings.dart @@ -15,9 +15,9 @@ class ProfileSettings extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Настройки', - style: TextStyle( + Text( + MyLocale.of(context).profileSettings, + style: const TextStyle( color: Color(0xFFFADCD2), fontSize: 14.0, fontWeight: FontWeight.bold, diff --git a/frontend/lib/features/profile/presentation/widgets/profile_settings_locale.dart b/frontend/lib/features/profile/presentation/widgets/profile_settings_locale.dart index a7423b3..538ef74 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_settings_locale.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_settings_locale.dart @@ -162,8 +162,8 @@ class _Locale extends StatelessWidget { Expanded( child: Text( switch (locale.languageCode) { - 'ru' => 'Русский', - 'en' => 'English', + 'ru' => MyLocale.of(context).commonLanguageRu, + 'en' => MyLocale.of(context).commonLanguageEn, _ => throw UnimplementedError(), }, style: TextStyle( 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 0f2f7d1..752a0ad 100644 --- a/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart +++ b/frontend/lib/features/profile/presentation/widgets/profile_user_info.dart @@ -5,6 +5,7 @@ import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/profile/domain/entities/user_entity.dart'; import 'package:cookify/features/profile/presentation/bloc/profile_bloc.dart'; 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'; @@ -56,9 +57,7 @@ class _Avatar extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () async { - final image = await ImagePicker().pickImage( - source: ImageSource.gallery, - ); + final image = await ImagePickerSheet.show(context); if (context.mounted && image != null) { context.read().add( @@ -69,16 +68,24 @@ class _Avatar extends StatelessWidget { child: Stack( children: [ Container( - alignment: Alignment.center, width: 96.0, height: 96.0, decoration: BoxDecoration( - color: Color(0xFFE5C9A8), - border: Border.all(color: Color(0xFF1E100A), width: 4.0), shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF1E100A), width: 4.0), + color: const Color(0xFFE5C9A8), ), child: CachedNetworkImage( imageUrl: avatarUrl ?? '', + imageBuilder: (context, imageProvider) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), placeholder: (context, url) => _AvatarPlaceholder(login: login), errorWidget: (context, url, error) => _AvatarPlaceholder(login: login), @@ -117,14 +124,16 @@ class _AvatarPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - login.substring(0, 1).toUpperCase(), - style: const TextStyle( - color: Color(0xFF1E100A), - fontSize: 36.0, - fontWeight: FontWeight.normal, - letterSpacing: 0.0, - height: 40.0 / 36.0, + return Center( + child: Text( + login.substring(0, 1).toUpperCase(), + style: const TextStyle( + color: Color(0xFF1E100A), + fontSize: 36.0, + fontWeight: FontWeight.normal, + letterSpacing: 0.0, + height: 40.0 / 36.0, + ), ), ); } diff --git a/frontend/lib/features/recipe/di/recipe_di.dart b/frontend/lib/features/recipe/di/recipe_di.dart index 7276d27..aa92a5c 100644 --- a/frontend/lib/features/recipe/di/recipe_di.dart +++ b/frontend/lib/features/recipe/di/recipe_di.dart @@ -1,4 +1,5 @@ import 'package:cookify/features/recipe/recipe_common/di/recipe_common_di.dart'; +import 'package:cookify/features/profile/data/local/user_statistic_local_store.dart'; import 'package:cookify/features/recipe/recipe_common/data/repositories/saved_recipe_repository_impl.dart'; import 'package:cookify/features/recipe/recipe_common/data/repositories/user_saved_recipe_detail_repository_impl.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/saved_recipe_repository.dart'; @@ -17,7 +18,10 @@ abstract class RecipeDi { static Future init() async { if (!getIt.isRegistered()) { getIt.registerSingleton( - SavedRecipeRepositoryImpl(storage: getIt()), + SavedRecipeRepositoryImpl( + storage: getIt(), + userStatisticLocalStore: UserStatisticLocalStore(storage: getIt()), + ), ); } await getIt().init(); diff --git a/frontend/lib/features/recipe/recipe_common/data/repositories/saved_recipe_repository_impl.dart b/frontend/lib/features/recipe/recipe_common/data/repositories/saved_recipe_repository_impl.dart index 46c2ba1..8551677 100644 --- a/frontend/lib/features/recipe/recipe_common/data/repositories/saved_recipe_repository_impl.dart +++ b/frontend/lib/features/recipe/recipe_common/data/repositories/saved_recipe_repository_impl.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:cookify/features/profile/data/local/user_statistic_local_store.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/saved_recipe_repository.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/user_saved_recipe_detail_repository.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; @@ -9,10 +10,14 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; class SavedRecipeRepositoryImpl implements SavedRecipeRepository { - SavedRecipeRepositoryImpl({required FlutterSecureStorage storage}) - : _storage = storage; + SavedRecipeRepositoryImpl({ + required FlutterSecureStorage storage, + required UserStatisticLocalStore userStatisticLocalStore, + }) : _storage = storage, + _userStatisticLocalStore = userStatisticLocalStore; final FlutterSecureStorage _storage; + final UserStatisticLocalStore _userStatisticLocalStore; static const _savedRecipesKey = 'saved_recipes_v1'; @@ -51,6 +56,7 @@ class SavedRecipeRepositoryImpl implements SavedRecipeRepository { Future saveRecipe(RecipePreviewEntity recipe) async { final recipes = _savedRecipesNotifier.value.toList(); final existingIndex = recipes.indexWhere((item) => item.id == recipe.id); + final isNewSavedRecipe = existingIndex == -1; if (existingIndex == -1) { recipes.add(recipe); } else { @@ -58,15 +64,22 @@ class SavedRecipeRepositoryImpl implements SavedRecipeRepository { } _savedRecipesNotifier.value = recipes; await _persist(); + if (isNewSavedRecipe) { + await _userStatisticLocalStore.incrementFavoriteRecipesCount(); + } } @override Future removeRecipe(String recipeId) async { + final isSavedBeforeRemove = isSaved(recipeId); final recipes = _savedRecipesNotifier.value .where((recipe) => recipe.id != recipeId) .toList(); _savedRecipesNotifier.value = recipes; await _persist(); + if (isSavedBeforeRemove) { + await _userStatisticLocalStore.decrementFavoriteRecipesCount(); + } if (GetIt.I.isRegistered()) { await GetIt.I().remove(recipeId); } diff --git a/frontend/lib/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart b/frontend/lib/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart index 44c335b..137aa7e 100644 --- a/frontend/lib/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart +++ b/frontend/lib/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart @@ -1,11 +1,12 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; import 'package:flutter/material.dart'; extension StyledRecipeDifficulty on RecipeDifficulty { String text(BuildContext context) => switch (this) { - RecipeDifficulty.easy => 'ЛЕГКО', - RecipeDifficulty.medium => 'СРЕДНЕ', - RecipeDifficulty.hard => 'СЛОЖНО', + RecipeDifficulty.easy => MyLocale.of(context).recipeDifficultyEasy, + RecipeDifficulty.medium => MyLocale.of(context).recipeDifficultyMedium, + RecipeDifficulty.hard => MyLocale.of(context).recipeDifficultyHard, }; Color color() => switch (this) { 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 676f0ee..8b1fd7f 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 @@ -1,7 +1,9 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_text_field.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/presentation/controllers/category_controller.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide KeyboardListener; class CategoryTextField extends StatefulWidget { const CategoryTextField({ @@ -22,6 +24,7 @@ class CategoryTextField extends StatefulWidget { } class _CategoryTextFieldState extends State { + bool _isShowKeyboard = false; final FocusNode _focusNode = FocusNode(); final TextEditingController _textController = TextEditingController(); final LayerLink _layerLink = LayerLink(); @@ -29,10 +32,20 @@ class _CategoryTextFieldState extends State { static const double _menuHeight = 80.0; + final KeyboardListener _keyboardListener = KeyboardListener(); + @override void initState() { super.initState(); _focusNode.addListener(_onFocusChange); + _keyboardListener.addListener(onChange: (bool isVisible) { + setState(() { + if (_isShowKeyboard && !isVisible) { + _hideOverlay(); + } + _isShowKeyboard = isVisible; + }); + }); } @override @@ -51,6 +64,7 @@ class _CategoryTextFieldState extends State { @override void dispose() { + _keyboardListener.dispose(); _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); _textController.dispose(); @@ -115,12 +129,12 @@ class _CategoryTextFieldState extends State { final filteredCategories = widget.categories; if (filteredCategories.isEmpty) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), child: Text( - 'Категории не найдены', - style: TextStyle(color: Color(0xFFE5C9A8)), + MyLocale.of(context).searchCategoryNotFound, + style: const TextStyle(color: Color(0xFFE5C9A8)), ), ), ); @@ -163,7 +177,7 @@ class _CategoryTextFieldState extends State { controller: _textController, focusNode: _focusNode, onChanged: widget.onChanged, - hint: 'Здоровое питание', + hint: MyLocale.of(context).searchCategoryHint, ), ), GestureDetector( 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 da9dd86..c022a54 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 @@ -1,7 +1,9 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_text_field.dart'; +import 'package:cookify/core/presentation/widgets/key_board_listener.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/ingredient_entity.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/controllers/ingredient_controller.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide KeyboardListener; class IngredientTextField extends StatefulWidget { const IngredientTextField({ @@ -22,20 +24,32 @@ class IngredientTextField extends StatefulWidget { } class _IngredientTextFieldState extends State { + bool _isShowKeyboard = false; final FocusNode _focusNode = FocusNode(); final TextEditingController _textController = TextEditingController(); final LayerLink _layerLink = LayerLink(); OverlayEntry? _overlayEntry; + int width = 0; static const double _menuHeight = 100.0; + final KeyboardListener _keyboardListener = KeyboardListener(); + @override void initState() { super.initState(); _focusNode.addListener(_onFocusChange); + _keyboardListener.addListener(onChange: (bool isVisible) { + setState(() { + if (_isShowKeyboard && !isVisible) { + _hideOverlay(); + } + _isShowKeyboard = isVisible; + }); + }); } - @override + @override void didUpdateWidget(IngredientTextField oldWidget) { super.didUpdateWidget(oldWidget); @@ -51,6 +65,7 @@ class _IngredientTextFieldState extends State { @override void dispose() { + _keyboardListener.dispose(); _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); _textController.dispose(); @@ -90,7 +105,7 @@ class _IngredientTextFieldState extends State { final Size fieldSize = renderBox.size; return Positioned( - width: fieldSize.width, + width: width.toDouble() - 40, child: CompositedTransformFollower( link: _layerLink, showWhenUnlinked: false, @@ -115,12 +130,12 @@ class _IngredientTextFieldState extends State { final filteredCategories = widget.ingredients; if (filteredCategories.isEmpty) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), child: Text( - 'Ингредиенты не найдены', - style: TextStyle(color: Color(0xFFE5C9A8)), + MyLocale.of(context).searchIngredientNotFound, + style: const TextStyle(color: Color(0xFFE5C9A8)), ), ), ); @@ -154,6 +169,7 @@ class _IngredientTextFieldState extends State { @override Widget build(BuildContext context) { + width = MediaQuery.of(context).size.width.toInt(); return CompositedTransformTarget( link: _layerLink, child: Row( @@ -163,7 +179,7 @@ class _IngredientTextFieldState extends State { controller: _textController, focusNode: _focusNode, onChanged: widget.onChanged, - hint: 'Курица', + hint: MyLocale.of(context).searchIngredientHint, ), ), GestureDetector( diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart index a73e0f5..8f60011 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/pages/recipe_detail_page_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_loading_content.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/saved_recipe_repository.dart'; import 'package:cookify/features/recipe/recipe_feed/domain/entities/recipe_preview_entity.dart'; @@ -174,7 +175,7 @@ class RecipeDetailPageContent extends StatelessWidget { ), Text( - 'Нет подключения к инернету. Подключитесь к сети и обновите страницу.', + MyLocale.of(context).commonOfflineMessage, style: TextStyle( color: Color(0xFFE5C9A8), fontSize: 16.0, @@ -202,7 +203,7 @@ class RecipeDetailPageContent extends StatelessWidget { borderRadius: BorderRadius.circular(48.0), ), child: Text( - 'Обновить', + MyLocale.of(context).commonRefresh, style: TextStyle( color: Color(0xFF2C1C16), fontSize: 16.0, @@ -215,7 +216,6 @@ class RecipeDetailPageContent extends StatelessWidget { ), ], ); - } }, listener: (context, state) {}, diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart index 827e0c9..0c4c04f 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_info_card.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/category_entity.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/cpfc_entity.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; @@ -147,7 +148,7 @@ class _CookingTime extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Время', + MyLocale.of(context).recipeDetailTime, style: const TextStyle( color: Color(0x99E5C9A8), fontSize: 15.0, @@ -158,7 +159,7 @@ class _CookingTime extends StatelessWidget { ), Text( - '$cookingTime мин', + MyLocale.of(context).commonMinutes(cookingTime), style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 16.0, @@ -185,17 +186,14 @@ class _Cpfc extends StatelessWidget { spacing: 8.0, children: [ _Cpf( - sign: 'Б', + sign: MyLocale.of(context).recipeDetailProteinSign, grams: cpfc.proteins, ), - _Cpf( - sign: 'Ж', - grams: cpfc.fats, - ), + _Cpf(sign: MyLocale.of(context).recipeDetailFatSign, grams: cpfc.fats), _Cpf( - sign: 'У', + sign: MyLocale.of(context).recipeDetailCarbsSign, grams: cpfc.carbohydrates, ), @@ -255,7 +253,7 @@ class _Cpf extends StatelessWidget { ), children: [ TextSpan( - text: 'г', + text: MyLocale.of(context).commonGramShort, style: TextStyle( fontSize: 12.0, fontWeight: FontWeight.w500, @@ -307,7 +305,7 @@ class _Calories extends StatelessWidget { ), children: [ TextSpan( - text: 'ккал', + text: MyLocale.of(context).commonKcalShort, style: TextStyle( fontSize: 12.0, fontWeight: FontWeight.w500, diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart index a6587ab..1db71f2 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_ingredients_card.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'dart:math'; import 'package:cookify/features/recipe/recipe_common/domain/entities/recipe_ingredient_entity.dart'; @@ -41,7 +42,7 @@ class _RecipeDetailIngredientsCardState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Ингридиенты', + MyLocale.of(context).recipeDetailIngredients, style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 18.0, @@ -137,7 +138,7 @@ class _ServingCount extends StatelessWidget { ), Text( - '$servingCount порций', + MyLocale.of(context).commonServingsDouble(servingCount), style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 12.0, diff --git a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_steps_card.dart b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_steps_card.dart index 1561d12..db93658 100644 --- a/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_steps_card.dart +++ b/frontend/lib/features/recipe/recipe_detail/presentation/widgets/recipe_detail_steps_card.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_network_or_file_image.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/recipe_step_entity.dart'; import 'package:flutter/material.dart'; @@ -22,7 +23,7 @@ class RecipeDetailStepsCard extends StatelessWidget { spacing: 26.0, children: [ Text( - 'Шаги', + MyLocale.of(context).recipeDetailSteps, style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 20.0, diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart index c211ae3..607434b 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/pages/recipe_feed_page_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; 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:cookify/features/recipe/recipe_feed/presentation/bloc/recipe_feed_state.dart'; @@ -109,7 +110,7 @@ class _RecipeFeedPageContentState extends State { ), Text( - 'Нет подключения к инернету. Подключитесь к сети и обновите страницу.', + MyLocale.of(context).commonOfflineMessage, style: TextStyle( color: Color(0xFFE5C9A8), fontSize: 16.0, @@ -137,7 +138,7 @@ class _RecipeFeedPageContentState extends State { borderRadius: BorderRadius.circular(48.0), ), child: Text( - 'Обновить', + MyLocale.of(context).commonRefresh, style: TextStyle( color: Color(0xFF2C1C16), fontSize: 16.0, diff --git a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart index 964422b..62146c6 100644 --- a/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart +++ b/frontend/lib/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_preview_card.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/saved_recipe_repository.dart'; import 'package:cookify/core/presentation/widgets/cookify_cached_network_image.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; @@ -94,12 +95,16 @@ class RecipeFeedRecipePreviewCard extends StatelessWidget { children: [ _Info( iconData: Icons.access_time, - text: '${recipe.cookingTime} мин', + text: MyLocale.of( + context, + ).commonMinutes(recipe.cookingTime), ), _Info( iconData: Icons.restaurant, - text: '${recipe.servingCount} порций', + text: MyLocale.of( + context, + ).commonServings(recipe.servingCount), ), ], ), diff --git a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart index 5742c43..0d04741 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_drafts_page.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_navigation_bar.dart'; import 'package:cookify/features/recipe/recipe_form/domain/entities/draft_recipe_entity.dart'; import 'package:cookify/features/recipe/recipe_form/domain/repositories/draft_recipe_repository.dart'; @@ -19,7 +20,7 @@ class RecipeDraftsPage extends StatelessWidget { Scaffold( appBar: AppBar( title: Text( - 'Черновики', + MyLocale.of(context).recipeDraftsTitle, style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 18.0, @@ -27,7 +28,6 @@ class RecipeDraftsPage extends StatelessWidget { letterSpacing: -0.72, height: 28.0 / 18.0, ), - ), actions: [ IconButton( @@ -98,7 +98,7 @@ class RecipeDraftsPage extends StatelessWidget { ), Text( - 'Здесь пока пусто. Создавайте свои собственные кулинарные шедевры', + MyLocale.of(context).recipeDraftsEmptyMessage, style: TextStyle( color: Color(0xFFE5C9A8), fontSize: 16.0, @@ -126,7 +126,7 @@ class RecipeDraftsPage extends StatelessWidget { borderRadius: BorderRadius.circular(48.0), ), child: Text( - 'Создать', + MyLocale.of(context).recipeDraftsCreate, style: TextStyle( color: Color(0xFF2C1C16), fontSize: 16.0, @@ -191,7 +191,7 @@ class _DraftTile extends StatelessWidget { @override Widget build(BuildContext context) { final title = draft.name.trim().isEmpty - ? 'Без названия' + ? MyLocale.of(context).recipeDraftsUntitled : draft.name.trim(); return Material( @@ -224,7 +224,9 @@ class _DraftTile extends StatelessWidget { ), const SizedBox(height: 6.0), Text( - 'Обновлено: ${_formatDateTime(draft.updatedAt)}', + MyLocale.of( + context, + ).recipeDraftsUpdated(_formatDateTime(draft.updatedAt)), style: const TextStyle( color: Color(0x99E5C9A8), fontSize: 12.0, @@ -241,9 +243,9 @@ class _DraftTile extends StatelessWidget { builder: (context) { return AlertDialog( backgroundColor: const Color(0xFF2C1C16), - title: const Text( - 'Удалить черновик?', - style: TextStyle(color: Color(0xFFE5C9A8)), + title: Text( + MyLocale.of(context).recipeDraftsDeleteTitle, + style: const TextStyle(color: Color(0xFFE5C9A8)), ), content: Text( title, @@ -252,15 +254,15 @@ class _DraftTile extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text( - 'Отмена', - style: TextStyle(color: Color(0xFFE5C9A8)), + child: Text( + MyLocale.of(context).commonCancel, + style: const TextStyle(color: Color(0xFFE5C9A8)), ), ), TextButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text( - 'Удалить', + child: Text( + MyLocale.of(context).commonDelete, style: TextStyle(color: Color(0xFFE5C9A8)), ), ), diff --git a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page.dart b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page.dart index c6909e7..e7c3a1c 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/pages/recipe_form_page.dart @@ -14,11 +14,7 @@ class RecipeFormPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (_) => RecipeFormDi.getIt(), - child: SafeArea( - child: Stack( - children: [RecipeFormPageContent(draftId: args.draftId)], - ), - ), + child: RecipeFormPageContent(draftId: args.draftId), ); } } 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 cf04bfb..6f79928 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 @@ -1,8 +1,10 @@ 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/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'; import 'package:cookify/features/recipe/recipe_common/domain/entities/cpfc_entity.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/recipe_ingredient_entity.dart'; @@ -25,9 +27,11 @@ import 'package:cookify/features/recipe/recipe_form/domain/repositories/draft_re import 'package:cookify/features/recipe/recipe_form/presentation/bloc/recipe_form_cubit.dart'; import 'package:cookify/features/recipe/recipe_form/presentation/bloc/recipe_form_state.dart'; import 'package:cookify/features/recipe/recipe_form/presentation/widgets/recipe_form_photo_field.dart'; +import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart'; 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:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; @@ -58,6 +62,7 @@ class _RecipeFormPageContentState extends State { String? _draftId; bool _isSavingDraft = false; bool _isSavingToSaved = false; + bool _showValidationErrors = false; @override void initState() { @@ -266,13 +271,19 @@ class _RecipeFormPageContentState extends State { setState(() => _isSavingDraft = true); try { + final isNewDraft = _draftId == null || _draftId!.trim().isEmpty; final saved = await _upsertCurrentDraft(); _draftId = saved.id; + if (isNewDraft) { + await UserStatisticLocalStore( + storage: GetIt.I(), + ).incrementCreatedRecipesCount(); + } if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Черновик сохранён'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(MyLocale.of(context).recipeFormDraftSaved)), + ); } finally { if (mounted) { setState(() => _isSavingDraft = false); @@ -371,7 +382,9 @@ class _RecipeFormPageContentState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Рецепт добавлен в сохранённые')), + SnackBar( + content: Text(MyLocale.of(context).recipeFormSavedRecipeAdded), + ), ); } finally { if (mounted) { @@ -395,22 +408,30 @@ class _RecipeFormPageContentState extends State { .where((step) => step.descriptionController.text.trim().isNotEmpty) .toList(); + final hasName = nameController.text.trim().isNotEmpty; + final hasDescription = descriptionController.text.trim().isNotEmpty; + final hasPhoto = photoController.photos.isNotEmpty; + final hasCookingTime = _toInt(cookingTimeController) > 0; + final hasCategories = validCategories.isNotEmpty; + final hasIngredients = validIngredients.isNotEmpty; + final hasSteps = validSteps.isNotEmpty; + final isValid = - nameController.text.trim().isNotEmpty && - descriptionController.text.trim().isNotEmpty && - photoController.photos.isNotEmpty && - validCategories.isNotEmpty && - validIngredients.isNotEmpty && - validSteps.isNotEmpty && - _toInt(cookingTimeController) > 0; + hasName && + hasDescription && + hasPhoto && + hasCategories && + hasIngredients && + hasSteps && + hasCookingTime; + + setState(() { + _showValidationErrors = !isValid; + }); if (!isValid) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Заполните обязательные поля: фото, название, описание, категории, ингредиенты и шаги.', - ), - ), + SnackBar(content: Text(MyLocale.of(context).recipeFormFillRequired)), ); } @@ -522,14 +543,21 @@ class _RecipeFormPageContentState extends State { await GetIt.I().remove(draftId); } if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Рецепт опубликован'))); + await UserStatisticLocalStore( + storage: GetIt.I(), + ).incrementCreatedRecipesCount(); + await UserStatisticLocalStore( + storage: GetIt.I(), + ).incrementPublishedRecipesCount(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(MyLocale.of(context).recipeFormPublished)), + ); context.go('/'); break; case Failure(): ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Не удалось опубликовать рецепт')), + SnackBar(content: Text(MyLocale.of(context).recipeFormPublishFailed)), ); break; } @@ -537,378 +565,456 @@ class _RecipeFormPageContentState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - context.pop(); - }, - icon: Icon(Icons.arrow_back, color: Color(0xFFE5C9A8)), - ), - title: const Text( - 'Создание рецепта', - style: TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 20.0, - fontWeight: FontWeight.w600, - ), + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + context.pop(); + }, + icon: Icon(Icons.arrow_back, color: Color(0xFFE5C9A8)), + ), + title: Text( + MyLocale.of(context).recipeFormTitle, + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 20.0, + fontWeight: FontWeight.w600, ), - centerTitle: true, - backgroundColor: const Color(0xFF1A0F0A), - surfaceTintColor: const Color(0xFF1A0F0A), ), + centerTitle: true, backgroundColor: const Color(0xFF1A0F0A), - body: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 50.0), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: BlocBuilder( - builder: (context, state) { - return ListView( - padding: const EdgeInsets.only(bottom: 24.0), + surfaceTintColor: const Color(0xFF1A0F0A), + ), + backgroundColor: const Color(0xFF1A0F0A), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: BlocBuilder( + builder: (context, state) { + return ListView( + children: [ + RecipeFormPhotoField(controller: photoController), + if (_showValidationErrors && + photoController.photos.isEmpty) ...[ + const SizedBox(height: 8.0), + _FieldErrorText( + message: MyLocale.of(context).recipeFormErrorPhoto, + ), + ], + const SizedBox(height: 16.0), + CookifyTextField( + controller: nameController, + label: MyLocale.of(context).recipeFormNameLabel, + hint: MyLocale.of(context).recipeFormNameHint, + maxLength: 80, + failureMessage: + _showValidationErrors && + nameController.text.trim().isEmpty + ? MyLocale.of(context).recipeFormErrorName + : null, + ), + const SizedBox(height: 12.0), + CookifyTextField( + controller: descriptionController, + label: MyLocale.of( + context, + ).recipeFormDescriptionLabel, + hint: MyLocale.of(context).recipeFormDescriptionHint, + maxLines: 3, + maxLength: 300, + failureMessage: + _showValidationErrors && + descriptionController.text.trim().isEmpty + ? MyLocale.of(context).recipeFormErrorDescription + : null, + ), + const SizedBox(height: 16.0), + _SectionLabel(MyLocale.of(context).recipeFormNutrition), + const SizedBox(height: 8.0), + Row( + children: [ + _MetricField( + label: MyLocale.of(context).recipeFormProtein, + controller: proteinsController, + ), + const SizedBox(width: 8.0), + _MetricField( + label: MyLocale.of(context).recipeFormFat, + controller: fatsController, + ), + const SizedBox(width: 8.0), + _MetricField( + label: MyLocale.of(context).recipeFormCarbs, + controller: carbsController, + ), + const SizedBox(width: 8.0), + _MetricField( + label: MyLocale.of(context).recipeFormCalories, + controller: caloriesController, + ), + ], + ), + const SizedBox(height: 16.0), + _SectionLabel( + MyLocale.of(context).recipeFormDifficulty, + ), + const SizedBox(height: 8.0), + Row( + children: [ + _DifficultyChip( + label: MyLocale.of( + context, + ).recipeFormDifficultyEasy, + isSelected: difficulty == RecipeDifficulty.easy, + onTap: () => setState( + () => difficulty = RecipeDifficulty.easy, + ), + difficulty: RecipeDifficulty.easy, + ), + const SizedBox(width: 8.0), + _DifficultyChip( + label: MyLocale.of( + context, + ).recipeFormDifficultyMedium, + isSelected: difficulty == RecipeDifficulty.medium, + onTap: () => setState( + () => difficulty = RecipeDifficulty.medium, + ), + difficulty: RecipeDifficulty.medium, + ), + const SizedBox(width: 8.0), + _DifficultyChip( + label: MyLocale.of( + context, + ).recipeFormDifficultyHard, + isSelected: difficulty == RecipeDifficulty.hard, + onTap: () => setState( + () => difficulty = RecipeDifficulty.hard, + ), + difficulty: RecipeDifficulty.hard, + ), + ], + ), + const SizedBox(height: 16.0), + CookifyTextField( + controller: cookingTimeController, + label: MyLocale.of( + context, + ).recipeFormCookingTimeLabel, + hint: MyLocale.of(context).recipeFormCookingTimeHint, + inputType: TextInputType.number, + inputFormatter: + FilteringTextInputFormatter.digitsOnly, + maxLength: 3, + failureMessage: + _showValidationErrors && + _toInt(cookingTimeController) <= 0 + ? MyLocale.of(context).recipeFormErrorCookingTime + : null, + ), + const SizedBox(height: 20.0), + _SectionLabel( + MyLocale.of(context).recipeFormCategories, + ), + const SizedBox(height: 8.0), + ...List.generate( + categoryControllers.length, + (i) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: CategoryTextField( + key: ObjectKey(categoryControllers[i]), + controller: categoryControllers[i], + categories: state.searchedCategories, + onChanged: context + .read() + .searchCategoryList, + onDelete: () { + setState(() { + categoryControllers + .removeAt(i) + .controller + .dispose(); + }); + }, + ), + ), + ), + TextButton.icon( + onPressed: () { + setState(() { + categoryControllers.add(CategoryController()); + }); + }, + icon: const Icon( + Icons.add_circle_outline, + color: Color(0xFFE5C9A8), + ), + label: Text( + MyLocale.of(context).recipeFormAddCategory, + style: const TextStyle(color: Color(0xFFE5C9A8)), + ), + ), + if (_showValidationErrors && + categoryControllers + .map((controller) => controller.category) + .whereType() + .isEmpty) ...[ + const SizedBox(height: 4.0), + _FieldErrorText( + message: MyLocale.of( + context, + ).recipeFormErrorCategories, + ), + ], + const SizedBox(height: 20.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _SectionLabel( + MyLocale.of(context).recipeFormIngredients, + ), + ], + ), + const SizedBox(height: 8.0), + ...ingredientDrafts.asMap().entries.map((entry) { + final index = entry.key; + final draft = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( children: [ - RecipeFormPhotoField(controller: photoController), - const SizedBox(height: 16.0), - CookifyTextField( - controller: nameController, - label: 'НАЗВАНИЕ РЕЦЕПТА', - hint: 'Название вашего шедевра', - maxLength: 80, - ), - const SizedBox(height: 12.0), - CookifyTextField( - controller: descriptionController, - label: 'ОПИСАНИЕ', - hint: 'Расскажите нам почему это вкусно...', - maxLines: 3, - maxLength: 300, - ), - const SizedBox(height: 16.0), - const _SectionLabel('КБЖУ'), - const SizedBox(height: 8.0), - Row( - children: [ - _MetricField( - label: 'БЕЛ', - controller: proteinsController, - ), - const SizedBox(width: 8.0), - _MetricField( - label: 'ЖИР', - controller: fatsController, - ), - const SizedBox(width: 8.0), - _MetricField( - label: 'УГЛ', - controller: carbsController, - ), - const SizedBox(width: 8.0), - _MetricField( - label: 'КАЛОРИИ', - controller: caloriesController, - ), - ], - ), - const SizedBox(height: 16.0), - const _SectionLabel('СЛОЖНОСТЬ'), - const SizedBox(height: 8.0), - Row( - children: [ - _DifficultyChip( - label: 'ЛЕГКО', - isSelected: difficulty == RecipeDifficulty.easy, - onTap: () => setState( - () => difficulty = RecipeDifficulty.easy, - ), - difficulty: RecipeDifficulty.easy, - ), - const SizedBox(width: 8.0), - _DifficultyChip( - label: 'СРЕДНЕ', - isSelected: difficulty == RecipeDifficulty.medium, - onTap: () => setState( - () => difficulty = RecipeDifficulty.medium, - ), - difficulty: RecipeDifficulty.medium, - ), - const SizedBox(width: 8.0), - _DifficultyChip( - label: 'СЛОЖНО', - isSelected: difficulty == RecipeDifficulty.hard, - onTap: () => setState( - () => difficulty = RecipeDifficulty.hard, - ), - difficulty: RecipeDifficulty.hard, - ), - ], - ), - const SizedBox(height: 16.0), - CookifyTextField( - controller: cookingTimeController, - label: 'ВРЕМЯ ПРИГОТОВЛЕНИЯ', - hint: '45 минут', - inputType: TextInputType.number, - inputFormatter: - FilteringTextInputFormatter.digitsOnly, - maxLength: 3, - ), - const SizedBox(height: 20.0), - const _SectionLabel('КАТЕГОРИИ'), - const SizedBox(height: 8.0), - ...List.generate( - categoryControllers.length, - (i) => Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: CategoryTextField( - key: ObjectKey(categoryControllers[i]), - controller: categoryControllers[i], - categories: state.searchedCategories, - onChanged: context - .read() - .searchCategoryList, - onDelete: () { - setState(() { - categoryControllers - .removeAt(i) - .controller - .dispose(); - }); - }, - ), - ), - ), - TextButton.icon( - onPressed: () { - setState(() { - categoryControllers.add(CategoryController()); - }); - }, - icon: const Icon( - Icons.add_circle_outline, - color: Color(0xFFE5C9A8), - ), - label: const Text( - 'Добавить категорию', - style: TextStyle(color: Color(0xFFE5C9A8)), - ), - ), - const SizedBox(height: 20.0), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [const _SectionLabel('ИНГРЕДИЕНТЫ')], - ), - const SizedBox(height: 8.0), - ...ingredientDrafts.asMap().entries.map((entry) { - final index = entry.key; - final draft = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Row( - children: [ - Expanded( - child: IngredientTextField( - controller: draft.controller, - ingredients: state.searchedIngredients, - onChanged: context - .read() - .searchIngredientList, - onDelete: () { - setState(() { - ingredientDrafts - .removeAt(index) - .dispose(); - }); - }, - ), - ), - const SizedBox(width: 8.0), - SizedBox( - width: 84.0, - child: _MiniTextField( - controller: draft.amountController, - hint: '100', - inputType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d{0,2}$'), - ), - LengthLimitingTextInputFormatter(6), - ], - ), - ), - const SizedBox(width: 8.0), - SizedBox( - width: 74.0, - child: _MiniTextField( - controller: draft.unitController, - hint: 'g', - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'[a-zA-Zа-яА-Я]'), - ), - LengthLimitingTextInputFormatter(10), - ], - ), - ), - ], - ), - ); - }), - TextButton.icon( - onPressed: () { - setState(() { - ingredientDrafts.add( - _IngredientDraft( - controller: IngredientController(), - ), - ); - }); - }, - icon: const Icon( - Icons.add_circle_outline, - color: Color(0xFFE5C9A8), - ), - label: const Text( - 'Добавить ингридиент', - style: TextStyle(color: Color(0xFFE5C9A8)), - ), - ), - const SizedBox(height: 20.0), - const _SectionLabel('ШАГИ ПРИГОТОВЛЕНИЯ'), - const SizedBox(height: 8.0), - ...stepDrafts.asMap().entries.map((entry) { - final index = entry.key; - final step = entry.value; - return _StepCard( - index: index + 1, - draft: step, - canDelete: stepDrafts.length > 1, - onPhotoTap: () async { - await step.pickPhoto(); - if (context.mounted) { - setState(() {}); - } - }, + Expanded( + child: IngredientTextField( + controller: draft.controller, + ingredients: state.searchedIngredients, + onChanged: context + .read() + .searchIngredientList, onDelete: () { setState(() { - stepDrafts.removeAt(index).dispose(); + ingredientDrafts + .removeAt(index) + .dispose(); }); }, - ); - }), - const SizedBox(height: 8.0), - OutlinedButton.icon( - onPressed: () { - setState(() { - stepDrafts.add(_StepDraft()); - }); - }, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFE5C9A8), - side: const BorderSide(color: Color(0x1AE5C9A8)), - minimumSize: const Size.fromHeight(52.0), - ), - icon: const Icon(Icons.add), - label: const Text('ДОБАВИТЬ ШАГ'), - ), - const SizedBox(height: 18.0), - SizedBox( - height: 52.0, - child: OutlinedButton( - onPressed: - _isSavingDraft || - _isSavingToSaved || - state.isPublishing - ? null - : _saveDraft, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFE5C9A8), - side: const BorderSide(color: Color(0x1AE5C9A8)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.0), - ), - ), - child: Text( - _isSavingDraft - ? 'Сохранение...' - : 'Сохранить черновик', - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), ), ), - const SizedBox(height: 10.0), + const SizedBox(width: 8.0), SizedBox( - height: 52.0, - child: OutlinedButton( - onPressed: - _isSavingDraft || - _isSavingToSaved || - state.isPublishing - ? null - : _saveToMyRecipes, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFE5C9A8), - side: const BorderSide(color: Color(0x1AE5C9A8)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.0), - ), - ), - child: Text( - _isSavingToSaved - ? 'Сохранение...' - : 'В сохранённые', - style: const TextStyle( - fontWeight: FontWeight.w700, + width: 84.0, + child: _MiniTextField( + controller: draft.amountController, + hint: '100', + inputType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d{0,2}$'), ), - ), + LengthLimitingTextInputFormatter(6), + ], ), ), - const SizedBox(height: 10.0), + const SizedBox(width: 8.0), SizedBox( - height: 56.0, - child: ElevatedButton( - onPressed: - state.isPublishing || - _isSavingDraft || - _isSavingToSaved - ? null - : _publish, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE5C9A8), - foregroundColor: const Color(0xFF1A0F0A), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.0), + width: 74.0, + child: _MiniTextField( + controller: draft.unitController, + hint: 'g', + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Zа-яА-Я]'), ), - ), - child: Text( - state.isPublishing - ? 'Публикация...' - : 'Опубликовать рецепт', - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), + LengthLimitingTextInputFormatter(10), + ], ), ), ], - ); + ), + ); + }), + TextButton.icon( + onPressed: () { + setState(() { + ingredientDrafts.add( + _IngredientDraft( + controller: IngredientController(), + ), + ); + }); }, + icon: const Icon( + Icons.add_circle_outline, + color: Color(0xFFE5C9A8), + ), + label: Text( + MyLocale.of(context).recipeFormAddIngredient, + style: const TextStyle(color: Color(0xFFE5C9A8)), + ), ), - ), - ), - - Positioned( - left: 0, - right: 0, - bottom: 0, - child: CookifyNavigationBar(index: 2), - ), - ], + if (_showValidationErrors && + ingredientDrafts + .where( + (draft) => + draft.controller.ingredient != null, + ) + .where( + (draft) => draft.amountController.text + .trim() + .isNotEmpty, + ) + .where( + (draft) => draft.unitController.text + .trim() + .isNotEmpty, + ) + .isEmpty) ...[ + const SizedBox(height: 4.0), + _FieldErrorText( + message: MyLocale.of( + context, + ).recipeFormErrorIngredients, + ), + ], + const SizedBox(height: 20.0), + _SectionLabel(MyLocale.of(context).recipeFormSteps), + const SizedBox(height: 8.0), + ...stepDrafts.asMap().entries.map((entry) { + final index = entry.key; + final step = entry.value; + return _StepCard( + index: index + 1, + draft: step, + canDelete: stepDrafts.length > 1, + onPhotoTap: () async { + await step.pickPhoto(context); + if (context.mounted) { + setState(() {}); + } + }, + onDelete: () { + setState(() { + stepDrafts.removeAt(index).dispose(); + }); + }, + ); + }), + const SizedBox(height: 8.0), + OutlinedButton.icon( + onPressed: () { + setState(() { + stepDrafts.add(_StepDraft()); + }); + }, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE5C9A8), + side: const BorderSide(color: Color(0x1AE5C9A8)), + minimumSize: const Size.fromHeight(52.0), + ), + icon: const Icon(Icons.add), + label: Text(MyLocale.of(context).recipeFormAddStep), + ), + if (_showValidationErrors && + stepDrafts + .where( + (step) => step.titleController.text + .trim() + .isNotEmpty, + ) + .where( + (step) => step.descriptionController.text + .trim() + .isNotEmpty, + ) + .isEmpty) ...[ + const SizedBox(height: 4.0), + _FieldErrorText( + message: MyLocale.of(context).recipeFormErrorSteps, + ), + ], + const SizedBox(height: 18.0), + SizedBox( + height: 52.0, + child: OutlinedButton( + onPressed: + _isSavingDraft || + _isSavingToSaved || + state.isPublishing + ? null + : _saveDraft, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE5C9A8), + side: const BorderSide(color: Color(0x1AE5C9A8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + ), + child: Text( + _isSavingDraft + ? MyLocale.of(context).recipeFormSaving + : MyLocale.of(context).recipeFormSaveDraft, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 10.0), + SizedBox( + height: 52.0, + child: OutlinedButton( + onPressed: + _isSavingDraft || + _isSavingToSaved || + state.isPublishing + ? null + : _saveToMyRecipes, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE5C9A8), + side: const BorderSide(color: Color(0x1AE5C9A8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + ), + child: Text( + _isSavingToSaved + ? MyLocale.of(context).recipeFormSaving + : MyLocale.of(context).recipeFormSaveToSaved, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 10.0), + SizedBox( + height: 56.0, + child: ElevatedButton( + onPressed: + state.isPublishing || + _isSavingDraft || + _isSavingToSaved + ? null + : _publish, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE5C9A8), + foregroundColor: const Color(0xFF1A0F0A), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + ), + child: Text( + state.isPublishing + ? MyLocale.of(context).recipeFormPublishing + : MyLocale.of(context).recipeFormPublish, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 10.0), + ], + ); + }, ), ), ); @@ -934,6 +1040,34 @@ class _SectionLabel extends StatelessWidget { } } +class _FieldErrorText extends StatelessWidget { + const _FieldErrorText({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Row( + spacing: 8.35, + children: [ + const Icon(Icons.error, color: Color(0xFF83260E), size: 11.67), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Color(0xFF83260E), + fontSize: 11.0, + fontWeight: FontWeight.bold, + letterSpacing: 0.0, + height: 16.5 / 11.0, + ), + ), + ), + ], + ); + } +} + class _MetricField extends StatelessWidget { const _MetricField({required this.label, required this.controller}); @@ -1122,7 +1256,7 @@ class _StepCard extends StatelessWidget { const SizedBox(height: 8.0), _MiniTextField( controller: draft.titleController, - hint: 'Заголовок шага', + hint: MyLocale.of(context).recipeFormStepTitleHint, inputFormatters: [LengthLimitingTextInputFormatter(80)], ), const SizedBox(height: 8.0), @@ -1132,7 +1266,7 @@ class _StepCard extends StatelessWidget { maxLength: 260, style: const TextStyle(color: Color(0xFFFFE6C9), fontSize: 13.0), decoration: InputDecoration( - hintText: 'Описание шага', + hintText: MyLocale.of(context).recipeFormStepDescriptionHint, hintStyle: const TextStyle( color: Color(0x4DE5C9A8), fontSize: 13.0, @@ -1161,18 +1295,30 @@ class _StepCard extends StatelessWidget { GestureDetector( onTap: onPhotoTap, child: Container( + width: double.infinity, height: 120.0, decoration: BoxDecoration( color: const Color(0x1AE5C9A8), borderRadius: BorderRadius.circular(10.0), ), child: draft.photo == null - ? const Center( - child: Text( - 'Добавить фото', - style: TextStyle(color: Color(0x99E5C9A8)), + ? 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, + ), ), - ) + ], + ) : ClipRRect( borderRadius: BorderRadius.circular(10.0), child: Image.file( @@ -1214,8 +1360,8 @@ class _StepDraft { final ImagePicker picker = ImagePicker(); XFile? photo; - Future pickPhoto() async { - final picked = await picker.pickImage(source: ImageSource.gallery); + Future pickPhoto(BuildContext context) async { + final picked = await ImagePickerSheet.show(context); if (picked != null) { photo = picked; } diff --git a/frontend/lib/features/recipe/recipe_form/presentation/widgets/recipe_form_photo_field.dart b/frontend/lib/features/recipe/recipe_form/presentation/widgets/recipe_form_photo_field.dart index b46cfc7..2b99ba4 100644 --- a/frontend/lib/features/recipe/recipe_form/presentation/widgets/recipe_form_photo_field.dart +++ b/frontend/lib/features/recipe/recipe_form/presentation/widgets/recipe_form_photo_field.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/controllers/photo_controller.dart'; +import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_form_page_content.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -25,9 +27,7 @@ class _RecipeFormPhotoFieldState extends State { return GestureDetector( onTap: () async { - final selected = await picker.pickImage( - source: ImageSource.gallery, - ); + final selected = await ImagePickerSheet.show(context); if (selected != null) { widget.controller.add(selected); } @@ -40,17 +40,17 @@ class _RecipeFormPhotoFieldState extends State { border: Border.all(color: const Color(0x1AE5C9A8)), ), child: photos.isEmpty - ? const Column( + ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.add_a_photo_outlined, color: Color(0x99E5C9A8), ), - SizedBox(height: 8.0), + const SizedBox(height: 8.0), Text( - 'Добавить фото', - style: TextStyle( + MyLocale.of(context).recipeFormAddPhoto, + style: const TextStyle( color: Color(0xB3E5C9A8), fontWeight: FontWeight.w600, ), diff --git a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart index 7c5e50e..7945efb 100644 --- a/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart +++ b/frontend/lib/features/recipe/recipe_saved/presentation/pages/recipe_saved_page.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_navigation_bar.dart'; import 'package:cookify/features/recipe/recipe_common/domain/repositories/saved_recipe_repository.dart'; import 'package:cookify/features/recipe/recipe_feed/domain/entities/recipe_preview_entity.dart'; @@ -30,8 +31,8 @@ class _RecipeSavedPageState extends State { children: [ Scaffold( appBar: AppBar( - title: const Text( - 'Моя кухня', + title: Text( + MyLocale.of(context).recipeSavedTitle, style: const TextStyle( color: Color(0xFFE5C9A8), fontSize: 18.0, @@ -99,7 +100,7 @@ class _RecipeSavedPageState extends State { ), Text( - 'Здесь пока пусто. Сохраняйте понравившиеся рецепты из ленты, чтобы не потерять их', + MyLocale.of(context).recipeSavedEmptyMessage, style: TextStyle( color: Color(0xFFE5C9A8), fontSize: 16.0, @@ -127,7 +128,7 @@ class _RecipeSavedPageState extends State { borderRadius: BorderRadius.circular(48.0), ), child: Text( - 'Найти', + MyLocale.of(context).recipeSavedFind, style: TextStyle( color: Color(0xFF2C1C16), fontSize: 16.0, diff --git a/frontend/lib/features/recipe/recipe_saved/presentation/widgets/recipe_saved_preview_card.dart b/frontend/lib/features/recipe/recipe_saved/presentation/widgets/recipe_saved_preview_card.dart index 72b282a..9c02cba 100644 --- a/frontend/lib/features/recipe/recipe_saved/presentation/widgets/recipe_saved_preview_card.dart +++ b/frontend/lib/features/recipe/recipe_saved/presentation/widgets/recipe_saved_preview_card.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_network_or_file_image.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart'; @@ -84,7 +85,9 @@ class RecipeSavedPreviewCard extends StatelessWidget { children: [ _Info( iconData: Icons.access_time, - text: '${recipe.cookingTime} мин', + text: MyLocale.of( + context, + ).commonMinutes(recipe.cookingTime), ), ], ), @@ -153,4 +156,4 @@ class _Difficulty extends StatelessWidget { ), ); } -} \ No newline at end of file +} 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 f35e77e..abc51ba 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,13 +1,19 @@ +import 'dart:io'; + +import 'package:cookify/core/l10n/my_locale.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'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; +import 'package:cookify/features/recipe/recipe_common/presentation/extensions/styled_recipe_difficulty.dart'; import 'package:cookify/features/recipe/recipe_search/domain/payloads/search_recipe_list_payload.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_form_cubit.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_form_state.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_search_page_args.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide KeyboardListener; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; // --- Константы стиля --- class AppColors { @@ -24,10 +30,16 @@ class RecipeSearchFormPageContent extends StatefulWidget { const RecipeSearchFormPageContent({super.key}); @override - State createState() => _RecipeSearchFormPageContentState(); + State createState() => + _RecipeSearchFormPageContentState(); } -class _RecipeSearchFormPageContentState extends State { +class _RecipeSearchFormPageContentState + extends State { +bool _isShowKeyboard = false; + + final KeyboardListener _keyboardListener = KeyboardListener(); + // Links & Overlays final LayerLink _categoryLink = LayerLink(); final LayerLink _ingredientLink = LayerLink(); @@ -39,13 +51,13 @@ class _RecipeSearchFormPageContentState extends State difficulties = []; + final List difficulties = RecipeDifficulty.values; int cookingTime = 45; int minCalories = 0, maxCalories = 4000; int minProteins = 0, maxProteins = 100; int minFats = 0, maxFats = 100; int minCarbs = 0, maxCarbs = 100; - + final List selectedCategories = []; final List selectedIngredients = []; @@ -53,15 +65,21 @@ class _RecipeSearchFormPageContentState extends State setState(() => difficulties.contains(d) ? difficulties.remove(d) : difficulties.add(d)), + onChanged: (d) => setState( + () => difficulties.contains(d) + ? difficulties.remove(d) + : difficulties.add(d), + ), ), ), @@ -126,11 +146,14 @@ class _RecipeSearchFormPageContentState extends State setState(() { minCalories = v1; maxCalories = v2; })), + _DoubleSlider( + label: MyLocale.of(context).searchCaloriesLabel, + unit: MyLocale.of(context).commonKcalShort, + min: 0, + max: 4000, + minValue: minCalories, + maxValue: maxCalories, + onValueChanged: (v1, v2) => setState(() { + minCalories = v1; + maxCalories = v2; + }), + ), const SizedBox(height: 12), - _DoubleSlider(label: 'БЕЛКИ', unit: 'г', min: 0, max: 100, minValue: minProteins, maxValue: maxProteins, onValueChanged: (v1, v2) => setState(() { minProteins = v1; maxProteins = v2; })), + _DoubleSlider( + label: MyLocale.of(context).searchProteinsLabel, + unit: MyLocale.of(context).commonGramShort, + min: 0, + max: 100, + minValue: minProteins, + maxValue: maxProteins, + onValueChanged: (v1, v2) => setState(() { + minProteins = v1; + maxProteins = v2; + }), + ), const SizedBox(height: 12), - _DoubleSlider(label: 'ЖИРЫ', unit: 'г', min: 0, max: 100, minValue: minFats, maxValue: maxFats, onValueChanged: (v1, v2) => setState(() { minFats = v1; maxFats = v2; })), + _DoubleSlider( + label: MyLocale.of(context).searchFatsLabel, + unit: MyLocale.of(context).commonGramShort, + min: 0, + max: 100, + minValue: minFats, + maxValue: maxFats, + onValueChanged: (v1, v2) => setState(() { + minFats = v1; + maxFats = v2; + }), + ), const SizedBox(height: 12), - _DoubleSlider(label: 'УГЛЕВОДЫ', unit: 'г', min: 0, max: 100, minValue: minCarbs, maxValue: maxCarbs, onValueChanged: (v1, v2) => setState(() { minCarbs = v1; maxCarbs = v2; })), + _DoubleSlider( + label: MyLocale.of(context).searchCarbsLabel, + unit: MyLocale.of(context).commonGramShort, + min: 0, + max: 100, + minValue: minCarbs, + maxValue: maxCarbs, + onValueChanged: (v1, v2) => setState(() { + minCarbs = v1; + maxCarbs = v2; + }), + ), ], ), ), @@ -163,12 +237,13 @@ class _RecipeSearchFormPageContentState extends State context.read().searchCategoryList(val), + onSearch: (val) => + context.read().searchCategoryList(val), onRemove: (item) => setState(() => selectedCategories.remove(item)), ), @@ -176,21 +251,21 @@ class _RecipeSearchFormPageContentState extends State context.read().searchIngredientList(val), - onRemove: (item) => setState(() => selectedIngredients.remove(item)), + onSearch: (val) => + context.read().searchIngredientList(val), + onRemove: (item) => + setState(() => selectedIngredients.remove(item)), ), const SliverToBoxAdapter(child: SizedBox(height: 28)), // Кнопка поиска - SliverToBoxAdapter( - child: _SearchButton(onPressed: _submitForm), - ), + SliverToBoxAdapter(child: _SearchButton(onPressed: _submitForm)), const SliverToBoxAdapter(child: SizedBox(height: 38)), ], @@ -205,7 +280,14 @@ class _RecipeSearchFormPageContentState extends State _SelectionChip(label: e.name, onRemove: () => onRemove(e))).toList(), + spacing: 8, + runSpacing: 8, + children: items + .map( + (e) => _SelectionChip( + label: e.name, + onRemove: () => onRemove(e), + ), + ) + .toList(), ), ], ], @@ -248,32 +345,50 @@ class _RecipeSearchFormPageContentState extends State(); - + _activeOverlay = OverlayEntry( builder: (context) => Positioned( width: MediaQuery.of(context).size.width - 48, child: CompositedTransformFollower( link: link, offset: const Offset(0, 52), - child: Material( // Чтобы работал GestureDetector и стили текста + child: Material( + // Чтобы работал GestureDetector и стили текста color: Colors.transparent, child: BlocProvider.value( value: cubit, child: BlocBuilder( builder: (context, state) { - final list = isCategory ? state.categories : state.ingredients; + final list = isCategory + ? state.categories + : state.ingredients; return Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withOpacity(0.05)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.05), + ), ), - child: list.isEmpty - ? const Padding(padding: EdgeInsets.all(16), child: Text('Ничего не найдено', textAlign: TextAlign.center, style: TextStyle(color: AppColors.textPrimary))) - : Column( - mainAxisSize: MainAxisSize.min, - children: list.map((item) => _buildOverlayItem(item, isCategory)).toList(), - ), + child: list.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + MyLocale.of(context).searchNothingFound, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + ), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: list + .map( + (item) => _buildOverlayItem(item, isCategory), + ) + .toList(), + ), ); }, ), @@ -286,25 +401,39 @@ class _RecipeSearchFormPageContentState extends State e.id == item.id) - : selectedIngredients.any((e) => e.id == item.id); + final isSelected = isCategory + ? selectedCategories.any((e) => e.id == item.id) + : selectedIngredients.any((e) => e.id == item.id); return InkWell( onTap: () => setState(() { if (isCategory) { - isSelected ? selectedCategories.removeWhere((e) => e.id == item.id) : selectedCategories.add(item); + isSelected + ? selectedCategories.removeWhere((e) => e.id == item.id) + : selectedCategories.add(item); } else { - isSelected ? selectedIngredients.removeWhere((e) => e.id == item.id) : selectedIngredients.add(item); + isSelected + ? selectedIngredients.removeWhere((e) => e.id == item.id) + : selectedIngredients.add(item); } }), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Text(item.name, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14)), + Text( + item.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + ), const Spacer(), - Icon(isSelected ? Icons.check_box : Icons.check_box_outline_blank, color: AppColors.accent, size: 20), + Icon( + isSelected ? Icons.check_box : Icons.check_box_outline_blank, + color: AppColors.accent, + size: 20, + ), ], ), ), @@ -312,7 +441,9 @@ class _RecipeSearchFormPageContentState extends State onChanged(d), child: Container( - width: 100, height: 32, + width: 100, + height: 32, alignment: Alignment.center, decoration: BoxDecoration( color: isSel ? bgColor : AppColors.chipBg, borderRadius: BorderRadius.circular(20), ), - child: Text(d.name.toUpperCase(), style: TextStyle( - color: isSel ? (d == RecipeDifficulty.hard ? Colors.white : Colors.black) : AppColors.buttonBg, - fontSize: 12, fontWeight: FontWeight.bold, - )), + child: Text( + d.text(context), + style: TextStyle( + color: isSel + ? (d == RecipeDifficulty.hard ? Colors.white : Colors.black) + : AppColors.buttonBg, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), ), ); }).toList(), @@ -432,7 +611,11 @@ class _SliderHeader extends StatelessWidget { final String valueText; final Widget child; - const _SliderHeader({required this.title, required this.valueText, required this.child}); + const _SliderHeader({ + required this.title, + required this.valueText, + required this.child, + }); @override Widget build(BuildContext context) { @@ -440,11 +623,31 @@ class _SliderHeader extends StatelessWidget { children: [ Row( children: [ - Expanded(child: Text(title, style: const TextStyle(color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w500))), + Expanded( + child: Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: const Color(0xFF37261F), borderRadius: BorderRadius.circular(4)), - child: Text(valueText, style: const TextStyle(color: AppColors.buttonBg, fontSize: 12, fontWeight: FontWeight.bold, fontStyle: FontStyle.italic)), + decoration: BoxDecoration( + color: const Color(0xFF37261F), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + valueText, + style: const TextStyle( + color: AppColors.buttonBg, + fontSize: 12, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ), + ), ), ], ), @@ -459,7 +662,15 @@ class _DoubleSlider extends StatelessWidget { final int min, max, minValue, maxValue; final Function(int, int) onValueChanged; - const _DoubleSlider({required this.label, required this.unit, required this.min, required this.max, required this.minValue, required this.maxValue, required this.onValueChanged}); + const _DoubleSlider({ + required this.label, + required this.unit, + required this.min, + required this.max, + required this.minValue, + required this.maxValue, + required this.onValueChanged, + }); @override Widget build(BuildContext context) { @@ -468,15 +679,34 @@ class _DoubleSlider extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(color: AppColors.textPrimary, fontSize: 10, letterSpacing: 1)), - Text('$minValue - $maxValue $unit', style: const TextStyle(color: AppColors.textPrimary, fontSize: 10)), + Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 10, + letterSpacing: 1, + ), + ), + Text( + '$minValue - $maxValue $unit', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 10, + ), + ), ], ), SliderTheme( - data: const SliderThemeData(thumbColor: AppColors.buttonBg, activeTrackColor: Color(0xFF615043), inactiveTrackColor: Color(0xFF615043), rangeThumbShape: RoundRangeSliderThumbShape(enabledThumbRadius: 8)), + data: const SliderThemeData( + thumbColor: AppColors.buttonBg, + activeTrackColor: Color(0xFF615043), + inactiveTrackColor: Color(0xFF615043), + rangeThumbShape: RoundRangeSliderThumbShape(enabledThumbRadius: 8), + ), child: RangeSlider( values: RangeValues(minValue.toDouble(), maxValue.toDouble()), - min: min.toDouble(), max: max.toDouble(), + min: min.toDouble(), + max: max.toDouble(), onChanged: (v) => onValueChanged(v.start.toInt(), v.end.toInt()), ), ), @@ -498,7 +728,154 @@ class _SearchButton extends StatelessWidget { minimumSize: const Size(double.infinity, 48), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - child: const Text('ПОИСК', style: TextStyle(color: Color(0xFF3E2D16), fontWeight: FontWeight.w800, letterSpacing: 1.6)), + child: Text( + MyLocale.of(context).searchButton, + style: const TextStyle( + color: Color(0xFF3E2D16), + fontWeight: FontWeight.w800, + letterSpacing: 1.6, + ), + ), + ); + } +} + +class ImagePickerSheet extends StatelessWidget { + const ImagePickerSheet({super.key}); + + /// Метод для вызова BottomSheet + static Future show(BuildContext context) async { + return await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, // Для кастомного скругления + isScrollControlled: true, + builder: (context) => const ImagePickerSheet(), + ); + } + + Future _pickImage(BuildContext context, ImageSource source) async { + final picker = ImagePicker(); + try { + final XFile? pickedFile = await picker.pickImage( + source: source, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + + if (context.mounted) { + if (pickedFile != null) { + Navigator.pop(context, pickedFile); + } else { + Navigator.pop(context, null); + } + } + } catch (e) { + debugPrint("Error picking image: $e"); + if (context.mounted) Navigator.pop(context, null); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Декоративный индикатор (Handle) + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.chipBg, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + + Text( + "Выберите источник", // Можно заменить на MyLocale.of(context)... + style: TextStyle( + color: AppColors.accent, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 24), + + _PickerOption( + icon: Icons.camera_alt_rounded, + label: "Камера", + onTap: () => _pickImage(context, ImageSource.camera), + ), + + const SizedBox(height: 12), + + _PickerOption( + icon: Icons.photo_library_rounded, + label: "Галерея", + onTap: () => _pickImage(context, ImageSource.gallery), + ), + + const SizedBox(height: 32), // Отступ снизу (safe area) + ], + ), + ); + } +} + +class _PickerOption extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _PickerOption({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.05), + ), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.accent, size: 24), + const SizedBox(width: 16), + Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + const Icon( + Icons.arrow_forward_ios_rounded, + color: AppColors.chipBg, + size: 16, + ), + ], + ), + ), ); } } \ No newline at end of file diff --git a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart index 1a879be..56d6a63 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_loading_content.dart'; import 'package:cookify/features/recipe/recipe_feed/presentation/widgets/recipe_feed_recipe_list.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/bloc/recipe_search_cubit.dart'; @@ -34,15 +35,15 @@ class _RecipeSearchPageContentState extends State { icon: Icon(Icons.arrow_back, color: const Color(0xFFE5C9A8)), ), title: Text( - 'Поиск', - style: const TextStyle( - color: Color(0xFFE5C9A8), - fontSize: 18.0, - fontWeight: FontWeight.bold, - letterSpacing: -0.72, - height: 28.0 / 18.0, - ), - ), + MyLocale.of(context).searchTitle, + style: const TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 18.0, + fontWeight: FontWeight.bold, + letterSpacing: -0.72, + height: 28.0 / 18.0, + ), + ), centerTitle: true, backgroundColor: Color(0xFF1A0F0A), surfaceTintColor: Color(0xFF1A0F0A), @@ -70,7 +71,7 @@ class _RecipeSearchPageContentState extends State { }, ); case RecipeSearchError(): - return const Text('Error'); + return Text(MyLocale.of(context).commonError); } }, listener: (context, state) {}, diff --git a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_category_section.dart b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_category_section.dart index 0c83d66..17c8eaa 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_category_section.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_category_section.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/category_entity.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/controllers/category_controller.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/widgets/category_text_field.dart'; @@ -26,7 +27,7 @@ class _RecipeSearchCategorySectionState @override Widget build(BuildContext context) { return RecipeSearchSectionCard( - title: 'Категории', + title: MyLocale.of(context).searchCategoriesTitle, child: Column( spacing: 8.0, children: [ @@ -53,8 +54,8 @@ class _RecipeSearchCategorySectionState children: [ Icon(Icons.add, color: const Color(0xFFE5C9A8), size: 24.0), - const Text( - 'Добавить категорию', + Text( + MyLocale.of(context).searchAddCategory, style: TextStyle(color: Color(0xFFE5C9A8)), ), ], diff --git a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_general_section.dart b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_general_section.dart index df5493f..f6b2cc1 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_general_section.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_general_section.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/cookify_text_field.dart'; import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; import 'package:cookify/features/recipe/recipe_search/presentation/widgets/recipe_search_difficulty_filter.dart'; @@ -34,7 +35,7 @@ class RecipeSearchGeneralSection extends StatelessWidget { @override Widget build(BuildContext context) { return RecipeSearchSectionCard( - title: 'Общее', + title: MyLocale.of(context).searchGeneralTitle, child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8.0, @@ -46,29 +47,29 @@ class RecipeSearchGeneralSection extends StatelessWidget { inputFormatter: FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), maxLength: 5, onChanged: (_) {}, - hint: 'Максимальное время приготовления', + hint: MyLocale.of(context).searchMaxCookingTimeHint, ), _MinMaxTextField( - title: 'Калории', + title: MyLocale.of(context).searchCaloriesTitle, minController: minCaloriesController, maxController: maxCaloriesController, ), _MinMaxTextField( - title: 'Белки', + title: MyLocale.of(context).searchProteinsTitle, minController: minProteinsController, maxController: maxProteinsController, ), _MinMaxTextField( - title: 'Жиры', + title: MyLocale.of(context).searchFatsTitle, minController: minFatsController, maxController: maxFatsController, ), _MinMaxTextField( - title: 'Углеводы', + title: MyLocale.of(context).searchCarbsTitle, minController: minCarbohydratesController, maxController: maxCarbohydratesController, ), @@ -116,7 +117,7 @@ class _MinMaxTextField extends StatelessWidget { RegExp(r'[0-9]'), ), maxLength: 5, - hint: 'Мин', + hint: MyLocale.of(context).searchMinHint, ), ), @@ -127,7 +128,7 @@ class _MinMaxTextField extends StatelessWidget { RegExp(r'[0-9]'), ), maxLength: 5, - hint: 'Макс', + hint: MyLocale.of(context).searchMaxHint, ), ), ], diff --git a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_ingredient_section.dart b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_ingredient_section.dart index e640fa8..e19524b 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_ingredient_section.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/widgets/recipe_search_ingredient_section.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/recipe/recipe_common/domain/entities/ingredient_entity.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/controllers/ingredient_controller.dart'; import 'package:cookify/features/recipe/recipe_common/presentation/widgets/ingredient_text_field.dart'; @@ -26,7 +27,7 @@ class _RecipeSearchIngredientSectionState @override Widget build(BuildContext context) { return RecipeSearchSectionCard( - title: 'Ингредиенты', + title: MyLocale.of(context).searchIngredientsTitle, child: Column( spacing: 8.0, children: [ @@ -37,7 +38,9 @@ class _RecipeSearchIngredientSectionState controller: widget.controllers[i], ingredients: widget.ingredients, onChanged: (name) { - context.read().searchIngredientList(name); + context.read().searchIngredientList( + name, + ); }, onDelete: () => setState(() { widget.controllers.removeAt(i); @@ -53,8 +56,8 @@ class _RecipeSearchIngredientSectionState children: [ Icon(Icons.add, color: const Color(0xFFE5C9A8), size: 24.0), - const Text( - 'Добавить ингредиент', + Text( + MyLocale.of(context).searchAddIngredient, style: TextStyle(color: Color(0xFFE5C9A8)), ), ], diff --git a/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart b/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart index 38f4d96..72de149 100644 --- a/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart +++ b/frontend/lib/features/restore/presentation/pages/restore_widget_content.dart @@ -1,4 +1,5 @@ import 'package:cookify/core/presentation/widgets/app.dart'; +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_service_button.dart'; @@ -49,19 +50,21 @@ class _RestoreWidgetContentState extends State { onChanged: (value) => context.read().add( ValidateLogin(login: value), ), - label: 'ЛОГИН ИЛИ EMAIL', - hint: 'Введите логин или email', + label: MyLocale.of(context).authRestoreLoginOrEmailLabel, + hint: MyLocale.of(context).authRestoreLoginOrEmailHint, isPassword: false, failureMessage: state.login.localizeError?.call(context) ?? - (state.hasError ? 'Неверный логин или email' : null), + (state.hasError + ? MyLocale.of(context).authRestoreWrongLoginOrEmail + : null), ), AuthButton( onPressed: () { context.read().add(Restore()); }, - title: 'Восстановить', + title: MyLocale.of(context).authRestoreButton, isLoading: state.isLoading, ), ], diff --git a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart index 020a80a..9426a43 100644 --- a/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart +++ b/frontend/lib/features/sign_in/presentation/pages/sign_in_widget_content.dart @@ -1,5 +1,5 @@ import 'package:cookify/core/presentation/widgets/app.dart'; -import 'package:cookify/core/presentation/widgets/app_toast.dart'; +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_service_button.dart'; @@ -10,7 +10,6 @@ import 'package:cookify/features/sign_in/presentation/bloc/sign_in_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; -import 'package:google_sign_in/google_sign_in.dart'; class SignInWidgetContent extends StatefulWidget { const SignInWidgetContent({super.key}); @@ -23,7 +22,7 @@ class _SignInWidgetContentState extends State { final loginController = TextEditingController(); final passwordController = TextEditingController(); - @override + @override void initState() { super.initState(); fToast = FToast(); @@ -53,12 +52,14 @@ class _SignInWidgetContentState extends State { onChanged: (value) => context.read().add( ValidateLogin(login: value), ), - label: 'ЛОГИН', - hint: 'Введите логин', + label: MyLocale.of(context).authLoginLabel, + hint: MyLocale.of(context).authLoginHint, isPassword: false, failureMessage: state.login.localizeError?.call(context) ?? - (state.hasError ? 'Неправильный логин' : null), + (state.hasError + ? MyLocale.of(context).authSignInWrongLogin + : null), ), AuthTextField( @@ -69,19 +70,21 @@ class _SignInWidgetContentState extends State { ); }, inputType: TextInputType.visiblePassword, - label: 'ПАРОЛЬ', - hint: 'Введите пароль', + label: MyLocale.of(context).authPasswordLabel, + hint: MyLocale.of(context).authPasswordHint, isPassword: true, failureMessage: state.password.localizeError?.call(context) ?? - (state.hasError ? 'Неправильный пароль' : null), + (state.hasError + ? MyLocale.of(context).authSignInWrongPassword + : null), ), AuthButton( onPressed: () { context.read().add(SignIn()); }, - title: 'Войти', + title: MyLocale.of(context).authSignInButton, isLoading: state.isLoading, ), ], @@ -105,9 +108,7 @@ class _SignInWidgetContentState extends State { ], ); }, - listener: (context, state) { - - }, + listener: (context, state) {}, ); } } diff --git a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart index fb48a7c..a941d7d 100644 --- a/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart +++ b/frontend/lib/features/sign_up/presentation/pages/sign_up_widget_content.dart @@ -1,3 +1,4 @@ +import 'package:cookify/core/l10n/my_locale.dart'; import 'package:cookify/core/presentation/widgets/app.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_button.dart'; import 'package:cookify/features/auth/auth_common/presentation/widgets/auth_divider.dart'; @@ -24,7 +25,7 @@ class _SignUpWidgetContentState extends State { final passwordController = TextEditingController(); final confirmPasswordController = TextEditingController(); - @override + @override void initState() { super.initState(); fToast = FToast(); @@ -56,14 +57,14 @@ class _SignUpWidgetContentState extends State { onChanged: (value) => context.read().add( ValidateLogin(login: value), ), - label: 'ЛОГИН', - hint: 'Введите логин', + label: MyLocale.of(context).authLoginLabel, + hint: MyLocale.of(context).authLoginHint, isPassword: false, failureMessage: state.login.localizeError?.call(context) ?? (state.failure != null && state.failure is LoginAlreadyExistsFailure - ? 'Логин занят' + ? MyLocale.of(context).authSignUpLoginTaken : null), ), @@ -74,12 +75,13 @@ class _SignUpWidgetContentState extends State { ), inputType: TextInputType.emailAddress, label: 'EMAIL', - hint: 'Введите email', + hint: MyLocale.of(context).authEmailHint, isPassword: false, - failureMessage: state.email.localizeError?.call(context) ?? + failureMessage: + state.email.localizeError?.call(context) ?? (state.failure != null && state.failure is EmailAlreadyExistsFailure - ? 'Почта занята' + ? MyLocale.of(context).authSignUpEmailTaken : null), ), @@ -96,8 +98,8 @@ class _SignUpWidgetContentState extends State { ); }, inputType: TextInputType.visiblePassword, - label: 'ПАРОЛЬ', - hint: 'Введите пароль', + label: MyLocale.of(context).authPasswordLabel, + hint: MyLocale.of(context).authPasswordHint, isPassword: true, failureMessage: state.password.localizeError?.call(context), ), @@ -108,8 +110,8 @@ class _SignUpWidgetContentState extends State { ValidateConfirmPassword(confirmPassword: value), ), inputType: TextInputType.visiblePassword, - label: 'ПОВТОРИТЕ ПАРОЛЬ', - hint: 'Введите пароль повторно', + label: MyLocale.of(context).authConfirmPasswordLabel, + hint: MyLocale.of(context).authConfirmPasswordHint, isPassword: true, failureMessage: state.confirmPassword.localizeError?.call( context, @@ -120,7 +122,7 @@ class _SignUpWidgetContentState extends State { onPressed: () { context.read().add(SignUp()); }, - title: 'Зарегистрироваться', + title: MyLocale.of(context).authSignUpButton, isLoading: state.isLoading, ), ], diff --git a/frontend/lib/features/token/data/requests/refresh_token_request.dart b/frontend/lib/features/token/data/requests/refresh_token_request.dart index c268756..f9256ad 100644 --- a/frontend/lib/features/token/data/requests/refresh_token_request.dart +++ b/frontend/lib/features/token/data/requests/refresh_token_request.dart @@ -5,7 +5,7 @@ part 'refresh_token_request.g.dart'; @freezed abstract class RefreshTokenRequest with _$RefreshTokenRequest { - const factory RefreshTokenRequest({required String refreshToken}) = + const factory RefreshTokenRequest({@JsonKey(name: 'refresh_token') required String refreshToken}) = _RefreshTokenRequest; factory RefreshTokenRequest.fromJson(Map json) => diff --git a/frontend/lib/features/token/di/token_di.dart b/frontend/lib/features/token/di/token_di.dart index 481d5fb..4b86fa4 100644 --- a/frontend/lib/features/token/di/token_di.dart +++ b/frontend/lib/features/token/di/token_di.dart @@ -21,7 +21,7 @@ abstract class TokenDi { static TokenLocalDataSource get _tokenLocalDataSource => TokenLocalDataSourceImpl(storage: Di.secureStorage); - static TokenStreamDataSource get _tokenStreamDataSource => + static final TokenStreamDataSource _tokenStreamDataSource = TokenStreamDataSourceImpl(); static TokenRepository get _tokenRepository => TokenRepositoryImpl( diff --git a/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart b/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart index 789b612..69e682d 100644 --- a/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart +++ b/frontend/lib/features/token/domain/use_cases/refresh_token_use_case.dart @@ -14,28 +14,39 @@ class RefreshTokenUseCase { Future> call() async { final tokenResult = await _repository.getToken(); - return tokenResult.fold((failure) => Left(failure), (token) async { - if (token == null) { - return const Left(NotFoundTokenFailure()); - } - - final newTokenResult = await _repository.refreshToken( - RefreshTokenPayload(refreshToken: token.refreshToken), - ); - - return newTokenResult.fold( - (failure) async { - await _repository.markTokenAsInvalid(); - await _repository.deleteToken(); - - return Left(failure); - }, - (newToken) async { - final result = await _repository.setToken(SetTokenPayload(token: newToken)); - - return result.fold((failure) => Left(failure), (_) => Right(newToken)); - }, - ); - }); + return tokenResult.fold( + (failure) async { + await _repository.markTokenAsInvalid(); + return Left(failure); + }, + (token) async { + if (token == null) { + return const Left(NotFoundTokenFailure()); + } + + final newTokenResult = await _repository.refreshToken( + RefreshTokenPayload(refreshToken: token.refreshToken), + ); + + return newTokenResult.fold( + (failure) async { + await _repository.markTokenAsInvalid(); + await _repository.deleteToken(); + + return Left(failure); + }, + (newToken) async { + final result = await _repository.setToken( + SetTokenPayload(token: newToken), + ); + + return result.fold( + (failure) => Left(failure), + (_) => Right(newToken), + ); + }, + ); + }, + ); } } diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 52ae7d8..a8bb8bb 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -13,7 +13,7 @@ Future main() async { Brightness.dark, // Темные иконки (для светлых фонов) // Нижняя панель (Navigation Bar) systemNavigationBarColor: Color(0xFF1E100A), // Ваш цвет из кода выше - systemNavigationBarIconBrightness: Brightness.dark, // Светлые иконки + systemNavigationBarIconBrightness: Brightness.light, // Светлые иконки ), ); diff --git a/frontend/lib/navigations/recipe_route.dart b/frontend/lib/navigations/recipe_route.dart index 8b451bf..400ad33 100644 --- a/frontend/lib/navigations/recipe_route.dart +++ b/frontend/lib/navigations/recipe_route.dart @@ -34,7 +34,7 @@ final recipeRoute = [ ShellRoute( pageBuilder: (context, state, child) { return NoTransitionPage( - key: state.pageKey, + key: state.pageKey, child: SafeArea( child: Scaffold( body: Column( @@ -68,30 +68,48 @@ final recipeRoute = [ ], ), - GoRoute( - path: '/create', - pageBuilder: (context, state) { - final args = state.extra is RecipeFormPageArgs - ? state.extra as RecipeFormPageArgs - : const RecipeFormPageArgs(); + ShellRoute( + pageBuilder: (context, state, child) { return NoTransitionPage( key: state.pageKey, - child: RecipeFormPage(args: args), + child: SafeArea( + child: Scaffold( + body: Column( + children: [ + Expanded(child: child), + + CookifyNavigationBar(index: 2), + ], + ), + ), + ), ); }, + routes: [ + GoRoute( + path: '/create', + pageBuilder: (context, state) { + final args = state.extra is RecipeFormPageArgs + ? state.extra as RecipeFormPageArgs + : const RecipeFormPageArgs(); + return NoTransitionPage( + key: state.pageKey, + child: RecipeFormPage(args: args), + ); + }, + ), + ], ), GoRoute( path: '/drafts', pageBuilder: (context, state) => - NoTransitionPage( - key: state.pageKey,child: const RecipeDraftsPage()), + NoTransitionPage(key: state.pageKey, child: const RecipeDraftsPage()), ), GoRoute( path: '/saved', pageBuilder: (context, state) => - NoTransitionPage( - key: state.pageKey,child: const RecipeSavedPage()), + NoTransitionPage(key: state.pageKey, child: const RecipeSavedPage()), ), ]; From e88dfb65071e5bf607a14079613350c979ee239d Mon Sep 17 00:00:00 2001 From: Pavel Halukha Date: Mon, 11 May 2026 00:54:36 +0300 Subject: [PATCH 6/6] feat: add extra cool search --- frontend/lib/core/l10n/app_en.arb | 1 + frontend/lib/core/l10n/app_localizations.dart | 6 + .../lib/core/l10n/app_localizations_en.dart | 3 + .../lib/core/l10n/app_localizations_ru.dart | 3 + frontend/lib/core/l10n/app_ru.arb | 1 + ...common_search_remote_data_source_impl.dart | 4 +- .../mappers/recipe_ingredient_mapper.dart | 2 +- .../data/mappers/recipe_step_mapper.dart | 2 +- .../data/models/recipe_ingredient_model.dart | 2 +- .../data/models/recipe_step_model.dart | 2 +- .../data/mappers/recipe_preview_mapper.dart | 13 + .../recipe_search_remote_data_source.dart | 1 + ...recipe_search_remote_data_source_impl.dart | 20 +- .../bloc/recipe_search_form_cubit.dart | 32 ++- .../recipe_search_form_page_content.dart | 236 ++++++++++++------ .../pages/recipe_search_page_content.dart | 175 ++++++++++++- frontend/pubspec.lock | 8 + frontend/pubspec.yaml | 1 + 18 files changed, 421 insertions(+), 91 deletions(-) diff --git a/frontend/lib/core/l10n/app_en.arb b/frontend/lib/core/l10n/app_en.arb index 8e77638..3fc6b95 100644 --- a/frontend/lib/core/l10n/app_en.arb +++ b/frontend/lib/core/l10n/app_en.arb @@ -166,6 +166,7 @@ "value": {} } }, + "recipeSearchText": "No recipes found with the specified filters", "searchNutritionGoals": "Nutrition goals", "searchCaloriesLabel": "CALORIES", "searchProteinsLabel": "PROTEINS", diff --git a/frontend/lib/core/l10n/app_localizations.dart b/frontend/lib/core/l10n/app_localizations.dart index 635b2a6..74e1aa1 100644 --- a/frontend/lib/core/l10n/app_localizations.dart +++ b/frontend/lib/core/l10n/app_localizations.dart @@ -812,6 +812,12 @@ abstract class AppLocalizations { /// **'до {value} мин'** String searchCookingTimeUpTo(Object value); + /// No description provided for @recipeSearchText. + /// + /// In ru, this message translates to: + /// **'По заданным фильтрам не найдено рецептов'** + String get recipeSearchText; + /// No description provided for @searchNutritionGoals. /// /// In ru, this message translates to: diff --git a/frontend/lib/core/l10n/app_localizations_en.dart b/frontend/lib/core/l10n/app_localizations_en.dart index 6bce5b9..7588e10 100644 --- a/frontend/lib/core/l10n/app_localizations_en.dart +++ b/frontend/lib/core/l10n/app_localizations_en.dart @@ -386,6 +386,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'up to $value min'; } + @override + String get recipeSearchText => 'No recipes found with the specified filters'; + @override String get searchNutritionGoals => 'Nutrition goals'; diff --git a/frontend/lib/core/l10n/app_localizations_ru.dart b/frontend/lib/core/l10n/app_localizations_ru.dart index c76ae78..d904090 100644 --- a/frontend/lib/core/l10n/app_localizations_ru.dart +++ b/frontend/lib/core/l10n/app_localizations_ru.dart @@ -387,6 +387,9 @@ class AppLocalizationsRu extends AppLocalizations { return 'до $value мин'; } + @override + String get recipeSearchText => 'По заданным фильтрам не найдено рецептов'; + @override String get searchNutritionGoals => 'Цели в питании'; diff --git a/frontend/lib/core/l10n/app_ru.arb b/frontend/lib/core/l10n/app_ru.arb index c23575e..29b800e 100644 --- a/frontend/lib/core/l10n/app_ru.arb +++ b/frontend/lib/core/l10n/app_ru.arb @@ -166,6 +166,7 @@ "value": {} } }, + "recipeSearchText": "По заданным фильтрам не найдено рецептов", "searchNutritionGoals": "Цели в питании", "searchCaloriesLabel": "КАЛОРИИ", "searchProteinsLabel": "БЕЛКИ", 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 ad69dd3..1772079 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 @@ -19,7 +19,7 @@ class RecipeCommonSearchRemoteDataSourceImpl '/api/search/tags?${request.lastId != null ? 'lastId=${request.lastId}' : ''}&name=${request.name}', ); - return (response.data['items'] as List) + return (response.data as List) .map((json) => CategoryModel.fromJson(json)) .toList(); } @@ -32,7 +32,7 @@ class RecipeCommonSearchRemoteDataSourceImpl '/api/search/ingredients?${request.lastId != null ? 'lastId=${request.lastId}' : ''}&name=${request.name}', ); - return (response.data['items'] as List) + return (response.data as List) .map((json) => IngredientModel.fromJson(json)) .toList(); } diff --git a/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_ingredient_mapper.dart b/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_ingredient_mapper.dart index ea94f30..c1c8054 100644 --- a/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_ingredient_mapper.dart +++ b/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_ingredient_mapper.dart @@ -13,7 +13,7 @@ abstract class RecipeIngredientMapper { calories: model.calories, ), amount: model.amount, - unit: model.unit, + unit: model.unit!, ); } } diff --git a/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_step_mapper.dart b/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_step_mapper.dart index ba80fc5..bc87c78 100644 --- a/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_step_mapper.dart +++ b/frontend/lib/features/recipe/recipe_common/data/mappers/recipe_step_mapper.dart @@ -5,7 +5,7 @@ abstract class RecipeStepMapper { static RecipeStepEntity fromModel(RecipeStepModel model) { return RecipeStepEntity( id: model.id.toString(), - name: model.name, + name: model.name!, photoUrl: model.photoUrl, description: model.description, ); diff --git a/frontend/lib/features/recipe/recipe_common/data/models/recipe_ingredient_model.dart b/frontend/lib/features/recipe/recipe_common/data/models/recipe_ingredient_model.dart index ddcc88e..85db6b6 100644 --- a/frontend/lib/features/recipe/recipe_common/data/models/recipe_ingredient_model.dart +++ b/frontend/lib/features/recipe/recipe_common/data/models/recipe_ingredient_model.dart @@ -13,7 +13,7 @@ abstract class RecipeIngredientModel with _$RecipeIngredientModel { @JsonKey(name: 'protein100g') required int proteins, @JsonKey(name: 'fat100g') required int fats, required double amount, - required String unit, + required String? unit, }) = _RecipeIngredientModel; factory RecipeIngredientModel.fromJson(Map json) => diff --git a/frontend/lib/features/recipe/recipe_common/data/models/recipe_step_model.dart b/frontend/lib/features/recipe/recipe_common/data/models/recipe_step_model.dart index 5ad6ec5..d0cf2b9 100644 --- a/frontend/lib/features/recipe/recipe_common/data/models/recipe_step_model.dart +++ b/frontend/lib/features/recipe/recipe_common/data/models/recipe_step_model.dart @@ -7,7 +7,7 @@ part 'recipe_step_model.g.dart'; abstract class RecipeStepModel with _$RecipeStepModel { const factory RecipeStepModel({ required int id, - @JsonKey(name: 'title') required String name, + @JsonKey(name: 'title') String? name, @JsonKey(name: 'image_url') String? photoUrl, required String description, @JsonKey(name: 'step_number') required int stepNumber, diff --git a/frontend/lib/features/recipe/recipe_feed/data/mappers/recipe_preview_mapper.dart b/frontend/lib/features/recipe/recipe_feed/data/mappers/recipe_preview_mapper.dart index dcbb102..7cf66da 100644 --- a/frontend/lib/features/recipe/recipe_feed/data/mappers/recipe_preview_mapper.dart +++ b/frontend/lib/features/recipe/recipe_feed/data/mappers/recipe_preview_mapper.dart @@ -1,4 +1,5 @@ import 'package:cookify/features/recipe/recipe_common/domain/enums/recipe_difficulty.dart'; +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_feed/domain/entities/recipe_preview_entity.dart'; @@ -14,4 +15,16 @@ abstract class RecipePreviewMapper { categories: model.categories, ); } + + static RecipePreviewEntity fromDetailModel(RecipeDetailModel model) { + return RecipePreviewEntity( + id: model.id.toString(), + name: model.name, + photoUrl: model.photoUrls.first.url, + cookingTime: model.cookingTime, + servingCount: model.servingCount.toInt(), + difficulty: RecipeDifficulty.values[model.difficulty], + categories: model.categories, + ); + } } 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 89251a5..fa39488 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,3 +1,4 @@ +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 58ab3e6..1923641 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,3 +1,4 @@ +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'; @@ -13,10 +14,25 @@ class RecipeSearchRemoteDataSourceImpl implements RecipeSearchRemoteDataSource { SearchRecipeListRequest request, ) async { final response = await _dio.get( - '/api/search/recipes${request.lastId != null ? 'lastId=${request.lastId}' : ''}', + '/api/search/recipes?${request.lastId != null ? 'lastId=${request.lastId}' : ''}', + queryParameters: { + 'title': request.name, + 'difficulty': request.difficulties, + 'maxCookingTime': request.maxCookingTime, + 'minCarb': request.minCarbohydrates, + 'maxCarb': request.maxCarbohydrates, + 'minProtein': request.minProteins, + 'maxProtein': request.maxProteins, + 'minFat': request.minFats, + 'maxFat': request.maxFats, + 'minCalories': request.minCalories, + 'maxCalories': request.maxCalories, + 'tagIds': request.categoryIds, + 'ingredientIds': request.ingredients + } ); - return (response.data['items'] as List) + return (response.data as List) .map((json) => RecipePreviewModel.fromJson(json)) .toList(); } 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 5405b65..afbd8a9 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 @@ -1,5 +1,6 @@ import 'dart:async'; // Добавляем для работы с Timer import 'package:cookify/core/domain/use_cases/results/result.dart'; +import 'package:cookify/features/recipe/recipe_common/domain/entities/ingredient_entity.dart'; import 'package:cookify/features/recipe/recipe_common/domain/payloads/search_category_list_payload.dart'; import 'package:cookify/features/recipe/recipe_common/domain/payloads/search_ingredient_list_payload.dart'; import 'package:cookify/features/recipe/recipe_common/domain/use_cases/search_category_list_use_case.dart'; @@ -11,9 +12,9 @@ class RecipeSearchFormCubit extends Cubit { RecipeSearchFormCubit({ required SearchCategoryListUseCase searchCategoryListUseCase, required SearchIngredientListUseCase searchIngredientListUseCase, - }) : _searchCategoryListUseCase = searchCategoryListUseCase, - _searchIngredientListUseCase = searchIngredientListUseCase, - super(const RecipeSearchFormState()); + }) : _searchCategoryListUseCase = searchCategoryListUseCase, + _searchIngredientListUseCase = searchIngredientListUseCase, + super(const RecipeSearchFormState()); final SearchCategoryListUseCase _searchCategoryListUseCase; final SearchIngredientListUseCase _searchIngredientListUseCase; @@ -31,7 +32,7 @@ class RecipeSearchFormCubit extends Cubit { final result = await _searchCategoryListUseCase( SearchCategoryListPayload(categories: state.categories, name: name), ); - + if (isClosed) return; if (result is Success) { @@ -57,10 +58,31 @@ class RecipeSearchFormCubit extends Cubit { }); } + Future> searchIngredientListFromAI( + List names, + ) async { + final ingredients = []; + + for (int i = 0; i < names.length; i++) { + final result = await _searchIngredientListUseCase( + SearchIngredientListPayload( + ingredients: state.ingredients, + name: names[i], + ), + ); + + if (result is Success && (result as Success>).data.isNotEmpty) { + ingredients.add((result as Success>).data.first); + } + } + + return ingredients; + } + @override Future close() { _categoryDebounce?.cancel(); _ingredientDebounce?.cancel(); return super.close(); } -} \ No newline at end of file +} 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 abc51ba..2803e4f 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,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:cookify/core/l10n/my_locale.dart'; @@ -13,6 +14,7 @@ import 'package:cookify/features/recipe/recipe_search/presentation/pages/recipe_ import 'package:flutter/material.dart' hide KeyboardListener; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; import 'package:image_picker/image_picker.dart'; // --- Константы стиля --- @@ -36,9 +38,9 @@ class RecipeSearchFormPageContent extends StatefulWidget { class _RecipeSearchFormPageContentState extends State { -bool _isShowKeyboard = false; + bool _isShowKeyboard = false; - final KeyboardListener _keyboardListener = KeyboardListener(); + final KeyboardListener _keyboardListener = KeyboardListener(); // Links & Overlays final LayerLink _categoryLink = LayerLink(); @@ -51,7 +53,7 @@ bool _isShowKeyboard = false; final _ingredientFocus = FocusNode(); // State - final List difficulties = RecipeDifficulty.values; + final List difficulties = RecipeDifficulty.values.toList(); int cookingTime = 45; int minCalories = 0, maxCalories = 4000; int minProteins = 0, maxProteins = 100; @@ -65,15 +67,16 @@ bool _isShowKeyboard = false; void initState() { super.initState(); _setupFocusListeners(); - _keyboardListener.addListener(onChange: (bool isVisible) { - setState(() { - - if (_isShowKeyboard && !isVisible) { - _closeOverlay(); - } - _isShowKeyboard = isVisible; - }); - }); + _keyboardListener.addListener( + onChange: (bool isVisible) { + setState(() { + if (_isShowKeyboard && !isVisible) { + _closeOverlay(); + } + _isShowKeyboard = isVisible; + }); + }, + ); } void _setupFocusListeners() { @@ -236,30 +239,106 @@ bool _isShowKeyboard = false; const SliverToBoxAdapter(child: SizedBox(height: 24)), // Категории - _buildSearchSection( - title: MyLocale.of(context).searchCategoriesTitle, - hint: MyLocale.of(context).searchAddCategoryHint, - link: _categoryLink, - focusNode: _categoryFocus, - items: selectedCategories, - onSearch: (val) => - context.read().searchCategoryList(val), - onRemove: (item) => setState(() => selectedCategories.remove(item)), + SliverToBoxAdapter( + child: _buildSearchSection( + title: MyLocale.of(context).searchCategoriesTitle, + hint: MyLocale.of(context).searchAddCategoryHint, + link: _categoryLink, + focusNode: _categoryFocus, + items: selectedCategories, + onSearch: (val) => + context.read().searchCategoryList(val), + onRemove: (item) => + setState(() => selectedCategories.remove(item)), + onTap: () => + context.read().searchCategoryList(''), + ), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), // Ингредиенты - _buildSearchSection( - title: MyLocale.of(context).searchIngredientsTitle, - hint: MyLocale.of(context).searchAddIngredientHint, - link: _ingredientLink, - focusNode: _ingredientFocus, - items: selectedIngredients, - onSearch: (val) => - context.read().searchIngredientList(val), - onRemove: (item) => - setState(() => selectedIngredients.remove(item)), + SliverToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 15.0, + children: [ + Expanded( + child: _buildSearchSection( + title: MyLocale.of(context).searchIngredientsTitle, + hint: MyLocale.of(context).searchAddIngredientHint, + link: _ingredientLink, + focusNode: _ingredientFocus, + items: selectedIngredients, + onSearch: (val) => context + .read() + .searchIngredientList(val), + onRemove: (item) => + setState(() => selectedIngredients.remove(item)), + onTap: () => context + .read() + .searchIngredientList(''), + ), + ), + + Padding( + padding: const EdgeInsets.only(top: 48.0), + 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)]), + ]; + + final response = await model.generateContent(content); + if (context.mounted) { + final json = + jsonDecode(response.text ?? '') + as Map; + final ingredients = await context + .read() + .searchIngredientListFromAI( + (json['products'] as List) + .map((e) => e as String) + .toList(), + ); + + setState(() => selectedIngredients.addAll(ingredients)); + } + } + }, + child: Container( + alignment: Alignment.center, + decoration: const BoxDecoration( + color: AppColors.background, + shape: BoxShape.circle, + ), + width: 40, + height: 40, + child: Icon(Icons.camera_alt, color: AppColors.accent), + ), + ), + ), + ], + ), ), const SliverToBoxAdapter(child: SizedBox(height: 28)), @@ -300,46 +379,46 @@ bool _isShowKeyboard = false; required List items, required Function(String) onSearch, required Function(dynamic) onRemove, + Function()? onTap, }) { - return SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w500, ), - const SizedBox(height: 16), - CompositedTransformTarget( - link: link, - child: _CustomTextField( - focusNode: focusNode, - hintText: hint, - onChanged: onSearch, - fontSize: 14, - ), + ), + const SizedBox(height: 16), + CompositedTransformTarget( + link: link, + child: _CustomTextField( + focusNode: focusNode, + hintText: hint, + onChanged: onSearch, + onTap: onTap, + fontSize: 14, + ), + ), + if (items.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: items + .map( + (e) => _SelectionChip( + label: e.name, + onRemove: () => onRemove(e), + ), + ) + .toList(), ), - if (items.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: items - .map( - (e) => _SelectionChip( - label: e.name, - onRemove: () => onRemove(e), - ), - ) - .toList(), - ), - ], ], - ), + ], ); } @@ -416,6 +495,8 @@ bool _isShowKeyboard = false; ? selectedIngredients.removeWhere((e) => e.id == item.id) : selectedIngredients.add(item); } + _closeOverlay(); + _showSearchOverlay(link: _categoryLink, isCategory: isCategory); }), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -473,10 +554,12 @@ class _CustomTextField extends StatelessWidget { final IconData? prefixIcon; final Function(String)? onChanged; final double fontSize; + final Function()? onTap; const _CustomTextField({ this.controller, this.focusNode, + this.onTap, required this.hintText, this.prefixIcon, this.onChanged, @@ -489,6 +572,7 @@ class _CustomTextField extends StatelessWidget { controller: controller, focusNode: focusNode, onChanged: onChanged, + onTap: onTap, cursorColor: AppColors.accent, style: TextStyle(color: AppColors.textSecondary, fontSize: fontSize), decoration: InputDecoration( @@ -797,7 +881,7 @@ class ImagePickerSheet extends StatelessWidget { ), ), const SizedBox(height: 24), - + Text( "Выберите источник", // Можно заменить на MyLocale.of(context)... style: TextStyle( @@ -806,23 +890,23 @@ class ImagePickerSheet extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - + const SizedBox(height: 24), - + _PickerOption( icon: Icons.camera_alt_rounded, label: "Камера", onTap: () => _pickImage(context, ImageSource.camera), ), - + const SizedBox(height: 12), - + _PickerOption( icon: Icons.photo_library_rounded, label: "Галерея", onTap: () => _pickImage(context, ImageSource.gallery), ), - + const SizedBox(height: 32), // Отступ снизу (safe area) ], ), @@ -851,9 +935,7 @@ class _PickerOption extends StatelessWidget { decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.05), - ), + border: Border.all(color: Colors.white.withValues(alpha: 0.05)), ), child: Row( children: [ @@ -878,4 +960,4 @@ class _PickerOption extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart index 56d6a63..ac2f233 100644 --- a/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart +++ b/frontend/lib/features/recipe/recipe_search/presentation/pages/recipe_search_page_content.dart @@ -62,6 +62,94 @@ class _RecipeSearchPageContentState extends State { case RecipeSearchLoading(): return const CookifyLoadingContent(); case RecipeSearchLoaded(): + if (state.recipes.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.search, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + MyLocale.of(context).recipeSearchText, + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.pop(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + MyLocale.of(context).searchButton, + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], + ); + } return RecipeFeedRecipeList( recipes: state.recipes, isLoading: state.isLoading, @@ -71,7 +159,92 @@ class _RecipeSearchPageContentState extends State { }, ); case RecipeSearchError(): - return Text(MyLocale.of(context).commonError); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16, + children: [ + Container( + padding: EdgeInsets.symmetric( + vertical: 50, + horizontal: 32, + ), + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xFF2C1C16), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Column( + spacing: 16, + children: [ + Container( + alignment: Alignment.center, + width: 96, + height: 96, + decoration: BoxDecoration( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.05 * 255).toInt()), + border: Border.all( + color: Color( + 0xFFE5C9A8, + ).withAlpha((0.1 * 255).toInt()), + ), + borderRadius: BorderRadius.circular(48.0), + ), + child: Icon( + Icons.wifi_off, + size: 48.0, + color: Color(0xFFE5C9A8), + ), + ), + + Text( + MyLocale.of(context).commonOfflineMessage, + style: TextStyle( + color: Color(0xFFE5C9A8), + fontSize: 16.0, + fontWeight: FontWeight.w300, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + GestureDetector( + onTap: () { + context.read().searchRecipeList(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 26.0, + ), + decoration: BoxDecoration( + color: Color(0xFFE5C9A8), + borderRadius: BorderRadius.circular(48.0), + ), + child: Text( + MyLocale.of(context).commonRefresh, + style: TextStyle( + color: Color(0xFF2C1C16), + fontSize: 16.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.0, + height: 20.0 / 16.0, + ), + ), + ), + ), + ], + ); } }, listener: (context, state) {}, diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 618127e..4644e4b 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -525,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.1.0" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f" + url: "https://pub.dev" + source: hosted + version: "0.4.7" google_identity_services_web: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 2297efc..412644d 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: flutter_staggered_grid_view: ^0.7.0 google_sign_in: ^6.0.0 fluttertoast: ^9.0.0 + google_generative_ai: ^0.4.7 dev_dependencies: flutter_test: