Merge pull request 'feat(socialAuth)' (#717) from misha/eatvio_app:main into main

Reviewed-on: eatvio/eatvio_app#717
This commit is contained in:
2025-10-06 13:48:47 +00:00
5 changed files with 228 additions and 103 deletions

View File

@@ -7,7 +7,7 @@ abstract class BaseCubit<State> extends Cubit<State> {
/// Унифицированная обработка ошибок
void handleError(Object error, [StackTrace? stackTrace]) {
log('Error in ${runtimeType}: $error', stackTrace: stackTrace);
log('Error in $runtimeType: $error', stackTrace: stackTrace);
// Здесь можно добавить отправку ошибок в Sentry или другую систему мониторинга
}

View File

@@ -0,0 +1,51 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:vk_id/vk_id.dart';
part 'auth_result_entity.freezed.dart';
@freezed
abstract class AuthResultEntity with _$AuthResultEntity {
const factory AuthResultEntity({
required String accessToken,
required String userId,
String? refreshToken,
String? firstName,
String? lastName,
String? email,
String? phone,
GenderEnum? gender,
String? birthDate,
}) = _AuthResultEntity;
}
enum GenderEnum {
male,
female,
notSet;
static GenderEnum? fromVkGender(VkSex? vkGender) {
switch (vkGender) {
case VkSex.male:
return GenderEnum.male;
case VkSex.female:
return GenderEnum.female;
case VkSex.notStated:
return GenderEnum.notSet;
case null:
return null;
}
}
static GenderEnum? fromString(String? gender) {
switch (gender) {
case 'male':
return GenderEnum.male;
case 'female':
return GenderEnum.female;
case 'notSet':
return GenderEnum.notSet;
default:
return null;
}
}
}

View File

@@ -0,0 +1,30 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'yandex_profile_model.freezed.dart';
part 'yandex_profile_model.g.dart';
@freezed
abstract class YandexProfileModel with _$YandexProfileModel {
factory YandexProfileModel({
required String id,
required String login,
required String birthday,
@JsonKey(name: 'client_id') required String clientId,
@JsonKey(name: 'display_name') required String displayName,
@JsonKey(name: 'real_name') required String realName,
@JsonKey(name: 'first_name') required String firstName,
@JsonKey(name: 'last_name') required String lastName,
@JsonKey(name: 'sex') required String sex,
@JsonKey(name: 'default_email') required String defaultEmail,
@JsonKey(name: 'default_phone') required YandexPhoneData defaultPhone,
}) = _YandexProfileModel;
factory YandexProfileModel.fromJson(Map<String, dynamic> json) => _$YandexProfileModelFromJson(json);
}
@freezed
abstract class YandexPhoneData with _$YandexPhoneData {
factory YandexPhoneData({required int id, required String number}) = _YandexPhoneData;
factory YandexPhoneData.fromJson(Map<String, dynamic> json) => _$YandexPhoneDataFromJson(json);
}

View File

@@ -1,6 +1,7 @@
// ignore_for_file: unused_element, duplicate_ignore
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
@@ -127,7 +128,7 @@ class _AuthScreenState extends State<AuthScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SocialButton(
onTap: () => _onVkPressed(context),
onTap: () => _onVkPressed(),
backgroundColor: const Color(0xFF2787F5),
tooltip: 'VK ID',
child: SvgPicture.asset('assets/icons/vk_logo.svg', height: 24, width: 24),
@@ -388,19 +389,14 @@ class _SocialButton extends StatelessWidget {
}
}
void _onVkPressed(BuildContext context) async {
void _onVkPressed() async {
try {
final accessToken = await SocialAuthFacade.signInWithVk(context: context);
final accessToken = await SocialAuthFacade.signInWithVk();
if (accessToken == null) {
return;
}
final isAuthenticated = await sl<AuthCubit>().getTokenBySocial(provider: 'vk', accessToken: accessToken);
if (isAuthenticated) {
if (AppRouter.navigatorKey.currentContext != null) {
await _reconnectAfterAuth();
}
}
} catch (e) {
log(e.toString());
}
}
@@ -410,11 +406,8 @@ void _onOkPressed() async {
if (accessToken == null) {
return;
}
final isAuthenticated = await sl<AuthCubit>().getTokenBySocial(provider: 'ok', accessToken: accessToken);
if (isAuthenticated) {
await _reconnectAfterAuth();
}
} catch (e) {
log(e.toString());
}
}
@@ -424,21 +417,14 @@ void _onMailRuPressed() async {
if (accessToken == null) {
return;
}
final isAuthenticated = await sl<AuthCubit>().getTokenBySocial(provider: 'mailru', accessToken: accessToken);
if (isAuthenticated) {
await _reconnectAfterAuth();
}
} catch (e) {
log(e.toString());
}
}
void _onYandexPressed() async {
final accessToken = await SocialAuthFacade.signInWithYandex();
if (accessToken == null) return;
final isAuthenticated = await sl<AuthCubit>().getTokenBySocial(provider: 'yandex', accessToken: accessToken);
if (isAuthenticated) {
await _reconnectAfterAuth();
}
}
Future<void> _reconnectAfterAuth() async {

View File

@@ -1,80 +1,125 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_login_yandex/flutter_login_yandex.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:http/http.dart' as http;
import 'package:vk_id/vk_id.dart';
import 'package:webview_flutter/webview_flutter.dart';
class VkAuthResult {
VkAuthResult({required this.accessToken, this.refreshToken, this.userId, this.firstName, this.lastName, this.email, this.phone, this.gender, this.birthDate});
final String accessToken;
final String? refreshToken;
final String? userId;
final String? firstName;
final String? lastName;
final String? email;
final String? phone;
final String? gender;
final String? birthDate;
}
class SocialAuthFacade {
static Future<String?> signInWithVk({required BuildContext context}) async {
static Future<String?> signInWithVk() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return null;
final result = await signInWithVkAndFetchProfile(context: context);
return result?.accessToken;
}
static Future<VkAuthResult?> signInWithVkAndFetchProfile({required BuildContext context}) async {
static Future<AuthResultEntity?> signInWithVkAndFetchProfile({required BuildContext context}) async {
final clientIdStr = EnvConfig.vkClientId.trim();
if (clientIdStr.isEmpty) {
return null;
}
try {
dynamic controller = VkIDController(clID: int.parse(clientIdStr));
final controller = VkIDController(clID: int.parse(clientIdStr));
final authUri = controller.generateAuthorizeLinkWithCodeVerifier();
String authUrl;
if (authUri is MapEntry) {
authUrl = authUri.value.toString();
} else {
authUrl = authUri.toString();
}
final authUrl = authUri.value.toString();
final codeVerifier = authUri.key;
final redirectScheme = 'vk$clientIdStr://';
final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUrl, redirectSchemePrefix: redirectScheme);
if (redirectParams == null) {
return null;
}
final code = redirectParams['code'];
final deviceId = redirectParams['device_id'];
if (code == null || deviceId == null) {
return null;
}
return VkAuthResult(accessToken: code);
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier);
} catch (e) {
return null;
}
}
static Future<AuthResultEntity?> _parseVKAuthResult({
required Map<String, String> redirectParams,
required VkIDController controller,
required String codeVerifier,
}) async {
final code = redirectParams['code'];
final deviceId = redirectParams['device_id'];
final state = redirectParams['state'];
if (code == null || deviceId == null || state == null) {
return null;
}
final authRes = await controller.retrieveOAuthToken(authorizationCode: code, deviceId: deviceId, codeVerifier: codeVerifier, state: state);
final auth = authRes.result;
if (auth == null) {
var msg = "Retrieve OAuth token error";
final err = authRes.error?.vkErr;
if (err != null) {
msg += ": ${err.error} - ${err.description}";
}
log(msg);
return null;
}
final profileRes = await controller.getProfileInfo();
final profile = profileRes.result;
if (profile == null) {
var msg = "Profile info load error";
final err = profileRes.error?.vkErr;
if (err != null) {
msg += ": ${err.error} - ${err.description}";
}
log(msg);
return null;
}
return AuthResultEntity(
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
userId: profile.userId.toString(),
firstName: profile.firstName,
lastName: profile.lastName,
email: profile.email,
phone: profile.phone,
gender: GenderEnum.fromVkGender(profile.parsedSex),
birthDate: profile.bDayDt?.toString(),
);
}
Future<Map<String, dynamic>?> exchangeCodeForToken(String code, String clientId, String clientSecret) async {
final response = await http.post(
Uri.parse('https://oauth.yandex.ru/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {'grant_type': 'authorization_code', 'code': code, 'client_id': clientId, 'client_secret': clientSecret},
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
log('Error exchanging code: ${response.body}');
}
return null;
}
static Future<String?> signInWithOk() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return null;
return _signInWithVkIdProvider(context: context, provider: 'ok');
final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu);
return vkAuthRes?.accessToken;
}
static Future<String?> signInWithMailRu() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return null;
return _signInWithVkIdProvider(context: context, provider: 'mail');
final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu);
return vkAuthRes?.accessToken;
}
static Future<String?> signInWithYandex() async {
static Future<AuthResultEntity?> signInWithYandex() async {
final clientId = EnvConfig.yandexClientId.trim();
final redirectUri = EnvConfig.yandexRedirectUri.trim();
@@ -87,7 +132,7 @@ class SocialAuthFacade {
if (context == null) return null;
final authorizeUrl = Uri.https('oauth.yandex.ru', '/authorize', {
'response_type': 'token',
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'force_confirm': 'yes',
@@ -99,62 +144,78 @@ class SocialAuthFacade {
if (params == null) return null;
final token = params['access_token'];
if (token != null && token.isNotEmpty) {
return token;
}
final code = params['code'];
if (code != null && code.isNotEmpty) {
return code;
}
return null;
} catch (e) {
try {
final plugin = FlutterLoginYandex();
final response = await plugin.signIn();
final token = (response is Map) ? response['token'] as String? : null;
return (token != null && token.isNotEmpty) ? token : null;
} catch (_) {
if (code == null || code.isEmpty) {
return null;
}
final tokens = await _getTokensFomYandex(code: code, clientId: clientId, clientSecret: EnvConfig.yandexClientSecret);
if (tokens == null) {
return null;
}
final response = await http.get(Uri.parse('https://login.yandex.ru/info?format=json'), headers: {'Authorization': 'OAuth ${tokens.accessToken}'});
final Map<String, dynamic> json = jsonDecode(response.body);
final yandexProfileData = YandexProfileModel.fromJson(json);
return AuthResultEntity(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
userId: yandexProfileData.id,
firstName: yandexProfileData.firstName,
lastName: yandexProfileData.lastName,
email: yandexProfileData.defaultEmail,
phone: yandexProfileData.defaultPhone.number,
gender: GenderEnum.fromString(yandexProfileData.sex),
birthDate: yandexProfileData.birthday,
);
} catch (e) {
log(e.toString());
}
return null;
}
static Future<String?> _signInWithVkIdProvider({required BuildContext context, required String provider}) async {
static Future<({String accessToken, String refreshToken})?> _getTokensFomYandex({
required String code,
required String clientId,
required String clientSecret,
}) async {
final response = await http.post(
Uri.parse('https://oauth.yandex.ru/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {'grant_type': 'authorization_code', 'code': code, 'client_id': clientId, 'client_secret': clientSecret},
);
if (response.statusCode != 200) {
log('Yandex token exchange failed: ${response.body}');
return null;
}
final tokens = jsonDecode(response.body);
final accessToken = tokens['access_token'] as String;
final refreshToken = tokens['refresh_token'] as String;
return (accessToken: accessToken, refreshToken: refreshToken);
}
static Future<AuthResultEntity?> _signInWithVkIdProvider({required BuildContext context, required VkProvider provider}) async {
final clientIdStr = EnvConfig.vkClientId.trim();
if (clientIdStr.isEmpty) {
return null;
}
try {
dynamic controller = VkIDController(clID: int.parse(clientIdStr));
final controller = VkIDController(clID: int.parse(clientIdStr));
dynamic authUri;
try {
authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider);
} catch (e) {
try {
authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: null);
} catch (e2) {
authUri = controller.generateAuthorizeLinkWithCodeVerifier();
}
}
// Extract URL from MapEntry
String authUrl;
if (authUri is MapEntry) {
authUrl = authUri.value.toString();
} else {
authUrl = authUri.toString();
}
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider);
final codeVerifier = authUri.key;
final authUrl = authUri.value.toString();
final redirectScheme = 'vk$clientIdStr://';
final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUrl, redirectSchemePrefix: redirectScheme);
if (redirectParams == null) return null;
final code = redirectParams['code'];
final deviceId = redirectParams['device_id'];
if (code == null || deviceId == null) return null;
return code;
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier);
} catch (e) {
return null;
}
@@ -190,12 +251,9 @@ class SocialAuthFacade {
}
return NavigationDecision.navigate;
},
onPageStarted: (url) {
},
onPageFinished: (url) {
},
onWebResourceError: (error) {
},
onPageStarted: (url) {},
onPageFinished: (url) {},
onWebResourceError: (error) {},
),
);
@@ -204,7 +262,7 @@ class SocialAuthFacade {
} catch (e) {
return null;
}
if (!context.mounted) return null;
return showDialog<Map<String, String>>(
context: context,
barrierDismissible: false,