From 183fa012d2f3f68b8cb155471a9b510d2b9f42b4 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Thu, 9 Oct 2025 17:24:36 +0300 Subject: [PATCH] feat(auth): Issue-691 --- lib/core/app_router.dart | 5 + lib/core/di/cubit_registrations.dart | 3 +- lib/core/routing/auth_routes.dart | 10 +- lib/features/auth/models/oauth_model.dart | 24 + .../bloc/oauth_cubit/oauth_cubit.dart | 44 ++ .../bloc/oauth_cubit/oauth_state.dart | 14 + .../auth/presentation/pages/auth_screen.dart | 33 +- .../pages/register_via_social_screen.dart | 652 +++++++++++++----- .../auth/repositories/auth_repository.dart | 52 +- lib/features/auth/sdk/social_auth_facade.dart | 12 +- lib/features/auth/services/auth_service.dart | 6 + 11 files changed, 637 insertions(+), 218 deletions(-) create mode 100644 lib/features/auth/models/oauth_model.dart create mode 100644 lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart create mode 100644 lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index f3715464..26355055 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/routing/main_navigation_routes.dart'; import 'package:eatvio_app/core/routing/other_routes.dart'; import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/routing/settings_routes.dart'; +import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:go_router/go_router.dart'; @@ -50,4 +51,8 @@ class AppRouter { return null; } + + static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) { + appRouter.goNamed('registerViaSocialScreen', extra: {'authResult': authResult, 'provider': provider}); + } } diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index ec2de9ce..bd9014e6 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/core/views/cubit/views_cubit.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; +import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; @@ -73,7 +74,7 @@ class CubitRegistrations { sl.registerFactory(() => RegistrationCubit(registrationRepository: sl())); sl.registerFactory(() => ResetPasswordCubit(resetPasswordRepository: sl())); sl.registerFactory(() => VerifyEmailCubit(profileRepository: sl())); - + sl.registerFactory(() => OAuthCubit(authRepository: sl())); // Feed related cubits sl.registerFactory(() => FeedCubit(feedRepository: sl())); sl.registerFactory(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl())); diff --git a/lib/core/routing/auth_routes.dart b/lib/core/routing/auth_routes.dart index 67ecbca6..b94cc2c8 100644 --- a/lib/core/routing/auth_routes.dart +++ b/lib/core/routing/auth_routes.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; +import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/features/auth/presentation/pages/auth_screen.dart'; import 'package:eatvio_app/features/auth/presentation/pages/forget_password_screen.dart'; import 'package:eatvio_app/features/auth/presentation/pages/register_via_social_screen.dart'; @@ -33,7 +34,14 @@ class AuthRoutes { builder: (context, state) => VerifyScreen(passKey: state.extra as String), ), GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()), - GoRoute(path: RouteConstants.registerViaSocialScreen, name: 'registerViaSocialScreen', builder: (context, state) => const RegisterViaSocialScreen()), + GoRoute( + path: RouteConstants.registerViaSocialScreen, + name: 'registerViaSocialScreen', + builder: (context, state) { + final extra = state.extra as Map?; + return RegisterViaSocialScreen(authResult: extra?['authResult'] as AuthResultEntity?, provider: extra?['provider'] as String?); + }, + ), ]; } } diff --git a/lib/features/auth/models/oauth_model.dart b/lib/features/auth/models/oauth_model.dart new file mode 100644 index 00000000..aff79b5f --- /dev/null +++ b/lib/features/auth/models/oauth_model.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'oauth_model.freezed.dart'; +part 'oauth_model.g.dart'; + +@freezed +abstract class OAuthModel with _$OAuthModel { + const factory OAuthModel({ + required String provider, + required String id, + @JsonKey(name: 'access_token') required String accessToken, + @JsonKey(name: 'refresh_token') required String refreshToken, + @JsonKey(name: 'first_name') required String firstName, + @JsonKey(name: 'last_name') required String lastName, + required String email, + required String phone, + @JsonKey(name: 'date_of_birth') required String dateOfBirth, + required String gender, + required int weight, + required int height, + }) = _OAuthModel; + + factory OAuthModel.fromJson(Map json) => _$OAuthModelFromJson(json); +} diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart new file mode 100644 index 00000000..8d73b195 --- /dev/null +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart @@ -0,0 +1,44 @@ +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'; + +class OAuthCubit extends Cubit { + final AuthRepository authRepository; + + OAuthCubit({required this.authRepository}) : super(const OAuthState()); + + Future getOAuthToken({ + required String provider, + required String accessToken, + required String id, + required String refreshToken, + required String firstName, + required String lastName, + required String email, + required String phone, + required String dateOfBirth, + required String gender, + required int weight, + required int height, + }) async { + try { + final token = await authRepository.getOAuthToken( + provider: provider, + accessToken: accessToken, + id: id, + refreshToken: refreshToken, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + dateOfBirth: dateOfBirth, + gender: gender, + weight: weight, + height: height, + ); + emit(OAuthState.loaded(token: token)); + } catch (e) { + emit(OAuthState.error(errorMessage: e.toString())); + } + } +} diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart new file mode 100644 index 00000000..7da9ab7c --- /dev/null +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'oauth_state.freezed.dart'; + +@freezed +abstract class OAuthState with _$OAuthState { + const factory OAuthState() = _OAuthState; + + const factory OAuthState.loading() = OAuthStateLoading; + + const factory OAuthState.loaded({required String token}) = OAuthStateLoaded; + + const factory OAuthState.error({required String errorMessage}) = OAuthStateError; +} diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index 6f49ea2a..13713922 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -391,10 +391,14 @@ class _SocialButton extends StatelessWidget { void _onVkPressed() async { try { - final accessToken = await SocialAuthFacade.signInWithVk(); - if (accessToken == null) { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return; + + final authResult = await SocialAuthFacade.signInWithVkAndFetchProfile(context: context); + if (authResult == null) { return; } + AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'vk'); } catch (e) { log(e.toString()); } @@ -402,10 +406,14 @@ void _onVkPressed() async { void _onOkPressed() async { try { - final accessToken = await SocialAuthFacade.signInWithOk(); - if (accessToken == null) { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return; + + final authResult = await SocialAuthFacade.signInWithOkAndFetchProfile(); + if (authResult == null) { return; } + AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'ok'); } catch (e) { log(e.toString()); } @@ -413,18 +421,27 @@ void _onOkPressed() async { void _onMailRuPressed() async { try { - final accessToken = await SocialAuthFacade.signInWithMailRu(); - if (accessToken == null) { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return; + + final authResult = await SocialAuthFacade.signInWithMailRuAndFetchProfile(); + if (authResult == null) { return; } + AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'mailru'); } catch (e) { log(e.toString()); } } void _onYandexPressed() async { - final accessToken = await SocialAuthFacade.signInWithYandex(); - if (accessToken == null) return; + try { + final authResult = await SocialAuthFacade.signInWithYandex(); + if (authResult == null) return; + AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'yandex'); + } catch (e) { + log(e.toString()); + } } Future _reconnectAfterAuth() async { 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 211b9eae..22fa5d18 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -1,13 +1,29 @@ import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/core/di/cubit_registrations.dart'; +import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart'; +import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; +import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; import 'package:url_launcher/url_launcher.dart'; class RegisterViaSocialScreen extends StatefulWidget { - const RegisterViaSocialScreen({super.key}); + final AuthResultEntity? authResult; + final String? provider; + + const RegisterViaSocialScreen({super.key, this.authResult, this.provider}); @override State createState() => _RegisterViaSocialScreenState(); @@ -17,201 +33,421 @@ class _RegisterViaSocialScreenState extends State { final maskFormatter = MaskTextInputFormatter(mask: '+7 (###) ### ## ##', filter: {"#": RegExp(r'[0-9]')}); final maskFormatterForBirthday = MaskTextInputFormatter(mask: '##.##.####', filter: {"#": RegExp(r'[0-9]')}); + // Контроллеры для полей ввода + final firstNameController = TextEditingController(); + final lastNameController = TextEditingController(); + final emailController = TextEditingController(); + final phoneController = TextEditingController(); + final dateOfBirthController = TextEditingController(); + final weightController = TextEditingController(); + final heightController = TextEditingController(); + + // Состояние для выбора пола + bool isMaleSelected = true; + + @override + void initState() { + super.initState(); + _initializeFieldsFromAuthResult(); + } + + Future _reconnectWebSocket(BuildContext context) async { + try { + await sl().clear(); + await sl().initialize(); + } catch (e) { + rethrow; + } + if (context.mounted) { + try { + await sl().getMyProfile(); + await sl().getChatRoomsWithLoading(); + await sl().connectToWebSocket(); + await sl().getNotifications(); + await sl().connectToWebSocket(); + await sl().registerPushToken(); + showSuccessToast('Успешная регистрация'); + } catch (e) { + rethrow; + } + } + AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); + } + + void _initializeFieldsFromAuthResult() { + if (widget.authResult != null) { + final authResult = widget.authResult!; + + // Логируем полученные данные для отладки + print('AuthResult data:'); + print('firstName: ${authResult.firstName}'); + print('lastName: ${authResult.lastName}'); + print('email: ${authResult.email}'); + print('phone: ${authResult.phone}'); + print('birthDate: ${authResult.birthDate}'); + print('gender: ${authResult.gender}'); + print('provider: ${widget.provider}'); + + // Предзаполняем поля из AuthResultEntity + if (authResult.firstName != null) { + firstNameController.text = authResult.firstName!; + } + if (authResult.lastName != null) { + lastNameController.text = authResult.lastName!; + } + if (authResult.email != null) { + emailController.text = authResult.email!; + } + if (authResult.phone != null) { + phoneController.text = authResult.phone!; + } + if (authResult.birthDate != null) { + dateOfBirthController.text = _formatBirthDate(authResult.birthDate!); + } + if (authResult.gender != null) { + isMaleSelected = authResult.gender == GenderEnum.male; + } + } + } + + String _formatBirthDate(String birthDate) { + try { + // Пытаемся распарсить различные форматы даты + DateTime? date; + + // Если дата в формате ISO (YYYY-MM-DD) + if (birthDate.contains('-')) { + date = DateTime.tryParse(birthDate); + } + // Если дата в формате DD.MM.YYYY + else if (birthDate.contains('.')) { + final parts = birthDate.split('.'); + if (parts.length == 3) { + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day != null && month != null && year != null) { + date = DateTime(year, month, day); + } + } + } + + if (date != null) { + // Форматируем в DD.MM.YYYY + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + final year = date.year.toString(); + return '$day.$month.$year'; + } + } catch (e) { + // Если не удалось распарсить, возвращаем исходную строку + } + + return birthDate; + } + + String _getProviderName(String? provider) { + switch (provider) { + case 'vk': + return 'ВКонтакте'; + case 'yandex': + return 'Яндекс'; + case 'ok': + return 'Одноклассники'; + case 'mailru': + return 'Mail.ru'; + default: + return 'социальной сети'; + } + } + + String _getDataStatusMessage(AuthResultEntity authResult) { + final hasEmail = authResult.email != null && authResult.email!.isNotEmpty; + final hasPhone = authResult.phone != null && authResult.phone!.isNotEmpty; + final hasBirthDate = authResult.birthDate != null && authResult.birthDate!.isNotEmpty; + + if (hasEmail && hasPhone && hasBirthDate) { + return 'Все данные успешно получены и предзаполнены.'; + } else if (hasEmail && hasPhone) { + return 'Email и номер телефона получены. Дата рождения может быть недоступна из-за настроек приватности.'; + } else if (hasEmail) { + return 'Email получен. Номер телефона и дата рождения могут быть недоступны из-за настроек приватности.'; + } else if (hasPhone) { + return 'Номер телефона получен. Email и дата рождения могут быть недоступны из-за настроек приватности.'; + } else { + // Специальное сообщение для VK + if (widget.provider == 'vk') { + return 'Основные данные получены. Email запрашивается автоматически. Номер телефона получается через VK API. Если номер замаскирован, это означает, что пользователь ограничил доступ к нему в настройках приватности.'; + } else { + return 'Основные данные получены. Email, номер телефона и дата рождения могут быть недоступны из-за настроек приватности.'; + } + } + } + + @override + void dispose() { + firstNameController.dispose(); + lastNameController.dispose(); + emailController.dispose(); + phoneController.dispose(); + dateOfBirthController.dispose(); + weightController.dispose(); + heightController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(toolbarHeight: 0), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 24), - const Center( - child: Text( - 'Регистрация', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1, color: Color(0xFF323F4B)), - ), - ), - const SizedBox(height: 24), - Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Имя'), - const SizedBox(height: 4), - CustomTextFormField(controller: TextEditingController(), hintText: 'Введите имя', onChanged: (newValue) {}), - ], + return BlocProvider( + create: (context) => CubitRegistrations.sl(), + child: Scaffold( + appBar: AppBar(toolbarHeight: 0), + body: BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + const Center( + child: Text( + 'Регистрация', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1, color: Color(0xFF323F4B)), + ), ), - ), - const SizedBox(width: 12), - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 24), + Row( children: [ - const Text('Фамилия'), - const SizedBox(height: 4), - CustomTextFormField(controller: TextEditingController(), hintText: 'Введите фамилию', onChanged: (newValue) {}), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - const Text('Почта'), - const SizedBox(height: 4), - CustomTextFormField( - controller: TextEditingController(), - textInputType: TextInputType.emailAddress, - keyboardType: TextInputType.emailAddress, - hintText: 'Введите email', - textInputAction: TextInputAction.next, - onChanged: (newValue) {}, - ), - const SizedBox(height: 12), - const Text('Номер телефона'), - const SizedBox(height: 4), - CustomTextFormField( - textInputAction: TextInputAction.done, - hintText: '+7 (999) 999 99 99', - controller: TextEditingController(), - keyboardType: TextInputType.number, - inputFormatters: [maskFormatter], - onChanged: (newValue) {}, - ), - const SizedBox(height: 12), - Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Дата рождения'), - const SizedBox(height: 4), - CustomTextFormField( - textInputAction: TextInputAction.done, - hintText: '01.01.2000', - controller: TextEditingController(), - keyboardType: TextInputType.number, - inputFormatters: [maskFormatterForBirthday], - onChanged: (newValue) {}, + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Имя'), + const SizedBox(height: 4), + CustomTextFormField(controller: firstNameController, hintText: 'Введите имя', onChanged: (newValue) {}), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Фамилия'), + const SizedBox(height: 4), + CustomTextFormField(controller: lastNameController, hintText: 'Введите фамилию', onChanged: (newValue) {}), + ], + ), ), ], ), - ), - const SizedBox(width: 12), - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 12), + const Text('Почта'), + const SizedBox(height: 4), + CustomTextFormField( + controller: emailController, + textInputType: TextInputType.emailAddress, + keyboardType: TextInputType.emailAddress, + hintText: 'Введите email', + textInputAction: TextInputAction.next, + onChanged: (newValue) {}, + ), + const SizedBox(height: 12), + const Text('Номер телефона'), + const SizedBox(height: 4), + CustomTextFormField( + textInputAction: TextInputAction.done, + hintText: '+7 (999) 999 99 99', + controller: phoneController, + keyboardType: TextInputType.number, + inputFormatters: [maskFormatter], + onChanged: (newValue) {}, + ), + const SizedBox(height: 12), + Row( children: [ - const Text('Пол'), - const SizedBox(height: 4), - Row( + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Дата рождения'), + const SizedBox(height: 4), + CustomTextFormField( + textInputAction: TextInputAction.done, + hintText: '01.01.2000', + controller: dateOfBirthController, + keyboardType: TextInputType.number, + inputFormatters: [maskFormatterForBirthday], + onChanged: (newValue) {}, + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Пол'), + const SizedBox(height: 4), + Row( + children: [ + Expanded(child: _genderButton('М', isMaleSelected)), + const SizedBox(width: 4), + Expanded(child: _genderButton('Ж', !isMaleSelected)), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Вес'), + const SizedBox(height: 4), + CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: MediaQuery.of(context).size.width / 2 - 22, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Рост'), + const SizedBox(height: 4), + CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}), + ], + ), + ), + ], + ), + const SizedBox(height: 28), + const Center( + child: Text( + 'Проверьте правильность заполненных\nданных. Пол, рост и вес требуются для\nрасчета вашей дневной нормы калорий', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 28), + InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _handleRegistration(context), + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration(color: state is OAuthStateLoading ? Colors.grey : appTheme.blueA400, borderRadius: BorderRadius.circular(20)), + child: Center( + child: state is OAuthStateLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'Зарегистрироваться', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.white), + ), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: 'Нажимая "Зарегистрироваться", Вы соглашаетесь\n', + style: const TextStyle(fontSize: 16, color: Colors.black), children: [ - Expanded(child: _genderButton('М', true)), - const SizedBox(width: 4), - Expanded(child: _genderButton('Ж', false)), + const TextSpan( + text: 'c ', + style: TextStyle(fontSize: 16, color: Colors.black), + ), + if (!hidePlans) ...[ + TextSpan( + text: ' Публичной офертой', + style: TextStyle( + fontSize: 16, + color: appTheme.blueA400, + decorationColor: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), + ), + const TextSpan( + text: ' и ', + style: TextStyle(fontSize: 16, color: Colors.black), + ), + ], + TextSpan( + text: 'Политикой конфиденциальности и защитой персональных данных', + style: TextStyle(fontSize: 16, color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/docs/privacy.pdf')), + ), ], ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Вес'), - const SizedBox(height: 4), - CustomTextFormField(controller: TextEditingController(), hintText: 'Введите вес', onChanged: (newValue) {}), - ], - ), - ), - const SizedBox(width: 12), - SizedBox( - width: MediaQuery.of(context).size.width / 2 - 22, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Рост'), - const SizedBox(height: 4), - CustomTextFormField(controller: TextEditingController(), hintText: 'Введите рост', onChanged: (newValue) {}), - ], - ), - ), - ], - ), - const SizedBox(height: 28), - const Center( - child: Text( - 'Проверьте правильность заполненных\nданных. Пол, рост и вес требуются для\nрасчета вашей дневной нормы калорий', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 28), - InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () {}, - 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: 18, color: Colors.white), - ), - ), - ), - ), - const SizedBox(height: 16), - Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - text: 'Нажимая "Зарегистрироваться", Вы соглашаетесь\n', - style: const TextStyle(fontSize: 16, color: Colors.black), - children: [ - const TextSpan( - text: 'c ', - style: TextStyle(fontSize: 16, color: Colors.black), ), - if (!hidePlans) ...[ - TextSpan( - text: ' Публичной офертой', - style: TextStyle(fontSize: 16, color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), + ), + // Показываем ошибки если есть + if (state is OAuthStateError) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), ), - const TextSpan( - text: ' и ', - style: TextStyle(fontSize: 16, color: Colors.black), + child: Text( + state.errorMessage, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, ), - ], - TextSpan( - text: 'Политикой конфиденциальности и защитой персональных данных', - style: TextStyle(fontSize: 16, color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/docs/privacy.pdf')), ), ], - ), + // Обработка успешной регистрации + if (state is OAuthStateLoaded) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: const Text( + 'Регистрация успешно завершена!', + style: TextStyle(color: Colors.green), + textAlign: TextAlign.center, + ), + ), + // Автоматический переход на главный экран после успешной регистрации + Builder( + builder: (context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _reconnectWebSocket(context); + }); + return const SizedBox.shrink(); + }, + ), + ], + ], ), - ), - ], + ); + }, ), ), ); @@ -219,29 +455,67 @@ class _RegisterViaSocialScreenState extends State { Widget _genderButton(String gender, bool isActive) { return InkWell( - onTap: () {}, + onTap: () { + setState(() { + isMaleSelected = gender == 'М'; + }); + }, borderRadius: BorderRadius.circular(20), child: Ink( height: 52, decoration: BoxDecoration( color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20), - border: isActive ? Border.all( - width: 2, - color: appTheme.blueA400, - ) : null, + border: isActive ? Border.all(width: 2, color: appTheme.blueA400) : null, ), child: Center( child: Text( gender, - style: TextStyle( - color: isActive ? appTheme.blueA400 : const Color(0xFF323F4B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: TextStyle(color: isActive ? appTheme.blueA400 : const Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w500), ), ), ), ); } + + void _handleRegistration(BuildContext context) { + // Валидация полей + if (firstNameController.text.isEmpty || + lastNameController.text.isEmpty || + emailController.text.isEmpty || + phoneController.text.isEmpty || + dateOfBirthController.text.isEmpty || + weightController.text.isEmpty || + heightController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Пожалуйста, заполните все поля'))); + return; + } + + // Парсим числовые значения + int? weight; + int? height; + try { + weight = int.parse(weightController.text); + height = int.parse(heightController.text); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Вес и рост должны быть числами'))); + return; + } + + // Вызываем OAuth регистрацию + context.read().getOAuthToken( + provider: widget.provider ?? 'social', + accessToken: widget.authResult?.accessToken ?? 'dummy_token', + id: widget.authResult?.userId ?? 'dummy_id', + refreshToken: widget.authResult?.refreshToken ?? 'dummy_refresh_token', + firstName: firstNameController.text, + lastName: lastNameController.text, + email: emailController.text, + phone: phoneController.text, + dateOfBirth: dateOfBirthController.text, + gender: isMaleSelected ? 'М' : 'Ж', + weight: weight, + height: height, + ); + } } diff --git a/lib/features/auth/repositories/auth_repository.dart b/lib/features/auth/repositories/auth_repository.dart index 84df92f6..bddebce1 100644 --- a/lib/features/auth/repositories/auth_repository.dart +++ b/lib/features/auth/repositories/auth_repository.dart @@ -1,4 +1,5 @@ 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/token_model.dart'; import 'package:eatvio_app/features/auth/services/auth_service.dart'; import 'package:eatvio_app/features/auth/models/social_exchange_request.dart'; @@ -8,26 +9,17 @@ class AuthRepository { AuthRepository({required this.authService}); - Future getTokenByCredentials( - String email, - String password, - ) async { + Future getTokenByCredentials(String email, String password) async { try { return await authService.getTokenByCredentials( - credentials: CredentialsModel( - email: email, - password: password, - ), + credentials: CredentialsModel(email: email, password: password), ); } catch (e) { rethrow; } } - Future getTokenBySocial({ - required String provider, - required String accessToken, - }) async { + Future getTokenBySocial({required String provider, required String accessToken}) async { try { return await authService.getTokenBySocial( body: SocialExchangeRequest(provider: provider, accessToken: accessToken), @@ -36,4 +28,40 @@ class AuthRepository { rethrow; } } + + Future getOAuthToken({ + required String provider, + required String accessToken, + required String id, + required String refreshToken, + required String firstName, + required String lastName, + required String email, + required String phone, + required String dateOfBirth, + required String gender, + required int weight, + required int height, + }) async { + try { + return (await authService.getOAuthToken( + body: OAuthModel( + provider: provider, + accessToken: accessToken, + id: id, + refreshToken: refreshToken, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone, + dateOfBirth: dateOfBirth, + gender: gender == 'М' ? 'male' : 'female', + weight: weight, + height: height, + ), + ))['token']! as String; + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/auth/sdk/social_auth_facade.dart b/lib/features/auth/sdk/social_auth_facade.dart index a235f03d..ada242a9 100644 --- a/lib/features/auth/sdk/social_auth_facade.dart +++ b/lib/features/auth/sdk/social_auth_facade.dart @@ -27,7 +27,7 @@ class SocialAuthFacade { try { final controller = VkIDController(clID: int.parse(clientIdStr)); - final authUri = controller.generateAuthorizeLinkWithCodeVerifier(); + final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: VkProvider.vkID, scopes: [VkScope.email, VkScope.phone]); final authUrl = authUri.value.toString(); final codeVerifier = authUri.key; @@ -105,18 +105,16 @@ class SocialAuthFacade { return null; } - static Future signInWithOk() async { + static Future signInWithOkAndFetchProfile() async { final context = AppRouter.navigatorKey.currentContext; if (context == null) return null; - final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu); - return vkAuthRes?.accessToken; + return await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu); } - static Future signInWithMailRu() async { + static Future signInWithMailRuAndFetchProfile() async { final context = AppRouter.navigatorKey.currentContext; if (context == null) return null; - final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu); - return vkAuthRes?.accessToken; + return await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu); } static Future signInWithYandex() async { diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index 7dabb65b..3d8d43e2 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -1,5 +1,6 @@ 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/token_model.dart'; import 'package:eatvio_app/features/auth/models/social_exchange_request.dart'; import 'package:retrofit/retrofit.dart'; @@ -19,4 +20,9 @@ abstract class AuthService { Future getTokenBySocial({ @Body() required SocialExchangeRequest body, }); + + @POST('/api/oauth/callback') + Future> getOAuthToken({ + @Body() required OAuthModel body, + }); }