diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index d41230f4..0921f560 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -3,7 +3,7 @@ name: Eatvio App Android Test
on: [ pull_request ]
env:
- flutter_version: "3.38.1"
+ flutter_version: "3.38.5"
java_version: "21.x"
flutter_channel: "stable"
my_local_cache_dir: "/opt/hostedtoolcache/"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index e06b26ab..ad156380 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -388,7 +388,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -539,7 +539,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -578,7 +578,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 87;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 94c063d6..182a74af 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -21,7 +21,7 @@
CFBundleSignature
????
CFBundleVersion
- 87
+ $(FLUTTER_BUILD_NUMBER)
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile
index 3a74adf3..52aa7ae8 100644
--- a/ios/fastlane/Fastfile
+++ b/ios/fastlane/Fastfile
@@ -48,8 +48,33 @@ platform :ios do
UI.message "Configuring Flutter for release mode..."
sh("cd .. && flutter build ios --config-only --release")
+ # Read build number from Flutter Generated.xcconfig
+ generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
+ flutter_build_number = nil
+
+ if File.exist?(generated_xcconfig_path)
+ File.foreach(generated_xcconfig_path) do |line|
+ matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
+ if matches
+ flutter_build_number = matches[1].strip
+ break
+ end
+ end
+ end
+
+ if flutter_build_number.nil?
+ UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
+ end
+
+ UI.message "Flutter build number: #{flutter_build_number}"
+
match(type: "appstore", readonly: true)
- increment_build_number(xcodeproj: "Runner.xcodeproj")
+
+ # Set build number explicitly instead of incrementing
+ increment_build_number(
+ xcodeproj: "Runner.xcodeproj",
+ build_number: flutter_build_number
+ )
build_app(
workspace: "Runner.xcworkspace",
@@ -74,7 +99,7 @@ platform :ios do
skip_submission: true,
distribute_external: false,
notify_external_testers: false,
- changelog: "TestFlight build #{get_build_number(xcodeproj: 'Runner.xcodeproj')}",
+ changelog: "TestFlight build #{flutter_build_number}",
verbose: true
)
rescue => ex
diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart
index 47f25430..776aea35 100644
--- a/lib/core/app_router.dart
+++ b/lib/core/app_router.dart
@@ -1,3 +1,4 @@
+import 'dart:developer' as developer;
import 'package:eatvio_app/core/routing/auth_routes.dart';
import 'package:eatvio_app/core/routing/content_routes.dart';
import 'package:eatvio_app/core/routing/main_navigation_routes.dart';
@@ -13,6 +14,9 @@ import 'package:go_router/go_router.dart';
class AppRouter {
static GlobalKey navigatorKey = GlobalKey();
+ // Домены для deep links
+ static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru'];
+
static GoRouter appRouter = GoRouter(
initialLocation: RouteConstants.updateScreen,
navigatorKey: navigatorKey,
@@ -27,51 +31,128 @@ class AppRouter {
);
/// Обработка редиректов для deep links
+ /// Возвращает null для обычной навигации, чтобы не мешать внутренним переходам
static String? _handleRedirect(BuildContext context, GoRouterState state) {
- final segments = state.uri.pathSegments;
- if (segments.length == 4) {
- final type = segments[1];
- switch (type) {
- case 'diary':
- FlutterNativeSplash.remove();
- return '/diary-screen';
- }
+ final uri = state.uri;
+ final host = uri.host.toLowerCase();
+ final segments = uri.pathSegments;
+
+ // Проверяем, является ли это deep link по домену
+ final isDeepLink = _deepLinkHosts.contains(host);
+
+ // Если это не deep link, не мешаем обычной навигации
+ if (!isDeepLink) {
+ return null;
}
- if (segments.length == 3) {
+
+ // Логирование для отладки
+ developer.log(
+ 'Deep link detected: host=$host, path=${uri.path}, segments=$segments',
+ name: 'AppRouter',
+ );
+
+ // Проверка на пустой путь или некорректный формат
+ if (segments.isEmpty) {
+ developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter');
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
+ }
+
+ // Проверка языка (первый сегмент должен быть ru или en)
+ if (segments[0] != 'ru' && segments[0] != 'en') {
+ developer.log(
+ 'Invalid language segment: ${segments[0]}, redirecting to feed',
+ name: 'AppRouter',
+ );
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
+ }
+
+ // Обработка deep links с учетом языка
+ // Формат: /{lang}/{type}/{identifier?}
+ if (segments.length == 4) {
+ // Формат: /{lang}/diary/{...}
+ final type = segments[1];
+
+ if (type == 'diary') {
+ developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter');
+ FlutterNativeSplash.remove();
+ return RouteConstants.diaryScreen;
+ }
+
+ developer.log(
+ 'Unknown deep link type for 4 segments: $type, redirecting to feed',
+ name: 'AppRouter',
+ );
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
+ } else if (segments.length == 3) {
+ // Формат: /{lang}/{type}/{identifier}
final type = segments[1];
final contentIdentifier = segments[2];
switch (type) {
case 'recipe':
+ developer.log('Deep link: recipe/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
return '/about-recipes-screen?slug=$contentIdentifier';
case 'blog_post':
+ developer.log('Deep link: blog_post/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
return '/blog-post-screen?id=$contentIdentifier';
case 'plan':
+ developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
return '/about-plan-screen?slug=$contentIdentifier';
case 'diet':
+ developer.log('Deep link: diet/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
return '/about-diet-screen?slug=$contentIdentifier';
case 'chat':
+ developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
- return '/chat-select-screen';
+ return RouteConstants.chatSelectScreen;
case 'profile':
+ developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
return '/profile-screen?slug=$contentIdentifier';
- }
- }
- if (segments.length == 2) {
- final type = segments[1];
- switch (type) {
- case 'chat':
+ case 'diary':
+ developer.log('Deep link: diary/$contentIdentifier', name: 'AppRouter');
FlutterNativeSplash.remove();
- return '/chat-select-screen';
+ return RouteConstants.diaryScreen;
+ default:
+ developer.log(
+ 'Unknown deep link type: $type, redirecting to feed',
+ name: 'AppRouter',
+ );
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
}
+ } else if (segments.length == 2) {
+ // Формат: /{lang}/{type} - только для chat
+ final type = segments[1];
+
+ if (type == 'chat') {
+ developer.log('Deep link: chat', name: 'AppRouter');
+ FlutterNativeSplash.remove();
+ return RouteConstants.chatSelectScreen;
+ } else {
+ developer.log(
+ 'Unknown deep link type for 2 segments: $type, redirecting to feed',
+ name: 'AppRouter',
+ );
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
+ }
+ } else {
+ // Неизвестный формат deep link
+ developer.log(
+ 'Unknown deep link format: ${segments.length} segments, redirecting to feed',
+ name: 'AppRouter',
+ );
+ FlutterNativeSplash.remove();
+ return RouteConstants.feedScreen;
}
-
- return null;
}
static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) {
diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart
index e01596f8..d5b109e4 100644
--- a/lib/core/di/cubit_registrations.dart
+++ b/lib/core/di/cubit_registrations.dart
@@ -76,7 +76,7 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() {
// Auth related cubits
- sl.registerFactory(() => AuthCubit(authRepository: sl()));
+ sl.registerFactory(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
sl.registerFactory(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
sl.registerFactory(() => ResetPasswordCubit(resetPasswordRepository: sl()));
sl.registerFactory(() => VerifyEmailCubit(profileRepository: sl()));
diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart
index fbbb4498..341691a5 100644
--- a/lib/core/di/repository_registrations.dart
+++ b/lib/core/di/repository_registrations.dart
@@ -17,6 +17,7 @@ import 'package:eatvio_app/features/diary/repositories/diary_repository.dart';
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
+import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
sl.registerLazySingleton(() => ConsultantRepository(consultantService: sl()));
sl.registerLazySingleton(() => DiaryRepository(diaryService: sl()));
sl.registerLazySingleton(() => GlossaryRepository(glossaryService: sl()));
+ sl.registerLazySingleton(() => BarcodeRepository(barcodeService: sl(), dio: sl()));
sl.registerLazySingleton(() => DietRepository(dietsService: sl()));
sl.registerLazySingleton(() => PlansRepository(plansService: sl()));
sl.registerLazySingleton(() => PlanRepository(planService: sl()));
diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart
index f70e28ce..62439f4a 100644
--- a/lib/core/di/service_registrations.dart
+++ b/lib/core/di/service_registrations.dart
@@ -19,6 +19,7 @@ import 'package:eatvio_app/features/diary/services/diary_service.dart';
import 'package:eatvio_app/features/diets/services/diet_service.dart';
import 'package:eatvio_app/features/feed/services/feed_service.dart';
import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
+import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
import 'package:eatvio_app/features/menu/services/notification_settings_service.dart';
import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
@@ -56,6 +57,7 @@ class ServiceRegistrations {
sl.registerLazySingleton(() => ConsultantService(sl()));
sl.registerLazySingleton(() => DiaryService(sl()));
sl.registerLazySingleton(() => GlossaryService(sl()));
+ sl.registerLazySingleton(() => BarcodeService(sl()));
sl.registerLazySingleton(() => DietService(sl()));
sl.registerLazySingleton(() => PlansService(sl()));
sl.registerLazySingleton(() => PlanService(sl()));
diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart
index 1110f88a..28d85d36 100644
--- a/lib/core/routing/other_routes.dart
+++ b/lib/core/routing/other_routes.dart
@@ -1,11 +1,9 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
-import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
-import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/calendar/presentation/pages/calendar_screen.dart';
import 'package:eatvio_app/features/chat/presentation/pages/chat_screen.dart';
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
@@ -59,23 +57,14 @@ class OtherRoutes {
redirect: (context, state) async {
try {
final prefs = sl();
- final firebaseApi = sl();
- final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
- await firebaseApi.registerPushToken();
+ final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
if (!welcomeScreenWasShowed) {
return null;
- } else if (await TokenStorage.haveTokenInStorage()) {
- return '/feed-screen';
} else {
- final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
- if (!hasAnonymousTokenBeenSent) {
- await firebaseApi.registerAnonymousPushToken();
- prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
- }
return '/auth-screen';
}
} catch (e) {
- return '/feed-screen';
+ return '/auth-screen';
}
},
),
@@ -247,7 +236,7 @@ class OtherRoutes {
GoRoute(
path: PersonalDietSuggestionScreen.path,
name: PersonalDietSuggestionScreen.name,
- builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int,),
+ builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int),
),
];
}
diff --git a/lib/features/auth/models/oauth_verification_confirm_request.dart b/lib/features/auth/models/oauth_verification_confirm_request.dart
new file mode 100644
index 00000000..88101ab2
--- /dev/null
+++ b/lib/features/auth/models/oauth_verification_confirm_request.dart
@@ -0,0 +1,27 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'oauth_verification_confirm_request.freezed.dart';
+part 'oauth_verification_confirm_request.g.dart';
+
+@freezed
+abstract class OAuthVerificationConfirmRequest with _$OAuthVerificationConfirmRequest {
+ const factory OAuthVerificationConfirmRequest({
+ @JsonKey(name: 'access_token') required String accessToken,
+ @JsonKey(name: 'refresh_token') required String refreshToken,
+ @JsonKey(name: 'client_id') required String clientId,
+ required String provider,
+ required String id,
+ String? email,
+ String? phone,
+ required String code,
+ @JsonKey(name: 'verification_type') required String verificationType,
+ @JsonKey(name: 'first_name') required String firstName,
+ @JsonKey(name: 'last_name') required String lastName,
+ @JsonKey(name: 'date_of_birth') required String dateOfBirth,
+ required String gender,
+ required int weight,
+ required int height,
+ }) = _OAuthVerificationConfirmRequest;
+
+ factory OAuthVerificationConfirmRequest.fromJson(Map json) => _$OAuthVerificationConfirmRequestFromJson(json);
+}
diff --git a/lib/features/auth/models/oauth_verification_confirm_response.dart b/lib/features/auth/models/oauth_verification_confirm_response.dart
new file mode 100644
index 00000000..85badaed
--- /dev/null
+++ b/lib/features/auth/models/oauth_verification_confirm_response.dart
@@ -0,0 +1,36 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'oauth_verification_confirm_response.freezed.dart';
+part 'oauth_verification_confirm_response.g.dart';
+
+@freezed
+abstract class OAuthVerificationConfirmResponse with _$OAuthVerificationConfirmResponse {
+ const factory OAuthVerificationConfirmResponse({
+ required bool success,
+ required String message,
+ required String token,
+ required Map user,
+ @JsonKey(name: 'is_new_user') required bool isNewUser,
+ }) = _OAuthVerificationConfirmResponse;
+
+ factory OAuthVerificationConfirmResponse.fromJson(Map json) => _$OAuthVerificationConfirmResponseFromJson(json);
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/features/auth/models/oauth_verification_request_response.dart b/lib/features/auth/models/oauth_verification_request_response.dart
new file mode 100644
index 00000000..3525051e
--- /dev/null
+++ b/lib/features/auth/models/oauth_verification_request_response.dart
@@ -0,0 +1,30 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'oauth_verification_request_response.freezed.dart';
+part 'oauth_verification_request_response.g.dart';
+
+@freezed
+abstract class OAuthVerificationRequestResponse with _$OAuthVerificationRequestResponse {
+ const factory OAuthVerificationRequestResponse({required String email, required String status}) = _OAuthVerificationRequestResponse;
+
+ factory OAuthVerificationRequestResponse.fromJson(Map json) => _$OAuthVerificationRequestResponseFromJson(json);
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
index 6f1a59f5..4e3c7db9 100644
--- a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
+++ b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
@@ -1,15 +1,26 @@
import 'dart:developer';
+import 'package:eatvio_app/core/app_router.dart';
+import 'package:eatvio_app/core/firebase_api.dart';
+import 'package:eatvio_app/core/prefs.dart';
+import 'package:eatvio_app/core/prefs_keys.dart';
+import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
+import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_native_splash/flutter_native_splash.dart';
+import 'package:go_router/go_router.dart';
class AuthCubit extends Cubit {
final AuthRepository authRepository;
+ final ProfileRepository profileRepository;
+ final FirebaseApi firebaseApi;
+ final Prefs prefs;
- AuthCubit({required this.authRepository}) : super(const AuthState());
+ AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
Future getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
@@ -25,4 +36,32 @@ class AuthCubit extends Cubit {
return false;
}
}
+
+ Future initializeUser() async {
+ await registerAnonymousPushToken();
+ if (await TokenStorage.haveTokenInStorage()) {
+ final profile = await profileRepository.getMyProfile();
+ if (profile.emailVerifiedAt != null) {
+ await registerPushToken();
+ AppRouter.navigatorKey.currentContext?.go(RouteConstants.feedScreen);
+ return;
+ }
+ AppRouter.navigatorKey.currentContext?.go(RouteConstants.verifyEmailScreen);
+ } else {
+ await registerAnonymousPushToken();
+ FlutterNativeSplash.remove();
+ }
+ }
+
+ Future registerAnonymousPushToken() async {
+ final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
+ if (!hasAnonymousTokenBeenSent) {
+ await firebaseApi.registerAnonymousPushToken();
+ prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
+ }
+ }
+
+ Future registerPushToken() async {
+ await firebaseApi.registerPushToken();
+ }
}
diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart
index b4f78b4a..daf156a8 100644
--- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart
+++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/token_storage.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -28,6 +29,19 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin {
log('OAuthCubit: Got token model: ${tokenModel.token}');
TokenStorage.saveTokenInStorage(tokenModel.token);
emit(OAuthState.loaded(token: tokenModel.token));
+ } on DioException catch (e) {
+ log('OAuthCubit: Error occurred: $e');
+ // Проверяем, является ли ошибка ошибкой валидации email/phone
+ final errors = e.response?.data['errors'] as Map?;
+ final emailErrors = errors?['email'] as List?;
+ final phoneErrors = errors?['phone'] as List?;
+
+ if ((emailErrors != null && emailErrors.isNotEmpty) || (phoneErrors != null && phoneErrors.isNotEmpty)) {
+ // Это ошибка валидации - нужно показать экран подтверждения
+ emit(const OAuthState.error(errorMessage: 'validation_error'));
+ } else {
+ emit(OAuthState.error(errorMessage: e.toString()));
+ }
} catch (e) {
log('OAuthCubit: Error occurred: $e');
emit(OAuthState.error(errorMessage: e.toString()));
@@ -77,16 +91,22 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin {
final dateOfBirth = errors?['date_of_birth'] as List?;
final errorsList = [];
if (emailErrors != null && emailErrors.isNotEmpty) {
- if (emailErrors.contains('Некорректный формат email')) {
- errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
- }
- if (emailErrors.contains('Такой Эл. почта уже существует.')) {
- errorsList.add(RegistrationValidationErrors.emailExist);
+ for (final error in emailErrors) {
+ final errorStr = error.toString();
+ if (errorStr.contains('Некорректный формат email')) {
+ errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
+ }
+ if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
+ errorsList.add(RegistrationValidationErrors.emailExist);
+ }
}
}
if (phoneErrors != null && phoneErrors.isNotEmpty) {
- if (phoneErrors.contains('Такой phone уже существует.')) {
- errorsList.add(RegistrationValidationErrors.phoneExist);
+ for (final error in phoneErrors) {
+ final errorStr = error.toString();
+ if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
+ errorsList.add(RegistrationValidationErrors.phoneExist);
+ }
}
}
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
@@ -101,6 +121,71 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin {
return null;
}
+
+ Future requestOAuthVerification({String? email, String? phone}) async {
+ try {
+ emit(const OAuthState.loading());
+ log('OAuthCubit: Requesting verification code for email: $email, phone: $phone');
+ final response = await authRepository.requestOAuthVerification(email: null, phone: phone);
+ if (response != null) {
+ log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}');
+ }
+ // Сбрасываем состояние после успешного запроса кода
+ emit(const OAuthState());
+ return response;
+ } catch (e) {
+ log('OAuthCubit: Error requesting verification code: $e');
+ emit(OAuthState.error(errorMessage: e.toString()));
+ return null;
+ }
+ }
+
+ Future confirmOAuthVerification({
+ required String provider,
+ required String accessToken,
+ required String refreshToken,
+ required String clientId,
+ required String id,
+ String? email,
+ String? phone,
+ required String code,
+ required String verificationType,
+ required String firstName,
+ required String lastName,
+ required String dateOfBirth,
+ required String gender,
+ required int weight,
+ required int height,
+ }) async {
+ try {
+ emit(const OAuthState.loading());
+ log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code');
+ final response = await authRepository.confirmOAuthVerification(
+ provider: provider,
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ clientId: clientId,
+ id: id,
+ email: email,
+ phone: phone,
+ code: code,
+ verificationType: verificationType,
+ firstName: firstName,
+ lastName: lastName,
+ dateOfBirth: dateOfBirth,
+ gender: gender,
+ weight: weight,
+ height: height,
+ );
+ log('OAuthCubit: OAuth verification confirmed successfully, token received');
+ TokenStorage.saveTokenInStorage(response.token);
+ emit(OAuthState.loaded(token: response.token));
+ } catch (e) {
+ log('OAuthCubit: Error confirming OAuth verification: $e');
+ emit(OAuthState.error(errorMessage: e.toString()));
+ rethrow; // Пробрасываем ошибку дальше, чтобы UI мог её обработать
+ }
+ }
}
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }
diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart
index 0220c9fb..bf9074d9 100644
--- a/lib/features/auth/presentation/pages/auth_screen.dart
+++ b/lib/features/auth/presentation/pages/auth_screen.dart
@@ -28,7 +28,6 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
-import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -232,9 +231,8 @@ class _AuthScreenState extends State {
final bool useMobileLayout = shortestSide < 600;
if (!useMobileLayout) isFocused = true;
final s = S.of(context);
- FlutterNativeSplash.remove();
return BlocProvider(
- create: (BuildContext context) => authCubit,
+ create: (BuildContext context) => authCubit..initializeUser(),
child: BlocBuilder(
builder: (BuildContext context, AuthState state) {
return Scaffold(
diff --git a/lib/features/auth/presentation/pages/forget_password_screen.dart b/lib/features/auth/presentation/pages/forget_password_screen.dart
index d842fcfc..911805c8 100644
--- a/lib/features/auth/presentation/pages/forget_password_screen.dart
+++ b/lib/features/auth/presentation/pages/forget_password_screen.dart
@@ -1,166 +1,124 @@
-import 'package:eatvio_app/core/locator_service.dart';
-import 'package:eatvio_app/core/theme/theme_helper.dart';
-import 'package:eatvio_app/core/utils/toasts.dart';
-import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
-import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart';
-import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_state.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:go_router/go_router.dart';
-
-class ForgetPasswordScreen extends StatefulWidget {
- final bool isAuth;
- const ForgetPasswordScreen({super.key, required this.isAuth});
-
- @override
- State createState() => _ForgetPasswordScreenState();
-}
-
-class _ForgetPasswordScreenState extends State {
- final emailController = TextEditingController();
- final codeController = TextEditingController();
- final passwordController = TextEditingController();
- final passwordConfirmController = TextEditingController();
-
- @override
- Widget build(BuildContext context) {
- return BlocProvider(
- create: (BuildContext context) => sl(),
- child: BlocConsumer(
- builder: (BuildContext context, ResetPasswordState state) {
- return Scaffold(
- appBar: AppBar(
- title: widget.isAuth ? const Text('Смена пароля') : const Text('Восстановление пароля'),
- centerTitle: false,
- ),
- body: state is ResetPasswordStateLoadingResetPassword || state is ResetPasswordStateLoadingSendEmailCode
- ? const Center(
- child: CircularProgressIndicator(),
- )
- : Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (state is ResetPasswordStateCodeSendError || state is ResetPasswordStateEmpty) ...[
- Text(
- '${widget.isAuth ? '' : 'Забыли пароль? '}Укажите свой email,\nи мы пришлём код для сброса.',
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 24),
- const Text(
- 'E-mail',
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- CustomTextFormField(
- controller: emailController,
- hintText: 'Введите e-mail',
- ),
- ],
- if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) ...[
- const Text(
- 'Код',
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- CustomTextFormField(
- controller: codeController,
- hintText: 'Введите код',
- ),
- const SizedBox(height: 12),
- const Text(
- 'Пароль',
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- CustomTextFormField(
- controller: passwordController,
- hintText: 'Введите новый пароль',
- ),
- const SizedBox(height: 12),
- const Text(
- 'Подтвердите пароль',
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- CustomTextFormField(
- controller: passwordConfirmController,
- hintText: 'Введите новый пароль',
- ),
- ],
- const Spacer(),
- InkWell(
- onTap: () {
- if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) {
- context.read().tryResetPassword(
- emailController.text,
- codeController.text,
- passwordController.text,
- passwordConfirmController.text,
- );
- } else {
- context.read().trySendEmailCode(emailController.text);
- }
- },
- borderRadius: BorderRadius.circular(20),
- child: Ink(
- width: MediaQuery.of(context).size.width - 32,
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(20),
- color: appTheme.blueA400,
- ),
- child: Center(
- child: Text(
- state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError
- ? 'Сменить пароль'
- : 'Сбросить пароль',
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.w600,
- color: Colors.white,
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- );
- },
- listener: (BuildContext context, ResetPasswordState state) {
- if (state is ResetPasswordStateCodeSendSuccess) {
- showSuccessToast('Код на почту успешно отправлен');
- }
- if (state is ResetPasswordStateCodeSendError) {
- showErrorToast('Ошибка отправки кода. Проверьте правильность ввёдной почты');
- }
- if (state is ResetPasswordStateResetPasswordError) {
- showErrorToast('Ошибка смены пароля. Проверьте правильность ввёднного кода и совпадение пароля');
- }
- if (state is ResetPasswordStateResetPassowrdSuccess) {
- GoRouter.of(context).pop();
- showSuccessToast('Пароль успешно сменён. ${widget.isAuth ? '' : 'Войдите в аккаунт'}');
- }
- },
- ),
- );
- }
-}
+import 'package:eatvio_app/core/locator_service.dart';
+import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/core/utils/toasts.dart';
+import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
+import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart';
+import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_state.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+
+class ForgetPasswordScreen extends StatefulWidget {
+ final bool isAuth;
+ const ForgetPasswordScreen({super.key, required this.isAuth});
+
+ @override
+ State createState() => _ForgetPasswordScreenState();
+}
+
+class _ForgetPasswordScreenState extends State {
+ final emailController = TextEditingController();
+ final codeController = TextEditingController();
+ final passwordController = TextEditingController();
+ final passwordConfirmController = TextEditingController();
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocProvider(
+ create: (BuildContext context) => sl(),
+ child: BlocConsumer(
+ builder: (BuildContext context, ResetPasswordState state) {
+ return Scaffold(
+ appBar: AppBar(title: widget.isAuth ? const Text('Смена пароля') : const Text('Восстановление пароля'), centerTitle: false),
+ body: state is ResetPasswordStateLoadingResetPassword || state is ResetPasswordStateLoadingSendEmailCode
+ ? const Center(child: CircularProgressIndicator())
+ : Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (state is ResetPasswordStateCodeSendError || state is ResetPasswordStateEmpty) ...[
+ Text(
+ '${widget.isAuth ? '' : 'Забыли пароль? '}Укажите свой email,\nи мы пришлём код для сброса.',
+ style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
+ ),
+ const SizedBox(height: 24),
+ const Text('E-mail', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 4),
+ CustomTextFormField(controller: emailController, hintText: 'Введите e-mail'),
+ ],
+ if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) ...[
+ const Text('Код', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 4),
+ CustomTextFormField(controller: codeController, hintText: 'Введите код'),
+ const SizedBox(height: 12),
+ const Text('Пароль', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 4),
+ CustomTextFormField(
+ controller: passwordController,
+ hintText: 'Введите новый пароль',
+ inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
+ ),
+ const SizedBox(height: 12),
+ const Text('Подтвердите пароль', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 4),
+ CustomTextFormField(
+ controller: passwordConfirmController,
+ hintText: 'Введите новый пароль',
+ inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
+ ),
+ ],
+ const Spacer(),
+ InkWell(
+ onTap: () {
+ if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) {
+ context.read().tryResetPassword(
+ emailController.text,
+ codeController.text,
+ passwordController.text,
+ passwordConfirmController.text,
+ );
+ } else {
+ context.read().trySendEmailCode(emailController.text);
+ }
+ },
+ borderRadius: BorderRadius.circular(20),
+ child: Ink(
+ width: MediaQuery.of(context).size.width - 32,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
+ child: Center(
+ child: Text(
+ state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError
+ ? 'Сменить пароль'
+ : 'Сбросить пароль',
+ style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ listener: (BuildContext context, ResetPasswordState state) {
+ if (state is ResetPasswordStateCodeSendSuccess) {
+ showSuccessToast('Код на почту успешно отправлен');
+ }
+ if (state is ResetPasswordStateCodeSendError) {
+ showErrorToast('Ошибка отправки кода. Проверьте правильность ввёдной почты');
+ }
+ if (state is ResetPasswordStateResetPasswordError) {
+ showErrorToast('Ошибка смены пароля. Проверьте правильность ввёднного кода и совпадение пароля');
+ }
+ if (state is ResetPasswordStateResetPassowrdSuccess) {
+ GoRouter.of(context).pop();
+ showSuccessToast('Пароль успешно сменён. ${widget.isAuth ? '' : 'Войдите в аккаунт'}');
+ }
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart
index 1791e24e..4967930e 100644
--- a/lib/features/auth/presentation/pages/register_via_social_screen.dart
+++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:developer';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -49,6 +50,8 @@ class _RegisterViaSocialScreenState extends State {
bool weightError = false;
bool heightError = false;
bool incorrectEmailFormatError = false;
+ bool showVerificationButton = false;
+ String? verificationType; // 'email' or 'phone'
// Состояние для выбора пола
bool isMaleSelected = true;
@@ -221,7 +224,28 @@ class _RegisterViaSocialScreenState extends State {
hasError: emailExistError || incorrectEmailFormatError,
onChanged: (newValue) {},
),
- if (emailExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такая почта уже существует.')],
+ if (emailExistError) ...[
+ const SizedBox(height: 4),
+ const ValidationErrorWidget(error: 'Такая почта уже существует.'),
+ if (showVerificationButton && verificationType == 'email') ...[
+ const SizedBox(height: 12),
+ InkWell(
+ borderRadius: BorderRadius.circular(20),
+ onTap: () => _handleVerificationRequest(context, 'email'),
+ child: Ink(
+ width: MediaQuery.of(context).size.width - 32,
+ height: 52,
+ decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
+ child: const Center(
+ child: Text(
+ 'Да, это мой аккаунт, подтвердить',
+ style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Colors.white),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
if (incorrectEmailFormatError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Некорректный формат email')],
const SizedBox(height: 12),
const Text('Номер телефона'),
@@ -235,7 +259,28 @@ class _RegisterViaSocialScreenState extends State {
hasError: phoneExistError,
onChanged: (newValue) {},
),
- if (phoneExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такой номер телефона уже существует.')],
+ if (phoneExistError) ...[
+ const SizedBox(height: 4),
+ const ValidationErrorWidget(error: 'Такой номер телефона уже существует.'),
+ if (showVerificationButton && verificationType == 'phone') ...[
+ const SizedBox(height: 12),
+ InkWell(
+ borderRadius: BorderRadius.circular(20),
+ onTap: () => _handleVerificationRequest(context, 'phone'),
+ child: Ink(
+ width: MediaQuery.of(context).size.width - 32,
+ height: 52,
+ decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
+ child: const Center(
+ child: Text(
+ 'Да, это мой аккаунт, подтвердить',
+ style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Colors.white),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -293,7 +338,13 @@ class _RegisterViaSocialScreenState extends State {
children: [
const Text('Вес'),
const SizedBox(height: 4),
- CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}, hasError: weightError),
+ CustomTextFormField(
+ controller: weightController,
+ hintText: 'Введите вес',
+ keyboardType: TextInputType.number,
+ onChanged: (newValue) {},
+ hasError: weightError,
+ ),
if (weightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Вес должен быть от 30 до 300 кг')],
],
),
@@ -306,7 +357,13 @@ class _RegisterViaSocialScreenState extends State {
children: [
const Text('Рост'),
const SizedBox(height: 4),
- CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}, hasError: heightError),
+ CustomTextFormField(
+ controller: heightController,
+ hintText: 'Введите рост',
+ keyboardType: TextInputType.number,
+ onChanged: (newValue) {},
+ hasError: heightError,
+ ),
if (heightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Рост должен быть от 100 до 250 см')],
],
),
@@ -501,6 +558,10 @@ class _RegisterViaSocialScreenState extends State {
clientId: clientID,
);
if (errors != null) {
+ bool hasEmailExist = false;
+ bool hasPhoneExist = false;
+
+ // Сначала определяем, какие ошибки присутствуют
for (final error in errors) {
switch (error) {
case RegistrationValidationErrors.incorrectEmailFormat:
@@ -509,14 +570,10 @@ class _RegisterViaSocialScreenState extends State {
});
break;
case RegistrationValidationErrors.emailExist:
- setState(() {
- emailExistError = true;
- });
+ hasEmailExist = true;
break;
case RegistrationValidationErrors.phoneExist:
- setState(() {
- phoneExistError = true;
- });
+ hasPhoneExist = true;
break;
case RegistrationValidationErrors.incorrectDateOfBirthFormat:
setState(() {
@@ -525,8 +582,236 @@ class _RegisterViaSocialScreenState extends State {
break;
}
}
+
+ // Устанавливаем verificationType согласно правилам:
+ // - Если оба (email и phone) существуют -> 'email'
+ // - Если только email -> 'email'
+ // - Если только phone -> 'phone'
+ if (hasEmailExist || hasPhoneExist) {
+ setState(() {
+ emailExistError = hasEmailExist;
+ phoneExistError = hasPhoneExist;
+ showVerificationButton = true;
+ if (hasEmailExist && hasPhoneExist) {
+ verificationType = 'email'; // Если оба существуют, используем email
+ } else if (hasEmailExist) {
+ verificationType = 'email'; // Только email
+ } else {
+ verificationType = 'phone'; // Только phone
+ }
+ });
+ }
+
+ // Если есть ошибки, возвращаемся и не показываем toast об успехе
return;
}
+ // Регистрация успешна только если нет ошибок
showSuccessToast('Регистрация успешна');
}
+
+ Future _handleVerificationRequest(BuildContext context, String type) async {
+ try {
+ // Используем service locator для доступа к OAuthCubit
+ final oauthCubit = CubitRegistrations.sl();
+ // Всегда передаем и email и phone
+ final String email = emailController.text;
+ final String phone = phoneController.text.replaceAll(RegExp(r'[^\d+]'), '');
+
+ final response = await oauthCubit.requestOAuthVerification(email: email, phone: phone);
+
+ if (response != null && context.mounted) {
+ // Показываем диалог для ввода кода
+ final displayText = response.email;
+ _showVerificationCodeDialog(context, type, displayText);
+ } else if ((emailController.text.isEmpty || phoneController.text.isEmpty) && context.mounted) {
+ showErrorToast('Пожалуйста, заполните все поля');
+ } else if (context.mounted) {
+ showErrorToast('Не удалось отправить код верификации');
+ }
+ } catch (e) {
+ if (context.mounted) {
+ showErrorToast('Ошибка: ${e.toString()}');
+ }
+ }
+ }
+
+ void _showVerificationCodeDialog(BuildContext context, String verificationType, String displayText) {
+ final List focusNodes = List.generate(4, (_) => FocusNode());
+ final List controllers = List.generate(4, (_) => TextEditingController());
+
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Подтверждение аккаунта'),
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('Код отправлен на $displayText'),
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: List.generate(4, (index) {
+ return SizedBox(
+ width: 50,
+ child: TextField(
+ controller: controllers[index],
+ focusNode: focusNodes[index],
+ textAlign: TextAlign.center,
+ keyboardType: TextInputType.number,
+ maxLength: 1,
+ decoration: const InputDecoration(counterText: '', border: OutlineInputBorder()),
+ onChanged: (value) {
+ if (value.isNotEmpty && index < 3) {
+ focusNodes[index + 1].requestFocus();
+ }
+ if (value.isEmpty && index > 0) {
+ focusNodes[index - 1].requestFocus();
+ }
+ // Проверяем, заполнен ли весь код
+ final code = controllers.map((c) => c.text).join();
+ if (code.length == 4) {
+ // Небольшая задержка перед отправкой, чтобы пользователь увидел последний символ
+ Future.delayed(const Duration(milliseconds: 100), () async {
+ if (dialogContext.mounted && context.mounted) {
+ await _handleVerificationConfirm(context, dialogContext, verificationType, code, controllers, focusNodes);
+ }
+ });
+ }
+ },
+ ),
+ );
+ }),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ for (var controller in controllers) {
+ controller.dispose();
+ }
+ for (var node in focusNodes) {
+ node.dispose();
+ }
+ Navigator.of(dialogContext).pop();
+ },
+ child: const Text('Отмена'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _handleVerificationConfirm(
+ BuildContext widgetContext,
+ BuildContext dialogContext,
+ String verificationType,
+ String code,
+ List controllers,
+ List focusNodes,
+ ) async {
+ try {
+ // Используем service locator для доступа к OAuthCubit, так как контекст из диалога может не иметь доступа к провайдеру
+ final oauthCubit = CubitRegistrations.sl();
+ final authResult = widget.authResult;
+
+ if (authResult == null) {
+ showErrorToast('Ошибка: данные авторизации не найдены');
+ return;
+ }
+
+ // Всегда передаем и email и phone (полный набор данных от провайдера)
+ // Используем данные из формы, если они есть, иначе из authResult
+ final String email = emailController.text.isNotEmpty ? emailController.text : (authResult.email ?? '');
+ final String phone = phoneController.text.isNotEmpty ? phoneController.text.replaceAll(RegExp(r'[^\d+]'), '') : (authResult.phone ?? '');
+
+ // Проверяем, что данные есть
+ if (email.isEmpty && phone.isEmpty) {
+ log('OAuth Verification Confirm: Both email and phone are empty');
+ showErrorToast('Ошибка: не указан email или телефон');
+ return;
+ }
+
+ // Проверяем, что все необходимые данные от провайдера есть
+ if (authResult.accessToken.isEmpty || authResult.clientId.isEmpty || authResult.userId.isEmpty) {
+ log('OAuth Verification Confirm: Missing required OAuth data');
+ showErrorToast('Ошибка: неполные данные авторизации');
+ return;
+ }
+
+ // Проверяем, что код не пустой
+ if (code.isEmpty || code.length != 4) {
+ showErrorToast('Введите полный код');
+ return;
+ }
+
+ // Получаем данные профиля из формы
+ final String firstName = firstNameController.text.isNotEmpty ? firstNameController.text : (authResult.firstName ?? '');
+ final String lastName = lastNameController.text.isNotEmpty ? lastNameController.text : (authResult.lastName ?? '');
+ final String dateOfBirth = dateOfBirthController.text.isNotEmpty ? dateOfBirthController.text : (authResult.birthDate ?? '');
+ final String gender = isMaleSelected ? 'male' : 'female';
+ final int weight = weightController.text.isNotEmpty ? (int.tryParse(weightController.text) ?? 0) : 0;
+ final int height = heightController.text.isNotEmpty ? (int.tryParse(heightController.text) ?? 0) : 0;
+
+ // Проверяем, что все обязательные поля заполнены
+ if (firstName.isEmpty || lastName.isEmpty || dateOfBirth.isEmpty || weight == 0 || height == 0) {
+ log('OAuth Verification Confirm: Missing required profile data');
+ showErrorToast('Ошибка: заполните все обязательные поля');
+ return;
+ }
+
+ // Логируем перед отправкой запроса
+ log('OAuth Verification Confirm: provider=${widget.provider}, verificationType=$verificationType, code=$code, email=$email, phone=$phone');
+
+ // Отправляем запрос на подтверждение с полным набором данных от провайдера
+ await oauthCubit.confirmOAuthVerification(
+ provider: widget.provider ?? 'social',
+ accessToken: authResult.accessToken,
+ refreshToken: authResult.refreshToken ?? '',
+ clientId: authResult.clientId,
+ id: authResult.userId,
+ email: email,
+ phone: phone,
+ code: code,
+ verificationType: verificationType,
+ firstName: firstName,
+ lastName: lastName,
+ dateOfBirth: dateOfBirth,
+ gender: gender,
+ weight: weight,
+ height: height,
+ );
+
+ // Закрываем диалог
+ for (var controller in controllers) {
+ controller.dispose();
+ }
+ for (var node in focusNodes) {
+ node.dispose();
+ }
+ if (dialogContext.mounted) {
+ Navigator.of(dialogContext).pop();
+ }
+
+ // Перезагружаем веб-сокет и переходим на главный экран
+ if (widgetContext.mounted) {
+ _reconnectWebSocket(widgetContext);
+ }
+ } catch (e, stackTrace) {
+ log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);
+ if (widgetContext.mounted) {
+ showErrorToast('Неверный код. Попробуйте снова.');
+ }
+ // Очищаем поля
+ for (var controller in controllers) {
+ controller.clear();
+ }
+ if (focusNodes.isNotEmpty) {
+ focusNodes[0].requestFocus();
+ }
+ }
+ }
}
diff --git a/lib/features/auth/repositories/auth_repository.dart b/lib/features/auth/repositories/auth_repository.dart
index a65f309c..eedd295a 100644
--- a/lib/features/auth/repositories/auth_repository.dart
+++ b/lib/features/auth/repositories/auth_repository.dart
@@ -1,6 +1,10 @@
+import 'dart:developer';
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_request.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/models/token_model.dart';
import 'package:eatvio_app/features/auth/services/auth_service.dart';
@@ -19,7 +23,6 @@ class AuthRepository {
}
}
-
Future signInViaSocialMedia({
required String provider,
required String accessToken,
@@ -73,4 +76,67 @@ class AuthRepository {
rethrow;
}
}
+
+ Future requestOAuthVerification({String? email, String? phone}) async {
+ try {
+ Map? body;
+ if (email != null) {
+ body = {'email': email};
+ } else if (phone != null) {
+ body = {'phone': phone};
+ }
+ if (body != null) {
+ return await authService.requestOAuthVerification(body: body);
+ }
+ return null;
+ } catch (e) {
+ rethrow;
+ }
+ }
+
+ Future confirmOAuthVerification({
+ required String provider,
+ required String accessToken,
+ required String refreshToken,
+ required String clientId,
+ required String id,
+ String? email,
+ String? phone,
+ required String code,
+ required String verificationType,
+ required String firstName,
+ required String lastName,
+ required String dateOfBirth,
+ required String gender,
+ required int weight,
+ required int height,
+ }) async {
+ try {
+ final request = OAuthVerificationConfirmRequest(
+ provider: provider,
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ clientId: clientId,
+ id: id,
+ email: email,
+ phone: phone,
+ code: code,
+ verificationType: verificationType,
+ firstName: firstName,
+ lastName: lastName,
+ dateOfBirth: dateOfBirth,
+ gender: gender,
+ weight: weight,
+ height: height,
+ );
+ // Логируем запрос перед отправкой
+ log(
+ 'AuthRepository: Sending OAuth verification confirm request: provider=$provider, verificationType=$verificationType, code=$code, email=$email, phone=$phone',
+ );
+ return await authService.confirmOAuthVerification(body: request);
+ } catch (e) {
+ log('AuthRepository: Error in confirmOAuthVerification: $e');
+ rethrow;
+ }
+ }
}
diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart
index 41d35da7..2c7c6249 100644
--- a/lib/features/auth/services/auth_service.dart
+++ b/lib/features/auth/services/auth_service.dart
@@ -2,6 +2,9 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_request.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
+import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/models/token_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -19,4 +22,10 @@ abstract class AuthService {
@POST('/api/oauth/login')
Future signInViaSocialMedia({@Body() required OAuthLoginRequest body});
+
+ @POST('/api/oauth/verification/request')
+ Future requestOAuthVerification({@Body() required Map body});
+
+ @POST('/api/oauth/verification/confirm')
+ Future confirmOAuthVerification({@Body() required OAuthVerificationConfirmRequest body});
}
diff --git a/lib/features/barcode/models/barcode_product_model.dart b/lib/features/barcode/models/barcode_product_model.dart
new file mode 100644
index 00000000..99fd8cd7
--- /dev/null
+++ b/lib/features/barcode/models/barcode_product_model.dart
@@ -0,0 +1,29 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'barcode_product_model.freezed.dart';
+part 'barcode_product_model.g.dart';
+
+@freezed
+abstract class BarcodeProductModel with _$BarcodeProductModel {
+ const factory BarcodeProductModel({
+ // ignore: non_constant_identifier_names
+ required String GTIN,
+ @JsonKey(name: 'Товарный знак (ru)') String? brandName,
+ @JsonKey(name: 'Описание (ru)') String? description,
+ @JsonKey(name: 'Количество/вес') String? quantity,
+ @JsonKey(name: 'Рынок сбыта') String? market,
+ @JsonKey(name: 'Калорийность') double? calories,
+ @JsonKey(name: 'Белки') double? proteins,
+ @JsonKey(name: 'Жиры') double? fats,
+ @JsonKey(name: 'Углеводы') double? carbohydrates,
+ }) = _BarcodeProductModel;
+
+ factory BarcodeProductModel.fromJson(Map json) => _$BarcodeProductModelFromJson(json);
+}
+
+@freezed
+abstract class BarcodeTaskStatusModel with _$BarcodeTaskStatusModel {
+ const factory BarcodeTaskStatusModel({String? status, @JsonKey(name: 'task_id') String? taskId, BarcodeProductModel? data}) = _BarcodeTaskStatusModel;
+
+ factory BarcodeTaskStatusModel.fromJson(Map json) => _$BarcodeTaskStatusModelFromJson(json);
+}
diff --git a/lib/features/barcode/repositories/barcode_repository.dart b/lib/features/barcode/repositories/barcode_repository.dart
new file mode 100644
index 00000000..b83ace5c
--- /dev/null
+++ b/lib/features/barcode/repositories/barcode_repository.dart
@@ -0,0 +1,108 @@
+import 'package:dio/dio.dart';
+import 'package:eatvio_app/features/barcode/models/barcode_product_model.dart';
+import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
+
+class BarcodeRepository {
+ final BarcodeService barcodeService;
+ final Dio dio;
+
+ BarcodeRepository({
+ required this.barcodeService,
+ required this.dio,
+ });
+
+ /// Получить информацию о продукте по штрихкоду
+ /// Возвращает либо данные продукта, либо статус задачи обработки
+ Future getProductByBarcode(String barcode) async {
+ try {
+ final response = await dio.get('/api/gsone/barcode/$barcode');
+
+ if (response.statusCode == 200) {
+ // Данные найдены в БД
+ final product = BarcodeProductModel.fromJson(response.data as Map);
+ return BarcodeResult.success(product);
+ } else if (response.statusCode == 202) {
+ // Запущена задача обработки
+ final taskStatus = BarcodeTaskStatusModel.fromJson(response.data as Map);
+ return BarcodeResult.processing(taskStatus.taskId ?? '');
+ }
+
+ throw Exception('Unexpected status code: ${response.statusCode}');
+ } on DioException catch (e) {
+ if (e.response?.statusCode == 202) {
+ // Если получен статус 202, значит запущена задача обработки
+ final taskStatus = BarcodeTaskStatusModel.fromJson(e.response!.data as Map);
+ return BarcodeResult.processing(taskStatus.taskId ?? '');
+ }
+ rethrow;
+ } catch (e) {
+ rethrow;
+ }
+ }
+
+ /// Получить статус задачи обработки
+ Future getTaskStatus(String taskId) async {
+ try {
+ final statusModel = await barcodeService.getTaskStatus(taskId: taskId);
+
+ if (statusModel.status == 'completed' && statusModel.data != null) {
+ return BarcodeTaskStatus.completed(statusModel.data!);
+ } else if (statusModel.status == 'failed') {
+ return BarcodeTaskStatus.failed();
+ } else {
+ return BarcodeTaskStatus.processing();
+ }
+ } catch (e) {
+ rethrow;
+ }
+ }
+}
+
+/// Результат запроса информации о продукте по штрихкоду
+class BarcodeResult {
+ final BarcodeResultType type;
+ final BarcodeProductModel? product;
+ final String? taskId;
+
+ BarcodeResult._({required this.type, this.product, this.taskId});
+
+ factory BarcodeResult.success(BarcodeProductModel product) {
+ return BarcodeResult._(type: BarcodeResultType.success, product: product);
+ }
+
+ factory BarcodeResult.processing(String taskId) {
+ return BarcodeResult._(type: BarcodeResultType.processing, taskId: taskId);
+ }
+}
+
+enum BarcodeResultType {
+ success,
+ processing,
+}
+
+/// Статус задачи обработки
+class BarcodeTaskStatus {
+ final BarcodeTaskStatusType type;
+ final BarcodeProductModel? product;
+
+ BarcodeTaskStatus._({required this.type, this.product});
+
+ factory BarcodeTaskStatus.completed(BarcodeProductModel product) {
+ return BarcodeTaskStatus._(type: BarcodeTaskStatusType.completed, product: product);
+ }
+
+ factory BarcodeTaskStatus.processing() {
+ return BarcodeTaskStatus._(type: BarcodeTaskStatusType.processing);
+ }
+
+ factory BarcodeTaskStatus.failed() {
+ return BarcodeTaskStatus._(type: BarcodeTaskStatusType.failed);
+ }
+}
+
+enum BarcodeTaskStatusType {
+ completed,
+ processing,
+ failed,
+}
+
diff --git a/lib/features/barcode/services/barcode_service.dart b/lib/features/barcode/services/barcode_service.dart
new file mode 100644
index 00000000..e64e2575
--- /dev/null
+++ b/lib/features/barcode/services/barcode_service.dart
@@ -0,0 +1,24 @@
+import 'package:dio/dio.dart';
+import 'package:eatvio_app/features/barcode/models/barcode_product_model.dart';
+import 'package:retrofit/retrofit.dart';
+
+part 'barcode_service.g.dart';
+
+@RestApi()
+abstract class BarcodeService {
+ factory BarcodeService(Dio dio, {String baseUrl}) = _BarcodeService;
+
+ @GET('/api/gsone/barcode/{barcode}')
+ Future getProductByBarcode({@Path('barcode') required String barcode});
+
+ @GET('/api/gsone/barcode/status/{task_id}')
+ Future getTaskStatus({@Path('task_id') required String taskId});
+}
+
+
+
+
+
+
+
+
diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart
index 08312aeb..139f9d10 100644
--- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart
+++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart
@@ -1,4 +1,3 @@
-import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
@@ -79,7 +78,10 @@ class _BlogPostScreenState extends State {
return Scaffold(
resizeToAvoidBottomInset: true,
bottomSheet: Container(
- padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero),
+ padding: EdgeInsets.only(
+ top: 12,
+ bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar
+ ),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
@@ -195,7 +197,7 @@ class _BlogPostScreenState extends State {
],
),
body: SingleChildScrollView(
- padding: const EdgeInsets.only(bottom: 80),
+ padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
physics: const BouncingScrollPhysics(),
child: Column(
children: [
diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart
index 8dd8545a..c50a68f8 100644
--- a/lib/features/chat/models/chat_message_model.dart
+++ b/lib/features/chat/models/chat_message_model.dart
@@ -4,17 +4,36 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_message_model.freezed.dart';
part 'chat_message_model.g.dart';
+int _parseAuthorId(dynamic json) {
+ if (json == null) return 0;
+ if (json is num) return json.toInt();
+ return 0;
+}
+
+DateTime _parseUpdatedAt(dynamic json) {
+ if (json == null) return DateTime.now();
+ if (json is String) return parseDate(json);
+ return DateTime.now();
+}
+
+String _parseData(dynamic json) {
+ if (json == null) return '';
+ return json.toString();
+}
+
@freezed
abstract class ChatMessageModel with _$ChatMessageModel {
const factory ChatMessageModel({
required int id,
- required String data,
+ @JsonKey(name: 'data', fromJson: _parseData) required String data,
@JsonKey(name: 'media_data') required List? mediaData,
- @JsonKey(name: 'author_id') required int authorId,
- @JsonKey(name: 'updated_at', fromJson: parseDate) required DateTime updatedAt,
+ @JsonKey(name: 'urls_media_info') List