From 1829ad611d3b0d3cfa9c50dab97b83fa012f7fae Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Mon, 2 Nov 2026 14:03:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(sbp=5Fpayments):=20QR-=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=8B=20=D0=A1=D0=91=D0=9F=20?= =?UTF-8?q?=D1=81=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE=D0=B3=D0=BE=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Новый SbpQrBottomSheet: рендер checkout_url (qr.nspk.ru/{qrcId}) через qr_flutter, сумма, инструкция, автоопределение оплаты polling-ом статуса chosen_plan (3 сек) и автозакрытие с успехом - Кнопка «Оплатить по QR-коду» в шите выбора банков — первичная, под заголовком, доступна сразу после получения checkout_url, не дожидаясь списка банков (важно для устройств без банковских приложений, iOS в РФ) - Блок «Симуляция оплаты» (testpay.alfabank.ru/sbp-page?qrcId=...) показывается только когда base URL API не продуктовый: AppConfig.isProdApi/prodApiHosts (хост целиком — стенды-поддомены eatvio.ru продом не считаются) - showBottomSheetWithBlur стал генериком — возвращает значение pop(result) - showConsultationPaymentFlow: опциональная прокидка суммы в QR-шит - Локализация ru/en, тесты (16 новых, всего 34 платёжных зелёные) --- lib/core/app_config.dart | 14 + lib/core/widgets/bottom_sheet_with_blur.dart | 8 +- .../widgets/notification_banner.dart | 6 +- .../payment/presentation/payment_flow.dart | 11 +- .../widgets/sbp_banks_bottom_sheet.dart | 58 +++- .../widgets/sbp_qr_bottom_sheet.dart | 316 ++++++++++++++++++ lib/generated/intl/messages_en.dart | 31 ++ lib/generated/intl/messages_ru.dart | 31 ++ lib/generated/l10n.dart | 110 ++++++ lib/l10n/intl_en.arb | 11 + lib/l10n/intl_ru.arb | 11 + pubspec.yaml | 1 + .../consultation_payment_flow_test.dart | 2 +- .../widgets/sbp_banks_bottom_sheet_test.dart | 81 +++++ .../widgets/sbp_qr_bottom_sheet_test.dart | 256 ++++++++++++++ 15 files changed, 935 insertions(+), 12 deletions(-) create mode 100644 lib/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart create mode 100644 test/features/payment/presentation/widgets/sbp_qr_bottom_sheet_test.dart diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index 921adaf5..6fdf4744 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -33,6 +33,20 @@ class AppConfig { late String _baseUrl; String get baseUrl => _baseUrl; + /// Продуктовые хосты API. Сравнение по хосту целиком: поддомены + /// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод. + static const List prodApiHosts = ['eatvio.ru', 'eatvio.com']; + + /// Текущий base URL указывает на продуктовый API? + bool get isProdApi { + final host = Uri.tryParse(baseUrl)?.host.toLowerCase() ?? ''; + if (host.isEmpty) return false; + return prodApiHosts.any((prodHost) => host == prodHost || host == 'www.$prodHost'); + } + + /// Работаем на стенде разработки/stage (не на продуктовом API)? + bool get isDevOrStageStand => !isProdApi; + void setBaseUrl(String baseUrls) { _baseUrl = baseUrls; _persistBaseUrl(baseUrls); diff --git a/lib/core/widgets/bottom_sheet_with_blur.dart b/lib/core/widgets/bottom_sheet_with_blur.dart index 57dc9026..c5a5ec78 100644 --- a/lib/core/widgets/bottom_sheet_with_blur.dart +++ b/lib/core/widgets/bottom_sheet_with_blur.dart @@ -3,7 +3,11 @@ import 'dart:ui'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:flutter/material.dart'; -Future showBottomSheetWithBlur({ +/// Показывает modal bottom sheet с blur-фоном. +/// +/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)` +/// внутри шита (по умолчанию `null`). +Future showBottomSheetWithBlur({ required BuildContext context, required Widget child, double? height = 200, @@ -11,7 +15,7 @@ Future showBottomSheetWithBlur({ bool enableDrag = true, bool userRootNavigator = false, }) { - return showModalBottomSheet( + return showModalBottomSheet( context: context, isScrollControlled: true, barrierColor: appTheme.transparentBlack, diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 11db4288..7ec9c0bc 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -106,7 +106,11 @@ class _NotificationBannerState extends State { onTap: () { final planId = (context.read().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId != null) { - showConsultationPaymentFlow(context, planId: planId); + showConsultationPaymentFlow( + context, + planId: planId, + amount: widget.planPrice, + ); } }, child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart index 2764dc9e..95ba1cd4 100644 --- a/lib/features/payment/presentation/payment_flow.dart +++ b/lib/features/payment/presentation/payment_flow.dart @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; Future showConsultationPaymentFlow( BuildContext context, { required int planId, + double? amount, Future Function(BuildContext)? showGate, Future Function( BuildContext, { @@ -21,7 +22,7 @@ Future showConsultationPaymentFlow( Future Function()? onRemove, PromoCodeModel? initialPromo, })? showForm, - Future Function(BuildContext, {required int planId})? showSbp, + Future Function(BuildContext, {required int planId, double? amount})? showSbp, Future Function({ required int planId, required String promoCode, @@ -65,7 +66,7 @@ Future showConsultationPaymentFlow( } if (!context.mounted) return; - await sbp(context, planId: planId); + await sbp(context, planId: planId, amount: amount); return; } @@ -92,7 +93,7 @@ Future showConsultationPaymentFlow( } if (!context.mounted) return; - await sbp(context, planId: planId); + await sbp(context, planId: planId, amount: amount); } Future _showGate(BuildContext context) { @@ -113,8 +114,8 @@ Future _showForm( ); } -Future _showSbp(BuildContext context, {required int planId}) { - return showSbpBanksBottomSheet(context, planId: planId); +Future _showSbp(BuildContext context, {required int planId, double? amount}) { + return showSbpBanksBottomSheet(context, planId: planId, amount: amount); } Future _defaultValidatePromo({ diff --git a/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart index 5b0f95e7..8bced0b2 100644 --- a/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart +++ b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart @@ -8,18 +8,23 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sbp/models/c2bmembers_model.dart'; /// Показывает bottom sheet выбора банка СБП для оплаты консультации. -Future showSbpBanksBottomSheet(BuildContext context, {required int planId}) { +Future showSbpBanksBottomSheet( + BuildContext context, { + required int planId, + double? amount, +}) { final height = MediaQuery.sizeOf(context).height * 0.55; return showBottomSheetWithBlur( context: context, height: height, - child: SbpBanksBottomSheet(planId: planId), + child: SbpBanksBottomSheet(planId: planId, amount: amount), ); } @@ -28,9 +33,13 @@ Future showSbpBanksBottomSheet(BuildContext context, {required int planId} /// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId] /// и показывает [Loader], пока загружаются checkout_url и список банков. class SbpBanksBottomSheet extends StatefulWidget { - const SbpBanksBottomSheet({super.key, required this.planId, this.cubit}); + const SbpBanksBottomSheet({super.key, required this.planId, this.amount, this.cubit}); final int planId; + + /// Сумма к оплате в рублях — передаётся в QR-шит для отображения. + final double? amount; + final ConsultationPaymentCubit? cubit; @override @@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State { style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), textAlign: TextAlign.center, ), + // QR-кнопка доступна сразу после получения checkout_url, + // не дожидаясь списка банков: на устройствах без банковских + // приложений (iOS в РФ) это единственный путь оплаты. + BlocBuilder( + buildWhen: (previous, current) => + previous.checkoutUrl != current.checkoutUrl, + builder: (context, state) { + final checkoutUrl = state.checkoutUrl; + if (checkoutUrl == null || checkoutUrl.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 16), + child: CustomOutlinedButton( + text: s.pay_by_qr_code, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + onPressed: _onShowQrTap, + ), + ); + }, + ), const SizedBox(height: 16), Expanded( child: BlocBuilder( @@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State { ); } + /// Открывает QR-шит поверх шита банков; при подтверждённой оплате + /// закрывает и родительский шит. + Future _onShowQrTap() async { + final paid = await showSbpQrBottomSheet( + context, + planId: widget.planId, + cubit: _cubit, + amount: widget.amount, + ); + if (paid == true && mounted) { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } + } + } + Future _onBankTap(C2bmemberModel bank) async { final opened = await _cubit.openBank(bank: bank); if (opened && mounted) { diff --git a/lib/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart b/lib/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart new file mode 100644 index 00000000..8a759319 --- /dev/null +++ b/lib/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart @@ -0,0 +1,316 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Тестовый симулятор оплаты СБП (только для стендов разработки/stage). +const String _testPayUrlBase = 'https://testpay.alfabank.ru/sbp-page?qrcId='; +const String _nspkUrlPrefix = 'https://qr.nspk.ru/'; +const Duration _pollInterval = Duration(seconds: 3); +const Duration _autoCloseDelay = Duration(seconds: 2); + +/// Показывает bottom sheet с QR-кодом СБП для оплаты консультации +/// с другого устройства. +/// +/// Возвращает `true`, если оплата подтверждена polling-ом статуса плана. +Future showSbpQrBottomSheet( + BuildContext context, { + required int planId, + required ConsultationPaymentCubit cubit, + double? amount, +}) { + return showBottomSheetWithBlur( + context: context, + height: null, + child: SbpQrBottomSheet( + planId: planId, + cubit: cubit, + amount: amount, + ), + ); +} + +/// QR-код оплаты СБП: рендерит [ConsultationPaymentState.checkoutUrl] +/// (payload вида `https://qr.nspk.ru/{qrcId}`), ожидает оплату через +/// polling статуса chosen_plan и показывает успех. +class SbpQrBottomSheet extends StatefulWidget { + const SbpQrBottomSheet({ + super.key, + required this.planId, + required this.cubit, + this.amount, + this.chatRepository, + this.pollInterval = _pollInterval, + }); + + final int planId; + final ConsultationPaymentCubit cubit; + + /// Сумма к оплате в рублях (показывается над QR, если передана). + final double? amount; + + /// Переопределяется в тестах; по умолчанию берётся из DI. + final ChatRepository? chatRepository; + + final Duration pollInterval; + + @override + State createState() => _SbpQrBottomSheetState(); +} + +class _SbpQrBottomSheetState extends State { + late final ChatRepository _chatRepository; + late final Timer _pollTimer; + bool _paid = false; + + @override + void initState() { + super.initState(); + _chatRepository = widget.chatRepository ?? sl(); + _pollTimer = Timer.periodic(widget.pollInterval, (_) => _pollStatus()); + _pollStatus(); + } + + @override + void dispose() { + _pollTimer.cancel(); + super.dispose(); + } + + Future _pollStatus() async { + if (_paid || !mounted) return; + try { + final plan = await _chatRepository.getChosenPlan(id: widget.planId); + if (plan.chosenPlanState.isStateAfterPaidState()) { + _onPaid(); + } + } catch (_) { + // Сетевые сбои polling-а игнорируем — следующий тик повторит проверку. + } + } + + void _onPaid() { + if (_paid || !mounted) return; + setState(() => _paid = true); + Future.delayed(_autoCloseDelay, () { + if (!mounted) return; + Navigator.of(context).pop(true); + }); + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return BlocProvider.value( + value: widget.cubit, + child: BlocBuilder( + builder: (context, state) { + final checkoutUrl = state.checkoutUrl; + if (checkoutUrl == null || checkoutUrl.isEmpty) { + return const SizedBox.shrink(); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text( + _paid ? s.sbp_qr_paid_title : s.sbp_qr_title, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + if (_paid) + _PaidContent(description: s.sbp_qr_paid_description) + else ...[ + if (widget.amount != null) ...[ + Text( + s.sbp_qr_amount_label, + style: TextStyles.textMMedium.copyWith(color: appTheme.gray600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + '${widget.amount!.toStringAsFixed(0)} ₽', + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ], + _QrCodeCard(checkoutUrl: checkoutUrl), + const SizedBox(height: 16), + _HowToPay( + step1: s.sbp_qr_step1, + step2: s.sbp_qr_step2, + step3: s.sbp_qr_step3, + ), + const SizedBox(height: 16), + _TestPayButton(checkoutUrl: checkoutUrl), + const SizedBox(height: 16), + Text( + s.sbp_qr_waiting_payment, + style: TextStyles.textMMedium.copyWith(color: appTheme.gray600), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + ], + ), + ); + }, + ), + ); + } +} + +class _QrCodeCard extends StatelessWidget { + const _QrCodeCard({required this.checkoutUrl}); + + final String checkoutUrl; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: appTheme.blueGray300), + ), + child: QrImageView( + key: const ValueKey('sbp_qr_code'), + data: checkoutUrl, + size: 200, + backgroundColor: Colors.white, + ), + ), + ); + } +} + +class _HowToPay extends StatelessWidget { + const _HowToPay({required this.step1, required this.step2, required this.step3}); + + final String step1; + final String step2; + final String step3; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: appTheme.blueA400.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(step1, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + const SizedBox(height: 4), + Text(step2, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + const SizedBox(height: 4), + Text(step3, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + ], + ), + ); + } +} + +/// Кнопка открытия тестового симулятора оплаты Альфа-Банка. +/// +/// Видна только когда QR выдан непродовым бэкендом: тестовый qrcId от +/// продового в приложении не отличить (формат одинаковый), различает их +/// именно то, какой бэкенд выдал checkout_url — на стендах разработки/stage +/// Альфа подключена в sandbox и выдаёт QR тестового терминала. Поэтому +/// критерий — текущий base URL API не продуктовый (см. [AppConfig.isProdApi]). +class _TestPayButton extends StatelessWidget { + const _TestPayButton({required this.checkoutUrl}); + + final String checkoutUrl; + + bool get _visible { + if (sl().isProdApi) return false; + return checkoutUrl.startsWith(_nspkUrlPrefix); + } + + @override + Widget build(BuildContext context) { + if (!_visible) return const SizedBox.shrink(); + final s = S.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text( + s.sbp_qr_test_hint, + style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => _openTestPayment(), + child: Text(s.sbp_qr_open_test_payment), + ), + ], + ), + ); + } + + Future _openTestPayment() async { + final qrcId = checkoutUrl.replaceFirst(_nspkUrlPrefix, ''); + final uri = Uri.parse('$_testPayUrlBase$qrcId'); + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + // Не смогли открыть браузер — пользователь оплатит по QR. + } + } +} + +class _PaidContent extends StatelessWidget { + const _PaidContent({required this.description}); + + final String description; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox(height: 8), + Container( + width: 64, + height: 64, + decoration: const BoxDecoration(color: Color(0xFFE8F5E9), shape: BoxShape.circle), + child: const Icon(Icons.check_rounded, color: Color(0xFF4CAF50), size: 40), + ), + const SizedBox(height: 12), + Text( + description, + style: TextStyles.textMMedium.copyWith(color: appTheme.gray600), + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 8929ab56..b4ae871b 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -658,6 +658,9 @@ class MessageLookup extends MessageLookupByLibrary { "В пароле должно быть минимум 6 символов", ), "pay": MessageLookupByLibrary.simpleMessage("Оплатить"), + "pay_by_qr_code": MessageLookupByLibrary.simpleMessage( + "Оплатить по QR-коду", + ), "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "pending_payment": MessageLookupByLibrary.simpleMessage("Ожидается оплата"), @@ -777,6 +780,34 @@ class MessageLookup extends MessageLookupByLibrary { "sbp_payment_error": MessageLookupByLibrary.simpleMessage( "Не удалось начать оплату. Попробуйте позже", ), + "sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage( + "Сумма к оплате", + ), + "sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage( + "Открыть тестовую оплату", + ), + "sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage( + "Консультация оплачена. Можно вернуться в чат.", + ), + "sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage( + "Оплата прошла успешно!", + ), + "sbp_qr_step1": MessageLookupByLibrary.simpleMessage( + "Откройте приложение банка на телефоне", + ), + "sbp_qr_step2": MessageLookupByLibrary.simpleMessage( + "Выберите «Оплата по QR-коду»", + ), + "sbp_qr_step3": MessageLookupByLibrary.simpleMessage( + "Наведите камеру на код", + ), + "sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage( + "Симуляция оплаты на тестовом стенде Альфа-Банка", + ), + "sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"), + "sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage( + "Ожидание оплаты…", + ), "send": MessageLookupByLibrary.simpleMessage("Отправить"), "send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 91ba0fac..28874ef1 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -297,6 +297,9 @@ class MessageLookup extends MessageLookupByLibrary { "password_short": MessageLookupByLibrary.simpleMessage( "В пароле должно быть минимум 6 символов", ), + "pay_by_qr_code": MessageLookupByLibrary.simpleMessage( + "Оплатить по QR-коду", + ), "pcs": MessageLookupByLibrary.simpleMessage("шт"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"), "personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"), @@ -354,6 +357,34 @@ class MessageLookup extends MessageLookupByLibrary { "sbp_payment_error": MessageLookupByLibrary.simpleMessage( "Не удалось начать оплату. Попробуйте позже", ), + "sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage( + "Сумма к оплате", + ), + "sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage( + "Открыть тестовую оплату", + ), + "sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage( + "Консультация оплачена. Можно вернуться в чат.", + ), + "sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage( + "Оплата прошла успешно!", + ), + "sbp_qr_step1": MessageLookupByLibrary.simpleMessage( + "Откройте приложение банка на телефоне", + ), + "sbp_qr_step2": MessageLookupByLibrary.simpleMessage( + "Выберите «Оплата по QR-коду»", + ), + "sbp_qr_step3": MessageLookupByLibrary.simpleMessage( + "Наведите камеру на код", + ), + "sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage( + "Симуляция оплаты на тестовом стенде Альфа-Банка", + ), + "sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"), + "sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage( + "Ожидание оплаты…", + ), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), "settings": MessageLookupByLibrary.simpleMessage("Настройки"), "share": MessageLookupByLibrary.simpleMessage("Поделиться"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index f6d24a07..26dab236 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2689,6 +2689,116 @@ class S { ); } + /// `Оплатить по QR-коду` + String get pay_by_qr_code { + return Intl.message( + 'Оплатить по QR-коду', + name: 'pay_by_qr_code', + desc: '', + args: [], + ); + } + + /// `Оплата по QR-коду` + String get sbp_qr_title { + return Intl.message( + 'Оплата по QR-коду', + name: 'sbp_qr_title', + desc: '', + args: [], + ); + } + + /// `Сумма к оплате` + String get sbp_qr_amount_label { + return Intl.message( + 'Сумма к оплате', + name: 'sbp_qr_amount_label', + desc: '', + args: [], + ); + } + + /// `Откройте приложение банка на телефоне` + String get sbp_qr_step1 { + return Intl.message( + 'Откройте приложение банка на телефоне', + name: 'sbp_qr_step1', + desc: '', + args: [], + ); + } + + /// `Выберите «Оплата по QR-коду»` + String get sbp_qr_step2 { + return Intl.message( + 'Выберите «Оплата по QR-коду»', + name: 'sbp_qr_step2', + desc: '', + args: [], + ); + } + + /// `Наведите камеру на код` + String get sbp_qr_step3 { + return Intl.message( + 'Наведите камеру на код', + name: 'sbp_qr_step3', + desc: '', + args: [], + ); + } + + /// `Ожидание оплаты…` + String get sbp_qr_waiting_payment { + return Intl.message( + 'Ожидание оплаты…', + name: 'sbp_qr_waiting_payment', + desc: '', + args: [], + ); + } + + /// `Оплата прошла успешно!` + String get sbp_qr_paid_title { + return Intl.message( + 'Оплата прошла успешно!', + name: 'sbp_qr_paid_title', + desc: '', + args: [], + ); + } + + /// `Консультация оплачена. Можно вернуться в чат.` + String get sbp_qr_paid_description { + return Intl.message( + 'Консультация оплачена. Можно вернуться в чат.', + name: 'sbp_qr_paid_description', + desc: '', + args: [], + ); + } + + /// `Открыть тестовую оплату` + String get sbp_qr_open_test_payment { + return Intl.message( + 'Открыть тестовую оплату', + name: 'sbp_qr_open_test_payment', + desc: '', + args: [], + ); + } + + /// `Симуляция оплаты на тестовом стенде Альфа-Банка` + String get sbp_qr_test_hint { + return Intl.message( + 'Симуляция оплаты на тестовом стенде Альфа-Банка', + name: 'sbp_qr_test_hint', + desc: '', + args: [], + ); + } + /// `Подробнее о заказе` String get more_about_order { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index bd2406d8..bac64c12 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -330,6 +330,17 @@ "choose_bank_for_payment": "Выберите банк", "no_installed_banks": "Нет установленных банков", "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", + "pay_by_qr_code": "Оплатить по QR-коду", + "sbp_qr_title": "Оплата по QR-коду", + "sbp_qr_amount_label": "Сумма к оплате", + "sbp_qr_step1": "Откройте приложение банка на телефоне", + "sbp_qr_step2": "Выберите «Оплата по QR-коду»", + "sbp_qr_step3": "Наведите камеру на код", + "sbp_qr_waiting_payment": "Ожидание оплаты…", + "sbp_qr_paid_title": "Оплата прошла успешно!", + "sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.", + "sbp_qr_open_test_payment": "Открыть тестовую оплату", + "sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка", "more_about_order": "Подробнее о заказе", "cancel_order": "Отменить заказ", "refuse_order":"Отказаться от заказа", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 5d729fe6..2f821fce 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -239,6 +239,17 @@ "choose_bank_for_payment": "Выберите банк", "no_installed_banks": "Нет установленных банков", "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", + "pay_by_qr_code": "Оплатить по QR-коду", + "sbp_qr_title": "Оплата по QR-коду", + "sbp_qr_amount_label": "Сумма к оплате", + "sbp_qr_step1": "Откройте приложение банка на телефоне", + "sbp_qr_step2": "Выберите «Оплата по QR-коду»", + "sbp_qr_step3": "Наведите камеру на код", + "sbp_qr_waiting_payment": "Ожидание оплаты…", + "sbp_qr_paid_title": "Оплата прошла успешно!", + "sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.", + "sbp_qr_open_test_payment": "Открыть тестовую оплату", + "sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка", "yes": "Да", "apply": "Применить", "have_promo_code": "У вас есть промокод?", diff --git a/pubspec.yaml b/pubspec.yaml index 4d9cf965..24223add 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -92,6 +92,7 @@ dependencies: floating: ^6.0.0 wakelock_plus: ^1.5.2 sbp: 0.1.0 + qr_flutter: ^4.1.0 # html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0. dependency_overrides: diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart index 8b532853..fee46123 100644 --- a/test/features/payment/consultation_payment_flow_test.dart +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -73,7 +73,7 @@ void main() { return formResult; } - Future mockSbp(BuildContext context, {required int planId}) async { + Future mockSbp(BuildContext context, {required int planId, double? amount}) async { sbpCalled = true; sbpPlanId = planId; } diff --git a/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart index 773a1d74..36ee7c24 100644 --- a/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart +++ b/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart @@ -1,18 +1,26 @@ import 'dart:async'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart'; import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get_it/get_it.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:sbp/models/c2bmembers_model.dart'; import '../../../../fixtures/fixture_reader.dart'; @@ -21,9 +29,21 @@ import 'sbp_banks_bottom_sheet_test.mocks.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), + MockSpec(), ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем. + setUpAll(() async { + await dotenv.load( + fileName: 'test.env', + isOptional: true, + mergeWith: const { + 'BASE_URL': 'https://eatvio.ru', + 'DEV_BASE_URL': 'https://dev2.eatvio.com', + }, + ); + }); group('SbpBanksBottomSheet', () { late MockPaymentRepository mockPaymentRepository; @@ -183,5 +203,66 @@ void main() { expect(find.byType(Loader), findsNothing); expect(find.text('Нет установленных банков'), findsOneWidget); }); + + testWidgets('QR-кнопка доступна до загрузки списка банков', (tester) async { + const planId = 12; + final banksCompleter = Completer>(); + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) => banksCompleter.future); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.tests), + ); + sl.registerLazySingleton(() => MockChatRepository()); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pump(); + await tester.pump(); + + // Банки ещё грузятся (Loader), а QR-кнопка уже доступна. + expect(find.byType(Loader), findsOneWidget); + expect(find.text('Оплатить по QR-коду'), findsOneWidget); + + banksCompleter.complete([installedBank]); + await tester.pumpAndSettle(); + expect(find.byType(Loader), findsNothing); + expect(find.text('Сбербанк'), findsOneWidget); + expect(find.text('Оплатить по QR-коду'), findsOneWidget); + + await GetIt.I.reset(); + }); + + testWidgets('после старта оплаты показывает кнопку QR и открывает QR-шит', (tester) async { + const planId = 12; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.tests), + ); + sl.registerLazySingleton(() => MockChatRepository()); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + expect(find.text('Оплатить по QR-коду'), findsOneWidget); + + await tester.tap(find.text('Оплатить по QR-коду')); + await tester.pump(); + await tester.pump(); + expect(find.byType(SbpQrBottomSheet), findsOneWidget); + expect(find.byType(QrImageView), findsOneWidget); + + // Закрываем QR-шит (останавливает polling-таймер). + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(); + await tester.pump(); + + await GetIt.I.reset(); + }); }); } diff --git a/test/features/payment/presentation/widgets/sbp_qr_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/sbp_qr_bottom_sheet_test.dart new file mode 100644 index 00000000..6ed83cf8 --- /dev/null +++ b/test/features/payment/presentation/widgets/sbp_qr_bottom_sheet_test.dart @@ -0,0 +1,256 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; +import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; +import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; +import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'sbp_qr_bottom_sheet_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const planId = 12; + const checkoutUrl = 'https://qr.nspk.ru/AD10106TB0BVFTBG9DQ96NKJEIVVTKIO'; + + group('SbpQrBottomSheet', () { + late MockPaymentRepository mockPaymentRepository; + late MockSbpRepository mockSbpRepository; + late MockChatRepository mockChatRepository; + late MockChosenPlanModel mockPlan; + late ConsultationPaymentCubit cubit; + late Future? sheetResult; + + setUp(() async { + await GetIt.I.reset(); + // AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем. + await dotenv.load( + fileName: 'test.env', + isOptional: true, + mergeWith: const { + 'BASE_URL': 'https://eatvio.ru', + 'DEV_BASE_URL': 'https://dev2.eatvio.com', + }, + ); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.tests), + ); + sl.registerLazySingleton(() => mockChatRepository); + + mockPaymentRepository = MockPaymentRepository(); + mockSbpRepository = MockSbpRepository(); + mockChatRepository = MockChatRepository(); + mockPlan = MockChosenPlanModel(); + cubit = ConsultationPaymentCubit( + paymentRepository: mockPaymentRepository, + sbpRepository: mockSbpRepository, + ); + cubit.emit( + ConsultationPaymentState( + isLoading: false, + checkoutUrl: checkoutUrl, + ), + ); + sheetResult = null; + }); + + tearDown(() async { + await GetIt.I.reset(); + await cubit.close(); + }); + + Future pumpApp(WidgetTester tester, {double? amount}) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => sheetResult = showSbpQrBottomSheet( + context, + planId: planId, + cubit: cubit, + amount: amount, + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + } + + Future openSheet(WidgetTester tester, {double? amount}) async { + await pumpApp(tester, amount: amount); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(); + } + + /// Закрывает шит, чтобы остановить polling-таймер в конце теста. + Future closeSheet(WidgetTester tester) async { + final navigator = tester.state( + find.byType(Navigator).first, + ); + navigator.pop(); + await tester.pump(); + await tester.pump(); + } + + void stubPlanState(ChosenPlanStateEnum state) { + when(mockChatRepository.getChosenPlan(id: planId)) + .thenAnswer((_) async => mockPlan); + when(mockPlan.chosenPlanState).thenReturn(state); + } + + /// Перерегистрирует DI с AppConfig, смотрящим на конкретный base URL. + Future useApiBaseUrl(String baseUrl) async { + await GetIt.I.reset(); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.tests)..setBaseUrl(baseUrl), + ); + sl.registerLazySingleton(() => mockChatRepository); + } + + testWidgets('рендерит QR-код из checkout_url и инструкцию', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + + await openSheet(tester); + + expect(find.byKey(const ValueKey('sbp_qr_code')), findsOneWidget); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text('Оплата по QR-коду'), findsOneWidget); + expect(find.text('Ожидание оплаты…'), findsOneWidget); + + await closeSheet(tester); + }); + + testWidgets('показывает сумму, если передана', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + + await openSheet(tester, amount: 1200); + + expect(find.text('Сумма к оплате'), findsOneWidget); + expect(find.text('1200 ₽'), findsOneWidget); + + await closeSheet(tester); + }); + + testWidgets('не показывает сумму, если не передана', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + + await openSheet(tester); + + expect(find.text('Сумма к оплате'), findsNothing); + + await closeSheet(tester); + }); + + testWidgets('показывает тестовую кнопку на stage-стенде (dev2)', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + await useApiBaseUrl('https://dev2.eatvio.com'); + + await openSheet(tester); + + expect(find.text('Открыть тестовую оплату'), findsOneWidget); + + await closeSheet(tester); + }); + + testWidgets('показывает тестовую кнопку на стенде-поддомене прод-хоста', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + // Поддомен eatvio.ru — стенд (например, hasan), а не прод. + await useApiBaseUrl('https://ubooquiequ7ohz9ias3aik4a-hasan.eatvio.ru'); + + await openSheet(tester); + + expect(find.text('Открыть тестовую оплату'), findsOneWidget); + + await closeSheet(tester); + }); + + testWidgets('скрывает тестовую кнопку на продуктовом API', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + await useApiBaseUrl('https://eatvio.ru'); + + await openSheet(tester); + + expect(find.text('Открыть тестовую оплату'), findsNothing); + // Сам QR при этом работает. + expect(find.byType(QrImageView), findsOneWidget); + + await closeSheet(tester); + }); + + testWidgets('скрывает тестовую кнопку на продуктовом API (eatvio.com)', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + await useApiBaseUrl('https://eatvio.com'); + + await openSheet(tester); + + expect(find.text('Открыть тестовую оплату'), findsNothing); + + await closeSheet(tester); + }); + + testWidgets('при оплате (statePaid) показывает успех и закрывает шит', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePaid); + + await openSheet(tester); + + // Первый poll выполняется сразу в initState. + await tester.pump(); + expect(find.text('Оплата прошла успешно!'), findsOneWidget); + + // Автозакрытие через 2 секунды. + await tester.pump(const Duration(seconds: 2)); + await tester.pump(); + expect(find.byType(SbpQrBottomSheet), findsNothing); + expect(await sheetResult, isTrue); + }); + + testWidgets('до оплаты продолжает ждать', (tester) async { + stubPlanState(ChosenPlanStateEnum.statePendingPayment); + + await openSheet(tester); + + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + + expect(find.text('Оплата прошла успешно!'), findsNothing); + expect(find.text('Ожидание оплаты…'), findsOneWidget); + + await closeSheet(tester); + }); + }); +}