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

@@ -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,
),
],
);
}
}