feat(sbp_payments): added new layoute for sbp banks and rederict to chosen bank, and added tests for layoute

This commit is contained in:
misha
2026-07-14 13:43:46 +03:00
parent 0ca2d1a1cc
commit e94681266a
10 changed files with 471 additions and 27 deletions

View File

@@ -133,7 +133,6 @@ class AppRouter {
final orderId = effectiveSegments[3];
if (orderId.isEmpty) return null;
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
print('Deep link: finance/alfa/return/$orderId');
return '${RouteConstants.chatScreen}?orderId=$orderId';
}

View File

@@ -76,7 +76,6 @@ class _ChatScreenState extends State<ChatScreen> {
orderId: _effectiveUuid == null ? widget.orderId : null,
sendInitialMessage: widget.sendInitialMessage ?? false,
);
print('uuid: $uuid');
if (uuid != null && mounted) {
setState(() => _effectiveUuid = uuid);
}

View File

@@ -2,15 +2,14 @@ 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_with_blur.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
}
class _NotificationBannerState extends State<NotificationBanner> {
bool isLoading = false;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
child: _StarIcon(),
),
if (isLoading)
const Padding(
padding: EdgeInsets.only(left: 40, top: 24),
child: Loader(),
)
else
Padding(
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
),
const SizedBox(height: 12),
GestureDetector(
onTap: () async {
onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
setState(() {
isLoading = true;
});
final paymentLink = await context.read<ChatRoomCubit>().getPaymentLink();
setState(() {
isLoading = false;
});
if (paymentLink != null && planId != null) {
if (!context.mounted) return;
GoRouter.of(context).pushReplacement(
PaymentScreen.path,
extra: {'url': paymentLink, 'planId': planId},
);
if (planId != null) {
showSbpBanksBottomSheet(context, planId: planId);
}
},
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),

View File

@@ -0,0 +1,224 @@
import 'package:cached_network_image/cached_network_image.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/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/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}) {
final height = MediaQuery.sizeOf(context).height * 0.55;
return showBottomSheetWithBlur(
context: context,
height: height,
child: SbpBanksBottomSheet(planId: planId),
);
}
/// Bottom sheet со списком установленных банков СБП.
///
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
/// и показывает [Loader], пока загружаются checkout_url и список банков.
class SbpBanksBottomSheet extends StatefulWidget {
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
final int planId;
final ConsultationPaymentCubit? cubit;
@override
State<SbpBanksBottomSheet> createState() => _SbpBanksBottomSheetState();
}
class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
late final ConsultationPaymentCubit _cubit;
late final bool _ownsCubit;
@override
void initState() {
super.initState();
_ownsCubit = widget.cubit == null;
_cubit = widget.cubit ?? sl<ConsultationPaymentCubit>();
WidgetsBinding.instance.addPostFrameCallback((_) {
_cubit.startPayment(planId: widget.planId);
});
}
@override
void dispose() {
if (_ownsCubit) {
_cubit.close();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<ConsultationPaymentCubit>.value(
value: _cubit,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.choose_bank_for_payment,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
builder: (context, state) {
if (state.isLoading) {
return const Loader();
}
if (state.errorMessage != null) {
return _ErrorContent(
message: s.sbp_payment_error,
onOk: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
},
);
}
if (state.banks.isEmpty) {
return Center(
child: Text(
s.no_installed_banks,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
);
}
return ListView.separated(
itemCount: state.banks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final bank = state.banks[index];
return _BankTile(bank: bank, onTap: () => _onBankTap(bank));
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
);
}
Future<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
}
}
}
class _BankTile extends StatelessWidget {
const _BankTile({required this.bank, required this.onTap});
final C2bmemberModel bank;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final name = bank.localName ?? bank.bankName;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
_BankIcon(bank: bank),
const SizedBox(width: 12),
Expanded(
child: Text(name, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
),
],
),
),
);
}
}
class _BankIcon extends StatelessWidget {
const _BankIcon({required this.bank});
final C2bmemberModel bank;
@override
Widget build(BuildContext context) {
if (bank.icon.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: bank.logoURL,
width: 40,
height: 40,
placeholder: (context, url) => _placeholder(),
errorWidget: (context, url, error) => _placeholder(),
),
);
}
return _placeholder();
}
Widget _placeholder() {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.account_balance, color: appTheme.blueA400, size: 22),
);
}
}
class _ErrorContent extends StatelessWidget {
const _ErrorContent({required this.message, required this.onOk});
final String message;
final VoidCallback onOk;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
message,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: onOk,
),
],
);
}
}

View File

@@ -173,6 +173,9 @@ class MessageLookup extends MessageLookupByLibrary {
),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
"Выберите банк",
),
"choose_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
"choose_diet_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок диеты",
@@ -588,6 +591,9 @@ class MessageLookup extends MessageLookupByLibrary {
"no_default_cards": MessageLookupByLibrary.simpleMessage(
"У вас нет привязанных карт. Что бы начать работу, привяжите карту",
),
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
"Нет установленных банков",
),
"not_liked_groceries": MessageLookupByLibrary.simpleMessage(
"Не любимые продукты",
),
@@ -737,6 +743,9 @@ class MessageLookup extends MessageLookupByLibrary {
"reply": MessageLookupByLibrary.simpleMessage("Ответить"),
"resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"),
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"send": MessageLookupByLibrary.simpleMessage("Отправить"),
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),

View File

@@ -62,6 +62,9 @@ class MessageLookup extends MessageLookupByLibrary {
"chat": MessageLookupByLibrary.simpleMessage("Чат"),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
"Выберите банк",
),
"choose_diet_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок диеты",
),
@@ -243,6 +246,9 @@ class MessageLookup extends MessageLookupByLibrary {
),
"no": MessageLookupByLibrary.simpleMessage("Нет"),
"no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"),
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
"Нет установленных банков",
),
"notifications": MessageLookupByLibrary.simpleMessage("Уведомления"),
"one_option": MessageLookupByLibrary.simpleMessage("1 опция"),
"option": MessageLookupByLibrary.simpleMessage("Опция"),
@@ -314,6 +320,9 @@ class MessageLookup extends MessageLookupByLibrary {
"Снять с публикации",
),
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
"share": MessageLookupByLibrary.simpleMessage("Поделиться"),

View File

@@ -2649,6 +2649,36 @@ class S {
return Intl.message('Оплатить', name: 'pay', desc: '', args: []);
}
/// `Выберите банк`
String get choose_bank_for_payment {
return Intl.message(
'Выберите банк',
name: 'choose_bank_for_payment',
desc: '',
args: [],
);
}
/// `Нет установленных банков`
String get no_installed_banks {
return Intl.message(
'Нет установленных банков',
name: 'no_installed_banks',
desc: '',
args: [],
);
}
/// `Не удалось начать оплату. Попробуйте позже`
String get sbp_payment_error {
return Intl.message(
'Не удалось начать оплату. Попробуйте позже',
name: 'sbp_payment_error',
desc: '',
args: [],
);
}
/// `Подробнее о заказе`
String get more_about_order {
return Intl.message(

View File

@@ -326,6 +326,9 @@
"date_has_ben_set": "Дата старта начала консультации установлена",
"tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации",
"pay": "Оплатить",
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
"more_about_order": "Подробнее о заказе",
"cancel_order": "Отменить заказ",
"refuse_order":"Отказаться от заказа",

View File

@@ -233,5 +233,8 @@
"video_call_permission_camera_title": "Доступ к камере",
"video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
"video_call_permission_microphone_title": "Доступ к микрофону",
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства."
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже"
}

View File

@@ -0,0 +1,187 @@
import 'dart:async';
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/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/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:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:sbp/models/c2bmembers_model.dart';
import '../../../../fixtures/fixture_reader.dart';
import 'sbp_banks_bottom_sheet_test.mocks.dart';
@GenerateNiceMocks([
MockSpec<PaymentRepository>(),
MockSpec<SbpRepository>(),
])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SbpBanksBottomSheet', () {
late MockPaymentRepository mockPaymentRepository;
late MockSbpRepository mockSbpRepository;
late ConsultationPaymentCubit cubit;
late PaymentModel alfaPayment;
final installedBank = C2bmemberModel.fromJson(const {
'bankName': 'Сбербанк',
'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
'schema': 'bank100000000111',
'package_name': 'ru.sberbankmobile',
'icon': '',
});
setUp(() {
mockPaymentRepository = MockPaymentRepository();
mockSbpRepository = MockSbpRepository();
cubit = ConsultationPaymentCubit(
paymentRepository: mockPaymentRepository,
sbpRepository: mockSbpRepository,
);
alfaPayment = PaymentModel.fromJson(
FixtureReader.readAsMap('chat/payments/payment_response_alfa_bank.json'),
);
});
tearDown(() {
cubit.close();
});
Widget createTestWidget({required int planId}) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(
body: SbpBanksBottomSheet(
planId: planId,
cubit: cubit,
),
),
);
}
testWidgets('показывает Loader пока идёт startPayment', (tester) async {
const planId = 12;
final completer = Completer<PaymentModel>();
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) => completer.future);
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pump();
expect(find.byType(Loader), findsOneWidget);
expect(find.text('Сбербанк'), findsNothing);
completer.complete(alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
await tester.pumpAndSettle();
});
testWidgets('вызывает startPaymentProcess с переданным planId', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
verify(mockPaymentRepository.startPaymentProcess(planId: planId)).called(1);
});
testWidgets('после успеха показывает список банков и скрывает Loader', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
expect(find.byType(Loader), findsNothing);
expect(find.text('Выберите банк'), findsOneWidget);
expect(find.text('Сбербанк'), findsOneWidget);
});
testWidgets('tap по банку вызывает openBank с checkout_url', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
when(
mockSbpRepository.openBank(
url: alfaPayment.paymentUrl,
bank: installedBank,
),
).thenAnswer((_) async => true);
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
await tester.tap(find.text('Сбербанк'));
await tester.pumpAndSettle();
verify(
mockSbpRepository.openBank(
url: alfaPayment.paymentUrl,
bank: installedBank,
),
).called(1);
});
testWidgets('при ошибке API показывает сообщение об ошибке', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenThrow(Exception('Payment start failed'));
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
expect(find.byType(Loader), findsNothing);
expect(find.text('Сбербанк'), findsNothing);
expect(
find.text('Не удалось начать оплату. Попробуйте позже'),
findsOneWidget,
);
});
testWidgets('при пустом списке банков показывает empty-сообщение', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => []);
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
expect(find.byType(Loader), findsNothing);
expect(find.text('Нет установленных банков'), findsOneWidget);
});
});
}