diff --git a/lib/core/interceptors/phone_verify_error_interceptor.dart b/lib/core/interceptors/phone_verify_error_interceptor.dart index 0649561a..c4d97eab 100644 --- a/lib/core/interceptors/phone_verify_error_interceptor.dart +++ b/lib/core/interceptors/phone_verify_error_interceptor.dart @@ -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 && data['type'] == 'unverificated_phone') { - final String phone = await sl().getMyPhone(); - final String passKey = await sl().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 _handlePhoneVerificationRequired() async { + final String phone = await sl().getMyPhone(); + final String passKey = await sl().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 _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); } } diff --git a/lib/core/interceptors/verification_requirement.dart b/lib/core/interceptors/verification_requirement.dart new file mode 100644 index 00000000..d09e3f7b --- /dev/null +++ b/lib/core/interceptors/verification_requirement.dart @@ -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 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, + ], + ); + } +} diff --git a/lib/core/routing/auth_routes.dart b/lib/core/routing/auth_routes.dart index 4d46e8de..2c3f754d 100644 --- a/lib/core/routing/auth_routes.dart +++ b/lib/core/routing/auth_routes.dart @@ -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', diff --git a/lib/features/registration/presentation/pages/verify_email_screen.dart b/lib/features/registration/presentation/pages/verify_email_screen.dart index d95084e9..1628886b 100644 --- a/lib/features/registration/presentation/pages/verify_email_screen.dart +++ b/lib/features/registration/presentation/pages/verify_email_screen.dart @@ -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 createState() => _VerifyEmailScreenState(); @@ -89,7 +93,12 @@ class _VerifyEmailScreenState extends State { _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 { }, ), 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().skipVerification(); + }, ), - onPressed: () { - context.read().skipVerification(); - }, - ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index d5782df6..95c01f2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.58+147 -publish_to: 'none' +version: 1.3.59+148 +publish_to: "none" environment: sdk: ^3.10.4 diff --git a/test/core/interceptors/phone_verify_error_interceptor_test.dart b/test/core/interceptors/phone_verify_error_interceptor_test.dart index a049ffb2..c827da8d 100644 --- a/test/core/interceptors/phone_verify_error_interceptor_test.dart +++ b/test/core/interceptors/phone_verify_error_interceptor_test.dart @@ -61,6 +61,23 @@ void main() { expect(() => interceptor.onError(error, handler), throwsA(isA())); }); + 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: {}); diff --git a/test/core/interceptors/verification_requirement_test.dart b/test/core/interceptors/verification_requirement_test.dart new file mode 100644 index 00000000..205c5e94 --- /dev/null +++ b/test/core/interceptors/verification_requirement_test.dart @@ -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, + ); + }); + }); +}