feat(sbp_payments): QR-код оплаты СБП с другого устройства

- Новый 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 платёжных зелёные)
This commit is contained in:
Anatoliy Poloz
2026-11-02 14:03:56 +03:00
parent 7a456c2d9d
commit 1829ad611d
15 changed files with 935 additions and 12 deletions

View File

@@ -33,6 +33,20 @@ class AppConfig {
late String _baseUrl;
String get baseUrl => _baseUrl;
/// Продуктовые хосты API. Сравнение по хосту целиком: поддомены
/// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод.
static const List<String> 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);

View File

@@ -3,7 +3,11 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
Future<void> showBottomSheetWithBlur({
/// Показывает modal bottom sheet с blur-фоном.
///
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
/// внутри шита (по умолчанию `null`).
Future<T?> showBottomSheetWithBlur<T>({
required BuildContext context,
required Widget child,
double? height = 200,
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
bool enableDrag = true,
bool userRootNavigator = false,
}) {
return showModalBottomSheet(
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
barrierColor: appTheme.transparentBlack,

View File

@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
onTap: () {
final planId = (context.read<ChatRoomCubit>().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)),

View File

@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
Future<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
double? amount,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(
BuildContext, {
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp,
Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
Future<PromoValidateResponse> Function({
required int planId,
required String promoCode,
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
return;
}
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
}
Future<bool?> _showGate(BuildContext context) {
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
}
Future<PromoValidateResponse> _defaultValidatePromo({

View File

@@ -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<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
Future<void> 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<void> 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<SbpBanksBottomSheet> {
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
// QR-кнопка доступна сразу после получения checkout_url,
// не дожидаясь списка банков: на устройствах без банковских
// приложений (iOS в РФ) это единственный путь оплаты.
BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
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<ConsultationPaymentCubit, ConsultationPaymentState>(
@@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
);
}
/// Открывает QR-шит поверх шита банков; при подтверждённой оплате
/// закрывает и родительский шит.
Future<void> _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<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) {

View File

@@ -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<bool?> showSbpQrBottomSheet(
BuildContext context, {
required int planId,
required ConsultationPaymentCubit cubit,
double? amount,
}) {
return showBottomSheetWithBlur<bool>(
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<SbpQrBottomSheet> createState() => _SbpQrBottomSheetState();
}
class _SbpQrBottomSheetState extends State<SbpQrBottomSheet> {
late final ChatRepository _chatRepository;
late final Timer _pollTimer;
bool _paid = false;
@override
void initState() {
super.initState();
_chatRepository = widget.chatRepository ?? sl<ChatRepository>();
_pollTimer = Timer.periodic(widget.pollInterval, (_) => _pollStatus());
_pollStatus();
}
@override
void dispose() {
_pollTimer.cancel();
super.dispose();
}
Future<void> _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<ConsultationPaymentCubit>.value(
value: widget.cubit,
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
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<AppConfig>().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<void> _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,
),
],
);
}
}

View File

@@ -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("Оказание услуг"),

View File

@@ -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("Поделиться"),

View File

@@ -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(

View File

@@ -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":"Отказаться от заказа",

View File

@@ -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": "У вас есть промокод?",

View File

@@ -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:

View File

@@ -73,7 +73,7 @@ void main() {
return formResult;
}
Future<void> mockSbp(BuildContext context, {required int planId}) async {
Future<void> mockSbp(BuildContext context, {required int planId, double? amount}) async {
sbpCalled = true;
sbpPlanId = planId;
}

View File

@@ -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<PaymentRepository>(),
MockSpec<SbpRepository>(),
MockSpec<ChatRepository>(),
])
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<List<C2bmemberModel>>();
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) => banksCompleter.future);
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => 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>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => 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<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump();
await tester.pump();
await GetIt.I.reset();
});
});
}

View File

@@ -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<PaymentRepository>(),
MockSpec<SbpRepository>(),
MockSpec<ChatRepository>(),
MockSpec<ChosenPlanModel>(),
])
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<bool?>? 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>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => 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<void> 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<void> 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<void> closeSheet(WidgetTester tester) async {
final navigator = tester.state<NavigatorState>(
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<void> useApiBaseUrl(String baseUrl) async {
await GetIt.I.reset();
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl(baseUrl),
);
sl.registerLazySingleton<ChatRepository>(() => 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);
});
});
}