forked from eatvio/eatvio_app
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user