forked from eatvio/eatvio_app
Merge remote-tracking branch 'upstream/main' into issue-1089
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
|
||||
class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 405) {
|
||||
final data = err.response?.data;
|
||||
|
||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
||||
await showErrorDialog(
|
||||
AppRouter.navigatorKey.currentContext!,
|
||||
title: 'Привяжите номер телефона',
|
||||
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||
);
|
||||
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||
|
||||
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
|
||||
GoRouter.of(currentContext).pop();
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
||||
return handler.reject(
|
||||
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
|
||||
);
|
||||
}
|
||||
if (err.response?.statusCode != 405) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final requirement = VerificationRequirement.tryParse(err.response?.data);
|
||||
if (requirement == null) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
|
||||
if (requirement.needsEmail) {
|
||||
await _handleEmailVerificationRequired();
|
||||
} else {
|
||||
await _handlePhoneVerificationRequired();
|
||||
}
|
||||
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
error: 'Phone number not verified',
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
|
||||
Future<void> _handlePhoneVerificationRequired() async {
|
||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||
final String passKey = await sl<ProfileRepository>().getPassKey(
|
||||
routeName: 'user_detailz.change-phone-verify',
|
||||
routeParams: {"phone": phone},
|
||||
);
|
||||
await showErrorDialog(
|
||||
AppRouter.navigatorKey.currentContext!,
|
||||
title: 'Привяжите номер телефона',
|
||||
description:
|
||||
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||
);
|
||||
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||
|
||||
if (currentContext != null &&
|
||||
currentContext.mounted &&
|
||||
GoRouter.of(currentContext).state.path ==
|
||||
RouteConstants.stepperBecomeConsultantScreen) {
|
||||
GoRouter.of(currentContext).pop();
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed(
|
||||
'verifyScreen',
|
||||
extra: passKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
|
||||
Future<void> _handleEmailVerificationRequired() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
|
||||
if (context == null || !context.mounted) {
|
||||
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
|
||||
return;
|
||||
}
|
||||
|
||||
await showErrorDialog(
|
||||
context,
|
||||
title: 'Подтвердите e-mail',
|
||||
description:
|
||||
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pushNamed('verifyEmailScreen', extra: true);
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// Канал верификации, которого не хватает для публичного действия.
|
||||
enum VerificationChannel { phone, email }
|
||||
|
||||
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||
///
|
||||
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||
class VerificationRequirement {
|
||||
const VerificationRequirement({required this.missing});
|
||||
|
||||
/// Каналы, подтверждение которых требует бэкенд.
|
||||
final List<VerificationChannel> missing;
|
||||
|
||||
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||
|
||||
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||
|
||||
/// Возвращает null, если ответ не является отказом верификации.
|
||||
static VerificationRequirement? tryParse(Object? data) {
|
||||
if (data is! Map) {
|
||||
return null;
|
||||
}
|
||||
if (data['type'] != 'unverificated_phone') {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Object? rawMissing = data['missing'];
|
||||
if (rawMissing is! List) {
|
||||
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||
return const VerificationRequirement(
|
||||
missing: [VerificationChannel.phone],
|
||||
);
|
||||
}
|
||||
|
||||
var hasPhone = false;
|
||||
var hasEmail = false;
|
||||
for (final Object? value in rawMissing) {
|
||||
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||
if (value == 'phone') {
|
||||
hasPhone = true;
|
||||
} else if (value == 'email') {
|
||||
hasEmail = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||
if (!hasPhone && !hasEmail) {
|
||||
return const VerificationRequirement(
|
||||
missing: [VerificationChannel.phone],
|
||||
);
|
||||
}
|
||||
|
||||
return VerificationRequirement(
|
||||
missing: [
|
||||
if (hasPhone) VerificationChannel.phone,
|
||||
if (hasEmail) VerificationChannel.email,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class AuthRoutes {
|
||||
name: 'verifyScreen',
|
||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||
),
|
||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
|
||||
GoRoute(
|
||||
path: RouteConstants.registerViaSocialScreen,
|
||||
name: 'registerViaSocialScreen',
|
||||
|
||||
@@ -15,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class VerifyEmailScreen extends StatefulWidget {
|
||||
const VerifyEmailScreen({super.key});
|
||||
const VerifyEmailScreen({super.key, this.returnOnSuccess = false});
|
||||
|
||||
/// Вернуться на предыдущий экран вместо перехода в ленту после подтверждения.
|
||||
/// Используется, когда экран открыт из публичного действия (пост, лайк, комментарий).
|
||||
final bool returnOnSuccess;
|
||||
|
||||
@override
|
||||
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
|
||||
@@ -89,7 +93,12 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
||||
_startTimer();
|
||||
} else if (state is VerifyEmailStateVerifySuccess) {
|
||||
showSuccessToast('Почта успешно подтверждена');
|
||||
goRouter.pushNamed('feedScreen');
|
||||
if (widget.returnOnSuccess) {
|
||||
// Экран открыт из публичного действия — возвращаемся назад с результатом.
|
||||
goRouter.pop(true);
|
||||
} else {
|
||||
goRouter.pushNamed('feedScreen');
|
||||
}
|
||||
} else if (state is VerifyEmailStateVerifyError) {
|
||||
showErrorToast("Неверный код. Попробуйте снова.");
|
||||
for (var controller in _controllers) {
|
||||
@@ -166,18 +175,20 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: 'Позже',
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
|
||||
// При переходе из публичного действия уйти без подтверждения нельзя.
|
||||
if (!widget.returnOnSuccess)
|
||||
CustomOutlinedButton(
|
||||
text: 'Позже',
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<VerifyEmailCubit>().skipVerification();
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<VerifyEmailCubit>().skipVerification();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.59+148
|
||||
version: 1.3.60+149
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
|
||||
@@ -61,6 +61,23 @@ void main() {
|
||||
expect(() => interceptor.onError(error, handler), throwsA(isA<Error>()));
|
||||
});
|
||||
|
||||
test('onError - 405 с missing: [email] без navigator-контекста отклоняет запрос без падения', () async {
|
||||
final requestOptions = RequestOptions(path: '/test');
|
||||
final response = Response(requestOptions: requestOptions, statusCode: 405, data: {'type': 'unverificated_phone', 'reason': 'verification_required', 'missing': ['email']});
|
||||
final error = DioException(requestOptions: requestOptions, response: response, type: DioExceptionType.badResponse);
|
||||
|
||||
final handler = MockErrorInterceptorHandler();
|
||||
when(() => handler.reject(any())).thenReturn(null);
|
||||
|
||||
// В тестах AppRouter.navigatorKey.currentContext == null: email-ветка должна это пережить.
|
||||
interceptor.onError(error, handler);
|
||||
|
||||
// onError объявлен как void, дожидаемся завершения асинхронной обработки.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verify(() => handler.reject(any())).called(1);
|
||||
});
|
||||
|
||||
test('onError - пропускает другие ошибки', () async {
|
||||
final requestOptions = RequestOptions(path: '/', method: 'GET', baseUrl: 'http://localhost');
|
||||
final response = Response(requestOptions: requestOptions, statusCode: 404, data: {});
|
||||
|
||||
91
test/core/interceptors/verification_requirement_test.dart
Normal file
91
test/core/interceptors/verification_requirement_test.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('VerificationRequirement.tryParse', () {
|
||||
test('missing: [email] — не хватает только почты', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
'missing': ['email'],
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsEmail, isTrue);
|
||||
expect(requirement.needsPhone, isFalse);
|
||||
expect(requirement.missing, [VerificationChannel.email]);
|
||||
});
|
||||
|
||||
test('missing: [phone] — не хватает только телефона', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
'missing': ['phone'],
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsPhone, isTrue);
|
||||
expect(requirement.needsEmail, isFalse);
|
||||
expect(requirement.missing, [VerificationChannel.phone]);
|
||||
});
|
||||
|
||||
test('missing: [phone, email] — не хватает обоих каналов', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
'missing': ['phone', 'email'],
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsPhone, isTrue);
|
||||
expect(requirement.needsEmail, isTrue);
|
||||
});
|
||||
|
||||
test('missing отсутствует — legacy-контракт «телефон»', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsPhone, isTrue);
|
||||
expect(requirement.needsEmail, isFalse);
|
||||
expect(requirement.missing, [VerificationChannel.phone]);
|
||||
});
|
||||
|
||||
test('missing пустой — legacy-контракт «телефон»', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
'missing': [],
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsPhone, isTrue);
|
||||
expect(requirement.needsEmail, isFalse);
|
||||
});
|
||||
|
||||
test('неизвестное значение в missing игнорируется', () {
|
||||
final requirement = VerificationRequirement.tryParse({
|
||||
'type': 'unverificated_phone',
|
||||
'missing': ['email', 'telegram'],
|
||||
});
|
||||
|
||||
expect(requirement, isNotNull);
|
||||
expect(requirement!.needsEmail, isTrue);
|
||||
expect(requirement.needsPhone, isFalse);
|
||||
expect(requirement.missing, [VerificationChannel.email]);
|
||||
});
|
||||
|
||||
test('data не Map — null', () {
|
||||
expect(VerificationRequirement.tryParse(null), isNull);
|
||||
expect(VerificationRequirement.tryParse('unverificated_phone'), isNull);
|
||||
expect(VerificationRequirement.tryParse(['email']), isNull);
|
||||
});
|
||||
|
||||
test('type не unverificated_phone — null', () {
|
||||
expect(
|
||||
VerificationRequirement.tryParse({
|
||||
'type': 'other',
|
||||
'missing': ['email'],
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user