Files
eatvio_app/lib/features/auth/presentation/pages/auth_screen.dart
alex-chevski cba2689406 feat(session): centralize session teardown and refresh
Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session.

Refs: #1113
2026-10-30 15:15:28 +03:00

498 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.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/di/cubit_registrations.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
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_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:eatvio_app/features/auth/sdk/social_auth_facade.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final emailController = TextEditingController();
final passwordController = TextEditingController();
bool checkboxState = false;
bool hidePassword = true;
final authCubit = sl<AuthCubit>();
late StreamSubscription<bool> keyboardSubscription;
bool isFocused = false;
int tapsCount = 0;
bool _isLoading = false;
@override
void initState() {
super.initState();
var keyboardVisibilityController = KeyboardVisibilityController();
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
if (mounted) {
setState(() {
isFocused = visible;
});
}
});
}
@override
void dispose() {
keyboardSubscription.cancel();
super.dispose();
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
void _onVkPressed() async {
try {
log('VK button pressed');
final context = AppRouter.navigatorKey.currentContext;
if (context == null) {
log('Context is null');
return;
}
log('Getting VK auth result');
final authResult = await SocialAuthFacade.signInWithVkAndFetchProfile(context: context);
if (authResult == null) {
log('VK auth result is null');
return;
}
log('VK auth result received, trying OAuth login');
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'vk');
} else {
log('Context is not mounted');
}
} catch (e) {
log('VK button error: $e');
}
}
void _onOkPressed() async {
try {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final authResult = await SocialAuthFacade.signInWithOkAndFetchProfile();
if (authResult == null) {
return;
}
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'vk');
}
} catch (e) {
log(e.toString());
}
}
void _onMailRuPressed() async {
try {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final authResult = await SocialAuthFacade.signInWithMailRuAndFetchProfile();
if (authResult == null) {
return;
}
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'vk');
}
} catch (e) {
log(e.toString());
}
}
void _onYandexPressed() async {
try {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final authResult = await SocialAuthFacade.signInWithYandex();
if (authResult == null) return;
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'yandex');
}
} catch (e) {
log(e.toString());
}
}
Future<void> _tryOAuthLogin(BuildContext context, AuthResultEntity authResult, String provider) async {
log('Starting OAuth login for provider: $provider');
try {
final oauthCubit = CubitRegistrations.sl<OAuthCubit>();
StreamSubscription? subscription;
subscription = oauthCubit.stream.listen((state) {
log('OAuth state changed: ${state.runtimeType}');
if (state is OAuthStateLoaded) {
log('OAuth login successful, navigating to main screen');
subscription?.cancel();
_reconnectAfterAuth();
} else if (state is OAuthStateError) {
log('OAuth login failed: ${state.errorMessage}');
subscription?.cancel();
AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider);
}
});
log('Calling getOAuthLogin with provider: $provider');
await oauthCubit.signInViaSocialMedia(
provider: provider,
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken ?? '',
clientId: authResult.clientId,
);
} catch (e) {
log('OAuth login error in catch block: $e');
AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider);
}
}
Future<void> _reconnectAfterAuth() async {
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@override
Widget build(BuildContext context) {
var shortestSide = MediaQuery.sizeOf(context).shortestSide;
final screenSize = MediaQuery.of(context).size;
final bool useMobileLayout = shortestSide < 600;
if (!useMobileLayout) isFocused = true;
final s = S.of(context);
return BlocProvider<AuthCubit>(
create: (BuildContext context) => authCubit..initializeUser(),
child: BlocBuilder<AuthCubit, AuthState>(
builder: (BuildContext context, AuthState state) {
return Scaffold(
appBar: AppBar(
toolbarHeight: screenSize.height * 0.15,
centerTitle: true,
title: GestureDetector(
key: AppKeys.hiddenLogoButtonAuthScreen,
onTap: () {
tapsCount++;
if (tapsCount == 7) {
tapsCount = 0;
GoRouter.of(context).pushNamed(DevToolsScreen.name);
}
},
child: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: screenSize.height * 0.20),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(child: Text('Авторизация через')),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SocialButton(
onTap: () => _onVkPressed(),
backgroundColor: const Color(0xFF2787F5),
tooltip: 'VK ID',
child: SvgPicture.asset('assets/icons/vk_logo.svg', height: 24, width: 24),
),
_SocialButton(
onTap: _onOkPressed,
backgroundColor: const Color(0xFFF58220),
tooltip: 'Одноклассники',
child: SvgPicture.asset('assets/icons/ok_logo.svg', height: 24, width: 24),
),
_SocialButton(
onTap: _onMailRuPressed,
backgroundColor: const Color(0xFF005FF9),
tooltip: 'Mail.ru',
child: SvgPicture.asset('assets/icons/mail_logo.svg', height: 24, width: 24),
),
_SocialButton(
onTap: _onYandexPressed,
backgroundColor: const Color(0xFFFFCC00),
tooltip: 'Яндекс ID',
child: SvgPicture.asset('assets/icons/yandex_logo.svg', height: 24, width: 24),
),
],
),
const SizedBox(height: 16),
const CustomDivider(),
const SizedBox(height: 8),
const SizedBox(height: 16),
Text(s.email),
const SizedBox(height: 4),
CustomTextFormField(
controller: emailController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
textInputType: TextInputType.emailAddress,
key: AppKeys.emailTextFieldAuthScreen,
hintText: 'Введите email',
),
const SizedBox(height: 16),
Text(s.password),
const SizedBox(height: 4),
CustomTextFormField(
obscureText: hidePassword,
controller: passwordController,
key: AppKeys.passwordTextFieldAuthScreen,
hintText: 'Введите пароль',
suffix: IconButton(
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
icon: Icon(hidePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, color: Colors.grey),
),
),
const SizedBox(height: 16),
Center(
child: GestureDetector(
onTap: () => GoRouter.of(context).goNamed('forgetPasswordScreen', extra: false),
child: Text(
'Забыли пароль?',
style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Нет аккаунта? '),
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('registrationScreenV2'),
child: Text(
'Зарегистрироваться',
style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline),
),
),
],
),
const Spacer(),
Center(
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: 'Нажимая "Войти" или "Зарегистрироваться", Вы соглашаетесь \n',
style: const TextStyle(fontSize: 12, color: Colors.black),
children: [
const TextSpan(text: 'c '),
if (!hidePlans) ...[
TextSpan(
text: ' Публичной офертой',
style: TextStyle(
fontSize: 12,
color: appTheme.blueA400,
decorationColor: appTheme.blueA400,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
),
const TextSpan(text: ' и '),
],
TextSpan(
text: 'Политикой конфиденциальности\nи защитой персональных данных',
style: TextStyle(
fontSize: 12,
color: appTheme.blueA400,
decorationColor: appTheme.blueA400,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}/docs/privacy.pdf')),
),
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
SizedBox(
height: 48,
width: MediaQuery.of(context).size.width - 88,
child: FloatingActionButton.extended(
key: AppKeys.signInButtonAuthScreen,
heroTag: 'authButton',
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: (state is AuthStateLoading || _isLoading) ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: (state is AuthStateLoading || _isLoading)
? null
: () async {
setState(() {
_isLoading = true;
});
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket();
}
}
setState(() {
_isLoading = false;
});
},
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
),
const SizedBox(width: 8),
SizedBox(
height: 48,
width: 48,
child: FloatingActionButton.extended(
heroTag: 'scanQrCodeButton',
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: state is AuthStateLoading || _isLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: state is AuthStateLoading || _isLoading
? null
: () async {
await showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Где взять QR-код?\n', style: TextStyle(fontSize: 18)),
Text(
'1. Войдите на сайте ${sl<AppConfig>().baseUrl} в свой аккаунт\n\n2. Перейдите в настройки профиля\n\n3. В разделе "Доп информация" перейдите на "QR Login"',
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
AppRouter.navigatorKey.currentContext?.pop();
},
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: const Text(
'Отмена',
style: TextStyle(color: Colors.black, fontWeight: FontWeight.w400),
),
),
),
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
AppRouter.navigatorKey.currentContext?.pop();
AppRouter.navigatorKey.currentContext?.pushNamed('scanQRCodeScreen');
},
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
child: Text(
'Перейти',
style: TextStyle(color: appTheme.blueA400, fontWeight: FontWeight.w600),
),
),
),
],
),
],
),
),
);
},
);
},
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: const Icon(Icons.qr_code_scanner_rounded, color: Colors.white),
),
),
],
),
],
),
),
),
);
},
),
);
}
}
class _SocialButton extends StatelessWidget {
const _SocialButton({required this.onTap, required this.child, required this.backgroundColor, required this.tooltip});
final VoidCallback onTap;
final Widget child;
final Color backgroundColor;
final String tooltip;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 48,
width: (MediaQuery.of(context).size.width - 16 * 2 - 12 * 3) / 4,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFDBDBDB), width: 1),
),
child: Center(child: child),
),
),
);
}
}