diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index a44f6927..516f806a 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -155,9 +155,10 @@ jobs:
- name: Get dependencies
run: |
- flutter clean
- flutter pub get
- dart run build_runner build --delete-conflicting-outputs
+ flutter clean
+ flutter pub cache repair
+ flutter pub get
+ dart run build_runner build --delete-conflicting-outputs
- name: Native Analyze
run: flutter analyze
diff --git a/Makefile b/Makefile
index 593e663e..96b60dc2 100644
--- a/Makefile
+++ b/Makefile
@@ -10,4 +10,4 @@ build-generated-files:
dart run build_runner build --delete-conflicting-outputs
clean-coverage:
- @./scripts/remove_generated_from_coverage.sh
\ No newline at end of file
+ @./scripts/remove_generated_from_coverage.sh
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4ce279c3..1838dff8 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -164,5 +164,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 5bcdccbd..9992c8a5 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -78,6 +78,57 @@
yandexauth
yandexauth2
yandexauth4
+
+ bank100000000111
+ bank100000000004
+ bank100000000005
+ bank100000000008
+ bank100000000007
+ bank100000000015
+ bank100000000001
+ bank100000000010
+ bank100000000013
+ bank100000000012
+ bank100000000020
+ bank100000000025
+ bank100000000030
+ bank100000000030_new
+ bank100000000003
+ bank100000000043
+ bank100000000028
+ bank100000000086
+ bank100000000011
+ bank100000000044
+ bank100000000049
+ bank100000000500
+ bank100000000095
+ bank100000000900
+ bank100000000056
+ bank100000000053
+ bank100000000121
+ bank100000000082
+ bank100000000127
+ bank100000000017
+ bank100000000087
+ bank100000000052
+ bank100000000006
+ bank100000000098
+ bank100000000092
+ bank100000000229
+ bank100000000027
+ bank100000000080
+ bank100000000122
+ bank100000000124
+ bank100000000118
+ bank100000000159
+ bank100000000146
+ bank100000000069
+ bank100000000140
+ bank100000000047
+ bank100000000099
+ bank100000000135
+ bank100000000139
+ bank100000000022
YAClientId
5399b03636104c6d920b87c1ad329254
diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart
index 62a840e0..9b6f9610 100644
--- a/lib/core/app_router.dart
+++ b/lib/core/app_router.dart
@@ -123,7 +123,21 @@ class AppRouter {
return '${InfoByOrderScreen.path}?planId=$consultationId';
}
- static String? _resolveDeepLinkUri(Uri uri) {
+ /// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
+ static String? _resolveFinanceAlfaReturnDeepLink(List effectiveSegments) {
+ if (effectiveSegments.length != 4 ||
+ effectiveSegments[1] != 'alfa' ||
+ effectiveSegments[2] != 'return') {
+ return null;
+ }
+ final orderId = effectiveSegments[3];
+ if (orderId.isEmpty) return null;
+ developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
+ return '${RouteConstants.chatScreen}?orderId=$orderId';
+ }
+
+ /// Разбирает deep link URI в внутренний route target (или null, если host не наш).
+ static String? resolveDeepLinkTarget(Uri uri) {
final host = uri.host.toLowerCase();
final segments = uri.pathSegments;
@@ -189,6 +203,10 @@ class AppRouter {
final target = _resolveConsultationsDeepLink(effectiveSegments);
if (target != null) return target;
break;
+ case 'finance':
+ final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
+ if (target != null) return target;
+ break;
}
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
@@ -196,7 +214,7 @@ class AppRouter {
}
static String? _handleRedirect(BuildContext context, GoRouterState state) {
- return _resolveDeepLinkUri(state.uri);
+ return resolveDeepLinkTarget(state.uri);
}
static void navigateByDeepLinkUrl(String url) {
@@ -230,7 +248,7 @@ class AppRouter {
);
}
- final target = _resolveDeepLinkUri(uri);
+ final target = resolveDeepLinkTarget(uri);
FlutterNativeSplash.remove();
if (target != null) {
appRouter.go(target);
diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart
index 499165e1..d3886779 100644
--- a/lib/core/di/cubit_registrations.dart
+++ b/lib/core/di/cubit_registrations.dart
@@ -39,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
+import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
@@ -149,6 +150,9 @@ class CubitRegistrations {
sl.registerFactory(() => ComplainingCubit(repository: sl()));
sl.registerFactory(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
+ sl.registerFactory(
+ () => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
+ );
sl.registerFactory(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory(() => CancelPlanCubit(cancelPlanRepository: sl()));
diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart
index 002593be..bb260432 100644
--- a/lib/core/di/repository_registrations.dart
+++ b/lib/core/di/repository_registrations.dart
@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
import 'package:eatvio_app/features/notifications/repository/notifications_repository.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/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
@@ -74,6 +76,8 @@ class RepositoryRegistrations {
sl.registerLazySingleton(() => BlockedUsersRepository(blockedUserService: sl()));
sl.registerLazySingleton(() => CommentsRepository(service: sl()));
sl.registerLazySingleton(() => NotificationSettingsRepository(sl()));
+ sl.registerLazySingleton(() => SbpRepository(sbpService: sl()));
+ sl.registerLazySingleton(() => PaymentRepository(chatService: sl()));
sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
sl.registerLazySingleton(() => RegistrationRepositoryV2(registrationService: sl()));
diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart
index d8f23ca2..27152661 100644
--- a/lib/core/di/service_registrations.dart
+++ b/lib/core/di/service_registrations.dart
@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
+import 'package:eatvio_app/features/payment/services/sbp_service.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
import 'package:eatvio_app/features/plans/services/plan_service.dart';
import 'package:eatvio_app/features/plans/services/plans_service.dart';
@@ -90,6 +91,7 @@ class ServiceRegistrations {
sl.registerLazySingleton(() => ConsultantRatingService(sl()));
sl.registerLazySingleton(() => NotificationSettingsService(sl()));
sl.registerLazySingleton(() => ViewsService(sl()));
+ sl.registerLazySingleton(() => const SbpServiceImpl());
sl.registerLazySingleton(() => PersonalDietSuggestionService(sl()));
sl.registerLazySingleton(() => RegistrationServiceV2(sl()));
diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart
index b7ff92ec..b8fb63ea 100644
--- a/lib/core/error_reporting/error_report_policy.dart
+++ b/lib/core/error_reporting/error_report_policy.dart
@@ -46,6 +46,7 @@ class ErrorReportPolicy {
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
case DioExceptionType.connectionError:
+ case DioExceptionType.transformTimeout:
return true;
case DioExceptionType.unknown:
case DioExceptionType.badResponse:
diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart
index db8db78b..7cc78138 100644
--- a/lib/core/routing/other_routes.dart
+++ b/lib/core/routing/other_routes.dart
@@ -212,13 +212,23 @@ class OtherRoutes {
name: 'chatScreen',
builder: (context, state) {
final uuidFromQuery = state.uri.queryParameters['uuid'];
- if (uuidFromQuery != null) {
- return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false);
+ final orderIdFromQuery = state.uri.queryParameters['orderId'];
+ if (uuidFromQuery != null || orderIdFromQuery != null) {
+ return ChatScreen(
+ uuid: uuidFromQuery,
+ orderId: orderIdFromQuery,
+ sendInitialMessage: false,
+ );
}
final data = state.extra as Map;
- final uuid = data['uuid'] as String;
+ final uuid = data['uuid'] as String?;
+ final orderId = data['orderId'] as String?;
final sendInitialMessage = data['sendInitialMessage'] as bool?;
- return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
+ return ChatScreen(
+ uuid: uuid,
+ orderId: orderId,
+ sendInitialMessage: sendInitialMessage,
+ );
},
),
GoRoute(
diff --git a/lib/features/chat/models/order_id_to_conversation_model.dart b/lib/features/chat/models/order_id_to_conversation_model.dart
new file mode 100644
index 00000000..8e83a12c
--- /dev/null
+++ b/lib/features/chat/models/order_id_to_conversation_model.dart
@@ -0,0 +1,15 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'order_id_to_conversation_model.freezed.dart';
+part 'order_id_to_conversation_model.g.dart';
+
+@freezed
+abstract class OrderIdToConversationModel with _$OrderIdToConversationModel {
+ factory OrderIdToConversationModel({
+ @JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
+ @JsonKey(name: 'conversation_uuid') required String conversationUuid,
+ }) = _OrderIdToConversationModel;
+
+ factory OrderIdToConversationModel.fromJson(Map json) =>
+ _$OrderIdToConversationModelFromJson(json);
+}
diff --git a/lib/features/chat/models/payment_model.dart b/lib/features/chat/models/payment_model.dart
index e2c5fbba..6413d5df 100644
--- a/lib/features/chat/models/payment_model.dart
+++ b/lib/features/chat/models/payment_model.dart
@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
@freezed
abstract class PaymentModel with _$PaymentModel {
-
factory PaymentModel({
@JsonKey(name: 'checkout_url') required String paymentUrl,
+ @JsonKey(name: 'order_id') String? orderId,
+ @JsonKey(name: 'payment_provider') String? paymentProvider,
}) = _PaymentModel;
- factory PaymentModel.fromJson(Map json) => _$PaymentModelFromJson(json);
-}
\ No newline at end of file
+ factory PaymentModel.fromJson(Map json) =>
+ _$PaymentModelFromJson(json);
+}
diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart
index dac801f7..452adb18 100644
--- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart
+++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart
@@ -94,17 +94,38 @@ class ChatRoomCubit extends Cubit {
}
}
- Future initChat(String uuid, {bool sendInitialMessage = false}) async {
+ /// Инициализирует чат по [uuid] либо по [orderId] (resolve → conversation_uuid).
+ ///
+ /// При наличии [orderId] сначала вызывается
+ /// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
+ ///
+ /// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
+ Future initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
try {
emit(const ChatRoomState.loading());
- final chatRoom = await chatRepository.getChatRoom(uuid);
- ChosenPlanModel? chosenPlan;
- if (chatRoom.chosenPlanId != null) {
- chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
+
+ String? resolvedUuid = uuid;
+ int? resolvedChosenPlanId;
+
+ if (orderId != null && orderId.isNotEmpty) {
+ final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
+ resolvedUuid = resolved.conversationUuid;
+ resolvedChosenPlanId = resolved.chosenPlanId;
}
- final chatMessages = await chatRepository.getChatMessages(uuid, currentPage);
+
+ if (resolvedUuid == null || resolvedUuid.isEmpty) {
+ throw ArgumentError('initChat requires uuid or orderId');
+ }
+
+ final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
+ ChosenPlanModel? chosenPlan;
+ final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
+ if (planId != null) {
+ chosenPlan = await chatRepository.getChosenPlan(id: planId);
+ }
+ final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
lastPage = chatMessages.meta.lastPage;
- await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id);
+ await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
emit(
ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -118,6 +139,7 @@ class ChatRoomCubit extends Cubit {
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
}
+ return resolvedUuid;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
@@ -125,6 +147,7 @@ class ChatRoomCubit extends Cubit {
messagesSubscription?.cancel();
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
+ return null;
}
}
@@ -414,7 +437,7 @@ class ChatRoomCubit extends Cubit {
try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId == null) return null;
- final paymentModel = await chatRepository.getPaymentLink(id: planId);
+ final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
return paymentModel.paymentUrl;
} catch (e, st) {
await reportFrom(e, st, this);
diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart
index e712955b..432bebdb 100644
--- a/lib/features/chat/presentation/pages/chat_screen.dart
+++ b/lib/features/chat/presentation/pages/chat_screen.dart
@@ -42,10 +42,11 @@ import 'package:intl/intl.dart';
import 'package:visibility_detector/visibility_detector.dart';
class ChatScreen extends StatefulWidget {
- final String uuid;
+ final String? uuid;
+ final String? orderId;
final bool? sendInitialMessage;
- const ChatScreen({required this.uuid, super.key, required this.sendInitialMessage});
+ const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
@override
State createState() => _ChatScreenState();
@@ -61,6 +62,11 @@ class _ChatScreenState extends State {
late AppLifecycleListener _listener;
List pickedFiles = [];
+ /// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
+ String? _effectiveUuid;
+
+ String? get conversationUuid => _effectiveUuid ?? widget.uuid;
+
void _onHide() {
chatRoomCubit.cancelTimer();
}
@@ -70,26 +76,41 @@ class _ChatScreenState extends State {
chatRoomCubit.setTimer();
}
+ Future _initChatRoom() async {
+ final uuid = await chatRoomCubit.initChat(
+ conversationUuid,
+ orderId: _effectiveUuid == null ? widget.orderId : null,
+ sendInitialMessage: widget.sendInitialMessage ?? false,
+ );
+ if (uuid != null && mounted) {
+ setState(() => _effectiveUuid = uuid);
+ }
+ }
+
@override
void didUpdateWidget(ChatScreen oldWidget) {
super.didUpdateWidget(oldWidget);
- if (oldWidget.uuid != widget.uuid) {
- chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
+ if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) {
+ _effectiveUuid = widget.uuid;
+ _initChatRoom();
}
}
@override
void initState() {
super.initState();
+ _effectiveUuid = widget.uuid;
scrollController.addListener(_onScroll);
- chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
+ _initChatRoom();
chatRoomCubit.setTimer();
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
}
void _onScroll() {
+ final uuid = conversationUuid;
+ if (uuid == null) return;
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
- chatRoomCubit.loadNextPage(widget.uuid);
+ chatRoomCubit.loadNextPage(uuid);
}
}
@@ -123,7 +144,7 @@ class _ChatScreenState extends State {
},
),
),
- body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)),
+ body: DataErrorWidget(onReload: _initChatRoom),
);
} else {
return Scaffold(
@@ -147,10 +168,7 @@ class _ChatScreenState extends State {
}
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
- final chatCounterpart = resolveChatCounterpart(
- users: state.chatRoom.users,
- currentUserId: sl().userId,
- );
+ final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl().userId);
final chosenPlan = state.chatRoom.chosenPlan;
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [
@@ -238,12 +256,7 @@ class _ChatScreenState extends State {
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
),
AnimatedCrossFade(
- firstChild: Text(
- 'Печатает...',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: CustomTextStyles.bodySmallMulishPrimary,
- ),
+ firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut,
secondChild: ChatUserOnlineStatusLine(
@@ -264,9 +277,7 @@ class _ChatScreenState extends State {
actions: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
- child: _ChatHeaderVideoCallButton(
- onPressed: () => _openVideoCall(context, state, chatCounterpart),
- ),
+ child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
),
],
),
@@ -771,7 +782,7 @@ class _ChatScreenState extends State {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: {
- 'conversationUuid': widget.uuid,
+ 'conversationUuid': conversationUuid,
'counterpartName': chatCounterpart.name,
'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
'counterpartUserId': chatCounterpart.id,
@@ -833,9 +844,7 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
child: SizedBox(
width: 40,
height: 40,
- child: Center(
- child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
- ),
+ child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
),
),
);
diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart
index c2a8069b..11db4288 100644
--- a/lib/features/chat/presentation/widgets/notification_banner.dart
+++ b/lib/features/chat/presentation/widgets/notification_banner.dart
@@ -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/payment_flow.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 {
- bool isLoading = false;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State {
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 {
),
const SizedBox(height: 12),
GestureDetector(
- onTap: () async {
+ onTap: () {
final planId = (context.read().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
- setState(() {
- isLoading = true;
- });
- final paymentLink = await context.read().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) {
+ showConsultationPaymentFlow(context, planId: planId);
}
},
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart
index fc079241..ade2ed26 100644
--- a/lib/features/chat/repositories/chat_repository.dart
+++ b/lib/features/chat/repositories/chat_repository.dart
@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart';
@@ -98,8 +99,19 @@ class ChatRepository {
Future getPaymentLink({
required int id,
+ required String paymentProvider,
}) async {
- return await chatService.getPaymentLink(id: id);
+ return await chatService.getPaymentLink(
+ id: id,
+ body: {'payment_provider': paymentProvider},
+ );
+
+ }
+
+ Future resolveOrderIdToConversation({
+ required String orderId,
+ }) async {
+ return await chatService.resolveOrderIdToConversation(orderId: orderId);
}
diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart
index a2bf52cf..2d5ceaac 100644
--- a/lib/features/chat/services/chat_service.dart
+++ b/lib/features/chat/services/chat_service.dart
@@ -3,6 +3,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -65,6 +66,12 @@ abstract class ChatService {
@POST('/api/chosen_plans/{id}/start_payment_process')
Future getPaymentLink({
@Path() required int id,
+ @Body() required Map body,
+ });
+
+ @GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
+ Future resolveOrderIdToConversation({
+ @Path() required String orderId,
});
@GET('/api/chosen_plans/show/{id}')
diff --git a/lib/features/order_info/cubit/order_info_cubit.dart b/lib/features/order_info/cubit/order_info_cubit.dart
index ff331c01..1c1630b6 100644
--- a/lib/features/order_info/cubit/order_info_cubit.dart
+++ b/lib/features/order_info/cubit/order_info_cubit.dart
@@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit {
try {
final plan = state.chosenPlan;
if (plan == null) return null;
- final paymentModel = await chatRepository.getPaymentLink(id: plan.id);
+ final paymentModel = await chatRepository.getPaymentLink(id: plan.id, paymentProvider: 'alfa-bank');
return paymentModel.paymentUrl;
} catch (e, st) {
await reportFrom(e, st, this);
diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart
new file mode 100644
index 00000000..ad0ae25e
--- /dev/null
+++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart
@@ -0,0 +1,98 @@
+import 'dart:developer';
+
+import 'package:eatvio_app/core/error_reporting/report_from.dart';
+import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
+import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
+import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:sbp/models/c2bmembers_model.dart';
+
+/// Cubit оплаты консультации через СБП: старт оплаты, список банков, редирект в банк.
+///
+/// Не хранит [planId] как поле — он передаётся в [startPayment].
+/// Polling статуса оплаты остаётся в ChatRoomCubit.
+class ConsultationPaymentCubit extends Cubit {
+ final PaymentRepository paymentRepository;
+ final SbpRepository sbpRepository;
+
+ ConsultationPaymentCubit({
+ required this.paymentRepository,
+ required this.sbpRepository,
+ }) : super(ConsultationPaymentState());
+
+ /// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП.
+ Future startPayment({required int planId}) async {
+ try {
+ emit(
+ state.copyWith(
+ isLoading: true,
+ errorMessage: null,
+ banks: const [],
+ checkoutUrl: null,
+ orderId: null,
+ ),
+ );
+
+ final payment = await paymentRepository.startPaymentProcess(planId: planId);
+
+ emit(
+ state.copyWith(
+ checkoutUrl: payment.paymentUrl,
+ orderId: payment.orderId,
+ ),
+ );
+
+ try {
+ final banks = await sbpRepository.getInstalledBanks();
+ if (isClosed) return;
+ emit(
+ state.copyWith(
+ banks: banks,
+ isLoading: false,
+ errorMessage: null,
+ ),
+ );
+ } catch (e, st) {
+ await reportFrom(e, st, this);
+ log(e.toString(), stackTrace: st);
+ if (isClosed) return;
+ emit(
+ state.copyWith(
+ isLoading: false,
+ banks: const [],
+ errorMessage: e.toString(),
+ ),
+ );
+ }
+ } catch (e, st) {
+ await reportFrom(e, st, this);
+ log(e.toString(), stackTrace: st);
+ if (isClosed) return;
+ emit(
+ state.copyWith(
+ isLoading: false,
+ banks: const [],
+ checkoutUrl: null,
+ orderId: null,
+ errorMessage: e.toString(),
+ ),
+ );
+ }
+ }
+
+ /// Открывает банковское приложение [bank] с текущим [ConsultationPaymentState.checkoutUrl].
+ Future openBank({required C2bmemberModel bank}) async {
+ final checkoutUrl = state.checkoutUrl;
+ if (checkoutUrl == null || checkoutUrl.isEmpty) {
+ return false;
+ }
+
+ try {
+ return await sbpRepository.openBank(url: checkoutUrl, bank: bank);
+ } catch (e, st) {
+ await reportFrom(e, st, this);
+ log(e.toString(), stackTrace: st);
+ return false;
+ }
+ }
+}
diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart
new file mode 100644
index 00000000..31aa1c04
--- /dev/null
+++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart
@@ -0,0 +1,15 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:sbp/models/c2bmembers_model.dart';
+
+part 'consultation_payment_state.freezed.dart';
+
+@freezed
+abstract class ConsultationPaymentState with _$ConsultationPaymentState {
+ factory ConsultationPaymentState({
+ @Default(false) bool isLoading,
+ @Default([]) List banks,
+ String? checkoutUrl,
+ String? orderId,
+ String? errorMessage,
+ }) = _ConsultationPaymentState;
+}
diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart
new file mode 100644
index 00000000..03cfe5b5
--- /dev/null
+++ b/lib/features/payment/presentation/payment_flow.dart
@@ -0,0 +1,49 @@
+import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
+import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.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';
+
+/// Оркестрирует флоу оплаты консультации:
+/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
+Future showConsultationPaymentFlow(
+ BuildContext context, {
+ required int planId,
+ Future Function(BuildContext)? showGate,
+ Future Function(BuildContext, {Future Function(String)? onValidate})? showForm,
+ Future Function(BuildContext, {required int planId})? showSbp,
+}) async {
+ final gate = showGate ?? _showGate;
+ final form = showForm ?? _showForm;
+ final sbp = showSbp ?? _showSbp;
+ final s = S.of(context);
+
+ final gateChoice = await gate(context);
+ if (gateChoice == null || !context.mounted) return;
+
+ if (gateChoice) {
+ final formResult = await form(
+ context,
+ onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
+ );
+ if (formResult == null || !context.mounted) return;
+ }
+
+ if (!context.mounted) return;
+ await sbp(context, planId: planId);
+}
+
+Future _showGate(BuildContext context) {
+ return showPromoCodeGateBottomSheet(context);
+}
+
+Future _showForm(
+ BuildContext context, {
+ Future Function(String)? onValidate,
+}) {
+ return ItvioPromoCodeModal.show(context, onValidate: onValidate);
+}
+
+Future _showSbp(BuildContext context, {required int planId}) {
+ return showSbpBanksBottomSheet(context, planId: planId);
+}
diff --git a/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart
new file mode 100644
index 00000000..b72a3f13
--- /dev/null
+++ b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart
@@ -0,0 +1,97 @@
+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/generated/l10n.dart';
+import 'package:flutter/material.dart';
+
+/// Показывает bottom sheet «У вас есть промокод?» с кнопками Да/Нет.
+///
+/// Возвращает `true` если пользователь нажал «Да», `false` если «Нет»,
+/// `null` если закрыл шторку (бекдроп/крестик).
+Future showPromoCodeGateBottomSheet(BuildContext context) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ barrierColor: appTheme.transparentBlack,
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ useSafeArea: true,
+ builder: (_) => Container(
+ decoration: const BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ child: _PromoCodeGateSheet(),
+ ),
+ );
+}
+
+/// Виджет-гейт выбора промокода.
+class PromoCodeGateBottomSheet extends StatelessWidget {
+ const PromoCodeGateBottomSheet({super.key, required this.onResult});
+
+ final ValueChanged onResult;
+
+ @override
+ Widget build(BuildContext context) {
+ final s = S.of(context);
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Center(child: BottomSheetHandlerBar()),
+ const SizedBox(height: 24),
+ Text(
+ s.have_promo_code,
+ style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ SizedBox(
+ height: 50,
+ child: FilledButton(
+ onPressed: () => onResult(true),
+ style: FilledButton.styleFrom(
+ backgroundColor: appTheme.blueA400,
+ foregroundColor: appTheme.whiteA700,
+ shape: const StadiumBorder(),
+ ),
+ child: Text(
+ s.yes,
+ style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 50,
+ child: OutlinedButton(
+ onPressed: () => onResult(false),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: appTheme.blueA400,
+ side: BorderSide(color: appTheme.blueA400),
+ shape: const StadiumBorder(),
+ ),
+ child: Text(
+ s.no,
+ style: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Внутренняя реализация модалки — обёртка над [PromoCodeGateBottomSheet].
+class _PromoCodeGateSheet extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return PromoCodeGateBottomSheet(
+ onResult: (result) => Navigator.of(context).pop(result),
+ );
+ }
+}
diff --git a/lib/features/payment/presentation/widgets/promo_code_modal.dart b/lib/features/payment/presentation/widgets/promo_code_modal.dart
new file mode 100644
index 00000000..0d4a9942
--- /dev/null
+++ b/lib/features/payment/presentation/widgets/promo_code_modal.dart
@@ -0,0 +1,281 @@
+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/generated/l10n.dart';
+import 'package:flutter/material.dart';
+
+/// Результат работы модалки промокода.
+class PromoCodeResult {
+ const PromoCodeResult({this.code});
+
+ /// Применённый промокод или null, если пользователь продолжил без него.
+ final String? code;
+
+ bool get hasPromoCode => code != null;
+}
+
+/// Ответ проверки промокода.
+class PromoValidation {
+ const PromoValidation.success() : error = null;
+ const PromoValidation.failure(this.error);
+
+ final String? error;
+
+ bool get isValid => error == null;
+}
+
+/// Модальное окно ввода промокода.
+class ItvioPromoCodeModal extends StatefulWidget {
+ const ItvioPromoCodeModal({super.key, this.onValidate});
+
+ final Future Function(String code)? onValidate;
+
+ static Future show(
+ BuildContext context, {
+ Future Function(String code)? onValidate,
+ }) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ useSafeArea: true,
+ backgroundColor: Colors.transparent,
+ builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
+ );
+ }
+
+ @override
+ State createState() => _ItvioPromoCodeModalState();
+}
+
+enum _ModalState { input, loading }
+
+class _ItvioPromoCodeModalState extends State {
+ final _controller = TextEditingController();
+ final _focusNode = FocusNode();
+
+ _ModalState _state = _ModalState.input;
+ String? _errorText;
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ Future _apply() async {
+ final code = _controller.text.trim().toUpperCase();
+
+ if (code.isEmpty) {
+ setState(() => _errorText = S.of(context).enter_promo_code);
+ return;
+ }
+
+ setState(() {
+ _state = _ModalState.loading;
+ _errorText = null;
+ });
+
+ PromoValidation validation;
+ try {
+ validation = widget.onValidate != null
+ ? await widget.onValidate!(code)
+ : const PromoValidation.success();
+ } catch (_) {
+ validation = const PromoValidation.failure(
+ 'Не удалось проверить промокод. Попробуйте позже',
+ );
+ }
+
+ if (!mounted) return;
+
+ if (!validation.isValid) {
+ setState(() {
+ _state = _ModalState.input;
+ _errorText = validation.error;
+ });
+ _focusNode.requestFocus();
+ return;
+ }
+
+ Navigator.of(context).pop(PromoCodeResult(code: code));
+ }
+
+ void _onSkip() {
+ Navigator.of(context).pop(const PromoCodeResult());
+ }
+
+ bool get _isLoading => _state == _ModalState.loading;
+
+ @override
+ Widget build(BuildContext context) {
+ final bottomInset = MediaQuery.of(context).viewInsets.bottom;
+ final s = S.of(context);
+
+ return Padding(
+ padding: EdgeInsets.only(bottom: bottomInset),
+ child: Container(
+ width: double.infinity,
+ decoration: const BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Center(child: BottomSheetHandlerBar()),
+ const SizedBox(height: 16),
+ _buildHeader(),
+ const SizedBox(height: 16),
+ const Text(
+ 'Промокод',
+ style: TextStyle(
+ fontSize: 13,
+ color: Color(0xFF6B7684),
+ ),
+ ),
+ const SizedBox(height: 8),
+ _buildField(),
+ const SizedBox(height: 8),
+ _buildHint(),
+ const SizedBox(height: 12),
+ _buildApplyButton(s),
+ const SizedBox(height: 6),
+ _buildSkipButton(s),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader() {
+ final s = S.of(context);
+ return Text(
+ s.have_promo_code_form,
+ style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
+ );
+ }
+
+ Widget _buildField() {
+ final hasError = _errorText != null;
+ final s = S.of(context);
+
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 150),
+ decoration: BoxDecoration(
+ color: appTheme.gray100,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ width: 2,
+ color: hasError
+ ? appTheme.red
+ : (_focusNode.hasFocus
+ ? appTheme.blueA400
+ : Colors.transparent),
+ ),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _controller,
+ focusNode: _focusNode,
+ enabled: !_isLoading,
+ textCapitalization: TextCapitalization.characters,
+ onChanged: (_) {
+ if (_errorText != null) {
+ setState(() => _errorText = null);
+ }
+ },
+ onSubmitted: (_) => _apply(),
+ decoration: InputDecoration(
+ hintText: s.enter_promo_code,
+ hintStyle: const TextStyle(
+ color: Color(0xFF9AA3AF),
+ ),
+ border: InputBorder.none,
+ isDense: true,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 14,
+ ),
+ ),
+ style: const TextStyle(
+ fontSize: 15,
+ color: Color(0xFF24303F),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHint() {
+ final String text;
+ final Color color;
+
+ if (_errorText != null) {
+ text = _errorText!;
+ color = appTheme.red;
+ } else {
+ text = '';
+ color = Colors.transparent;
+ }
+
+ return SizedBox(
+ height: 19,
+ child: Text(
+ text,
+ style: TextStyle(fontSize: 13, color: color),
+ overflow: TextOverflow.ellipsis,
+ ),
+ );
+ }
+
+ Widget _buildApplyButton(S s) {
+ return SizedBox(
+ height: 50,
+ child: FilledButton(
+ onPressed: _isLoading ? null : _apply,
+ style: FilledButton.styleFrom(
+ backgroundColor: appTheme.blueA400,
+ foregroundColor: appTheme.whiteA700,
+ disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
+ shape: const StadiumBorder(),
+ textStyle: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ child: _isLoading
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.5,
+ color: Colors.white,
+ ),
+ )
+ : Text(s.apply),
+ ),
+ );
+ }
+
+ Widget _buildSkipButton(S s) {
+ return SizedBox(
+ height: 46,
+ child: TextButton(
+ onPressed: _isLoading ? null : _onSkip,
+ style: TextButton.styleFrom(
+ foregroundColor: appTheme.blueA400,
+ shape: const StadiumBorder(),
+ textStyle: const TextStyle(fontSize: 14),
+ ),
+ child: Text(s.skip),
+ ),
+ );
+ }
+}
diff --git a/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart
new file mode 100644
index 00000000..5b0f95e7
--- /dev/null
+++ b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart
@@ -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 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 createState() => _SbpBanksBottomSheetState();
+}
+
+class _SbpBanksBottomSheetState extends State {
+ late final ConsultationPaymentCubit _cubit;
+ late final bool _ownsCubit;
+
+ @override
+ void initState() {
+ super.initState();
+ _ownsCubit = widget.cubit == null;
+ _cubit = widget.cubit ?? sl();
+ 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.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(
+ 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 _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,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/features/payment/repositories/payment_repository.dart b/lib/features/payment/repositories/payment_repository.dart
new file mode 100644
index 00000000..1d590e48
--- /dev/null
+++ b/lib/features/payment/repositories/payment_repository.dart
@@ -0,0 +1,18 @@
+import 'package:eatvio_app/features/chat/models/payment_model.dart';
+import 'package:eatvio_app/features/chat/services/chat_service.dart';
+
+/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
+class PaymentRepository {
+ final ChatService chatService;
+
+ PaymentRepository({required this.chatService});
+
+ /// Запускает процесс оплаты: POST `/api/chosen_plans/{id}/start_payment_process`
+ /// с `payment_provider: alfa-bank`.
+ Future startPaymentProcess({required int planId}) async {
+ return chatService.getPaymentLink(
+ id: planId,
+ body: const {'payment_provider': 'alfa-bank'},
+ );
+ }
+}
diff --git a/lib/features/payment/repositories/sbp_repository.dart b/lib/features/payment/repositories/sbp_repository.dart
new file mode 100644
index 00000000..59dd2824
--- /dev/null
+++ b/lib/features/payment/repositories/sbp_repository.dart
@@ -0,0 +1,27 @@
+import 'package:eatvio_app/features/payment/services/sbp_service.dart';
+import 'package:sbp/models/c2bmembers_model.dart';
+
+/// Репозиторий оплаты по СБП: обёртка над [SbpService] с graceful-обработкой
+/// ошибок нативного плагина (отсутствие установленных банков, недоступность
+/// сервисов на устройстве, в т.ч. на Huawei HMS и т.п.).
+///
+/// Возвращает управляемые значения вместо проброса исключений, чтобы UI мог
+/// показать fallback (например, открыть checkout_url в браузере/WebView).
+class SbpRepository {
+ final SbpService sbpService;
+
+ SbpRepository({required this.sbpService});
+
+ /// Возвращает установленные банки СБП либо пустой список при ошибке плагина.
+ Future> getInstalledBanks() async {
+ return await sbpService.getInstalledBanks();
+ }
+
+ /// Открывает банк для оплаты. Возвращает `false` при ошибке плагина.
+ Future openBank({
+ required String url,
+ required C2bmemberModel bank,
+ }) async {
+ return await sbpService.openBank(url: url, bank: bank);
+ }
+}
diff --git a/lib/features/payment/services/sbp_service.dart b/lib/features/payment/services/sbp_service.dart
new file mode 100644
index 00000000..1eea997c
--- /dev/null
+++ b/lib/features/payment/services/sbp_service.dart
@@ -0,0 +1,43 @@
+import 'package:sbp/data/c2bmembers_data.dart';
+import 'package:sbp/models/c2bmembers_model.dart';
+import 'package:sbp/sbp.dart' as sbp;
+
+/// Контракт обёртки над нативным плагином СБП (Система Быстрых Платежей).
+///
+/// Статичные методы класса [Sbp] из пакета `sbp` работают через MethodChannel,
+/// поэтому не поддаются прямому мокированию в тестах. Этот интерфейс вводится
+/// для тестируемости и возможности подмены реализации (см. [SbpServiceImpl]).
+abstract class SbpService {
+ /// Возвращает список банков, установленных на устройстве пользователя
+ /// и поддерживающих оплату по СБП.
+ Future> getInstalledBanks();
+
+ /// Открывает банковское приложение [bank] для оплаты по ссылке [url]
+ /// (checkout_url вида `https://qr.nspk.ru/...`).
+ ///
+ /// Возвращает `true`, если приложение банка успешно запущено.
+ Future openBank({required String url, required C2bmemberModel bank});
+}
+
+/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`.
+class SbpServiceImpl implements SbpService {
+ const SbpServiceImpl();
+
+ @override
+ Future> getInstalledBanks() {
+ // Используем встроенный справочник банков c2bmembersData (получаемый с
+ // https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что
+ // установлены на устройстве. Локальные иконки/названия включены, чтобы UI
+ // не зависел от сетевой загрузки логотипов.
+ return sbp.Sbp.getInstalledBanks(
+ C2bmembersModel.fromJson(c2bmembersData),
+ useAndroidLocalIcons: true,
+ useAndroidLocalNames: true,
+ );
+ }
+
+ @override
+ Future openBank({required String url, required C2bmemberModel bank}) {
+ return sbp.Sbp.openBank(url, bank);
+ }
+}
diff --git a/lib/features/plans/enums/chosen_plan_state_enum.dart b/lib/features/plans/enums/chosen_plan_state_enum.dart
index 2f37ec30..2e867f38 100644
--- a/lib/features/plans/enums/chosen_plan_state_enum.dart
+++ b/lib/features/plans/enums/chosen_plan_state_enum.dart
@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
@JsonValue('personal_diet_setted')
personalDietSet,
@JsonValue('charge_was_collected')
- stateChargeWasCollected;
+ stateChargeWasCollected,
+ @JsonValue('payment_failed')
+ statePaymentFailed;
static List getShowableChatBadgeStates() {
return [
@@ -53,6 +55,7 @@ enum ChosenPlanStateEnum {
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
+ case ChosenPlanStateEnum.statePaymentFailed:
return false;
default:
return true;
@@ -68,7 +71,7 @@ enum ChosenPlanStateEnum {
ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateComplained,
ChosenPlanStateEnum.stateDone,
- ChosenPlanStateEnum.personalDietSet
+ ChosenPlanStateEnum.personalDietSet,
];
}
}
diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart
index 37ffb25c..e563a193 100644
--- a/lib/generated/intl/messages_en.dart
+++ b/lib/generated/intl/messages_en.dart
@@ -123,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary {
"another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"),
"another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"),
"answer": MessageLookupByLibrary.simpleMessage("Ответить"),
+ "apply": MessageLookupByLibrary.simpleMessage("Apply"),
"archived": MessageLookupByLibrary.simpleMessage("В архиве"),
"atLeast3Symbols": MessageLookupByLibrary.simpleMessage(
"Минимум 3 символа",
@@ -173,6 +174,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(
"Выберите срок диеты",
@@ -378,6 +382,9 @@ class MessageLookup extends MessageLookupByLibrary {
"enter_information": MessageLookupByLibrary.simpleMessage(
"Введите информацию...",
),
+ "enter_promo_code": MessageLookupByLibrary.simpleMessage(
+ "Enter promo code",
+ ),
"error_desc": MessageLookupByLibrary.simpleMessage(
"Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже",
),
@@ -463,6 +470,12 @@ class MessageLookup extends MessageLookupByLibrary {
"hatred_description": MessageLookupByLibrary.simpleMessage(
"Враждебные высказывания",
),
+ "have_promo_code": MessageLookupByLibrary.simpleMessage(
+ "Do you have a promo code?",
+ ),
+ "have_promo_code_form": MessageLookupByLibrary.simpleMessage(
+ "Have a promo code?",
+ ),
"height": MessageLookupByLibrary.simpleMessage("Рост"),
"height_error": MessageLookupByLibrary.simpleMessage("Введите рост"),
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
@@ -588,6 +601,9 @@ class MessageLookup extends MessageLookupByLibrary {
"no_default_cards": MessageLookupByLibrary.simpleMessage(
"У вас нет привязанных карт. Что бы начать работу, привяжите карту",
),
+ "no_installed_banks": MessageLookupByLibrary.simpleMessage(
+ "Нет установленных банков",
+ ),
"not_liked_groceries": MessageLookupByLibrary.simpleMessage(
"Не любимые продукты",
),
@@ -699,6 +715,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио",
),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
+ "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
+ "promo_invalid": MessageLookupByLibrary.simpleMessage(
+ "Promo code not found or expired",
+ ),
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
@@ -737,6 +757,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("Оказание услуг"),
@@ -981,6 +1004,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя",
),
+ "yes": MessageLookupByLibrary.simpleMessage("Yes"),
"youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу",
),
diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart
index 0f59a0dc..5783d1f0 100644
--- a/lib/generated/intl/messages_ru.dart
+++ b/lib/generated/intl/messages_ru.dart
@@ -45,6 +45,7 @@ class MessageLookup extends MessageLookupByLibrary {
),
"another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"),
"answer": MessageLookupByLibrary.simpleMessage("Ответить"),
+ "apply": MessageLookupByLibrary.simpleMessage("Применить"),
"archived": MessageLookupByLibrary.simpleMessage("В архиве"),
"authentificate": MessageLookupByLibrary.simpleMessage("Войти"),
"authorization": MessageLookupByLibrary.simpleMessage("Авторизация"),
@@ -62,6 +63,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(
"Выберите срок диеты",
),
@@ -151,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary {
"enter_information": MessageLookupByLibrary.simpleMessage(
"Введите информацию...",
),
+ "enter_promo_code": MessageLookupByLibrary.simpleMessage(
+ "Введите промокод",
+ ),
"exit": MessageLookupByLibrary.simpleMessage("Выйти"),
"expand": MessageLookupByLibrary.simpleMessage("Показать полностью"),
"fats": MessageLookupByLibrary.simpleMessage("Жиры"),
@@ -181,6 +188,12 @@ class MessageLookup extends MessageLookupByLibrary {
"gallery": MessageLookupByLibrary.simpleMessage("Галерея"),
"gr": MessageLookupByLibrary.simpleMessage("гр"),
"grams": MessageLookupByLibrary.simpleMessage("гр"),
+ "have_promo_code": MessageLookupByLibrary.simpleMessage(
+ "У вас есть промокод?",
+ ),
+ "have_promo_code_form": MessageLookupByLibrary.simpleMessage(
+ "Есть промокод?",
+ ),
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
"hide": MessageLookupByLibrary.simpleMessage("Свернуть"),
"hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"),
@@ -243,6 +256,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("Опция"),
@@ -306,6 +322,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио",
),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
+ "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
+ "promo_invalid": MessageLookupByLibrary.simpleMessage(
+ "Промокод не найден или истёк",
+ ),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
@@ -314,6 +334,9 @@ class MessageLookup extends MessageLookupByLibrary {
"Снять с публикации",
),
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
+ "sbp_payment_error": MessageLookupByLibrary.simpleMessage(
+ "Не удалось начать оплату. Попробуйте позже",
+ ),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
"share": MessageLookupByLibrary.simpleMessage("Поделиться"),
@@ -441,6 +464,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя",
),
+ "yes": MessageLookupByLibrary.simpleMessage("Да"),
"you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"),
"your_blood_pressure": MessageLookupByLibrary.simpleMessage(
"Ваше артериальное давление?",
diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart
index 5fdc1ea4..6076d4b0 100644
--- a/lib/generated/l10n.dart
+++ b/lib/generated/l10n.dart
@@ -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(
@@ -4179,6 +4209,61 @@ class S {
);
}
+ /// `Yes`
+ String get yes {
+ return Intl.message('Yes', name: 'yes', desc: '', args: []);
+ }
+
+ /// `Apply`
+ String get apply {
+ return Intl.message('Apply', name: 'apply', desc: '', args: []);
+ }
+
+ /// `Do you have a promo code?`
+ String get have_promo_code {
+ return Intl.message(
+ 'Do you have a promo code?',
+ name: 'have_promo_code',
+ desc: '',
+ args: [],
+ );
+ }
+
+ /// `Have a promo code?`
+ String get have_promo_code_form {
+ return Intl.message(
+ 'Have a promo code?',
+ name: 'have_promo_code_form',
+ desc: '',
+ args: [],
+ );
+ }
+
+ /// `Promo code`
+ String get promo_code {
+ return Intl.message('Promo code', name: 'promo_code', desc: '', args: []);
+ }
+
+ /// `Enter promo code`
+ String get enter_promo_code {
+ return Intl.message(
+ 'Enter promo code',
+ name: 'enter_promo_code',
+ desc: '',
+ args: [],
+ );
+ }
+
+ /// `Promo code not found or expired`
+ String get promo_invalid {
+ return Intl.message(
+ 'Promo code not found or expired',
+ name: 'promo_invalid',
+ desc: '',
+ args: [],
+ );
+ }
+
/// `Loading…`
String get video_call_screen_loading_title {
return Intl.message(
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index 8dba037c..d884d273 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -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":"Отказаться от заказа",
@@ -492,6 +495,13 @@
"video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.",
"video_call_permission_microphone_title": "Microphone access",
"video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.",
+ "yes": "Yes",
+ "apply": "Apply",
+ "have_promo_code": "Do you have a promo code?",
+ "have_promo_code_form": "Have a promo code?",
+ "promo_code": "Promo code",
+ "enter_promo_code": "Enter promo code",
+ "promo_invalid": "Promo code not found or expired",
"video_call_screen_loading_title": "Loading…",
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
"video_call_screen_connecting_subtitle": "This may take a few seconds",
diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb
index 9b4fefd4..1a6bf995 100644
--- a/lib/l10n/intl_ru.arb
+++ b/lib/l10n/intl_ru.arb
@@ -236,6 +236,16 @@
"video_call_permission_camera_title": "Доступ к камере",
"video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
"video_call_permission_microphone_title": "Доступ к микрофону",
+ "choose_bank_for_payment": "Выберите банк",
+ "no_installed_banks": "Нет установленных банков",
+ "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
+ "yes": "Да",
+ "apply": "Применить",
+ "have_promo_code": "У вас есть промокод?",
+ "have_promo_code_form": "Есть промокод?",
+ "promo_code": "Промокод",
+ "enter_promo_code": "Введите промокод",
+ "promo_invalid": "Промокод не найден или истёк",
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
"video_call_screen_loading_title": "Загрузка…",
"video_call_screen_connecting_title": "Подключение\nк консультации",
diff --git a/pubspec.yaml b/pubspec.yaml
index 81aa249c..cb90827b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -90,6 +90,7 @@ dependencies:
flutter_callkit_incoming: ^3.1.1
floating: ^6.0.0
wakelock_plus: ^1.5.2
+ sbp: 0.1.0
dev_dependencies:
build_runner: ^2.10.4
diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart
index 19c61dab..648c5130 100644
--- a/test/core/app_router_test.dart
+++ b/test/core/app_router_test.dart
@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/app_router.dart';
+import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -6,6 +7,50 @@ import 'package:go_router/go_router.dart';
void main() {
group('AppRouter', () {
+ group('resolveDeepLinkTarget - finance/alfa/return', () {
+ const orderId = 'alfa_c29_k15138_t1712345678_r1234';
+
+ test('finance/alfa/return/{orderId} → chat-screen?orderId=', () {
+ final target = AppRouter.resolveDeepLinkTarget(
+ Uri.parse('https://eatvio.ru/finance/alfa/return/$orderId'),
+ );
+
+ expect(target, '${RouteConstants.chatScreen}?orderId=$orderId');
+ });
+
+ test('ru/finance/alfa/return/{orderId} → chat-screen?orderId= (metaSegments)', () {
+ final target = AppRouter.resolveDeepLinkTarget(
+ Uri.parse('https://eatvio.ru/ru/finance/alfa/return/$orderId'),
+ );
+
+ expect(target, '${RouteConstants.chatScreen}?orderId=$orderId');
+ });
+
+ test('неполный path /finance/alfa → feed', () {
+ final target = AppRouter.resolveDeepLinkTarget(
+ Uri.parse('https://eatvio.ru/finance/alfa'),
+ );
+
+ expect(target, RouteConstants.feedScreen);
+ });
+
+ test('неполный path /finance → feed', () {
+ final target = AppRouter.resolveDeepLinkTarget(
+ Uri.parse('https://eatvio.ru/finance'),
+ );
+
+ expect(target, RouteConstants.feedScreen);
+ });
+
+ test('регресс: chat/{uuid} → chat-screen?uuid=', () {
+ final target = AppRouter.resolveDeepLinkTarget(
+ Uri.parse('https://eatvio.ru/ru/chat/uuid-1'),
+ );
+
+ expect(target, '${RouteConstants.chatScreen}?uuid=uuid-1');
+ });
+ });
+
test('navigatorKey инициализирован', () {
expect(AppRouter.navigatorKey, isNotNull);
expect(AppRouter.navigatorKey, isA>());
diff --git a/test/features/chat/models/order_id_to_conversation_model_test.dart b/test/features/chat/models/order_id_to_conversation_model_test.dart
new file mode 100644
index 00000000..d64441b7
--- /dev/null
+++ b/test/features/chat/models/order_id_to_conversation_model_test.dart
@@ -0,0 +1,22 @@
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../../../fixtures/fixture_reader.dart';
+
+void main() {
+ group('OrderIdToConversationModel', () {
+ test('fromJson создаёт модель из JSON фикстуры', () {
+ final json = FixtureReader.readAsMap(
+ 'chat/payments/order_id_to_chat_uuid_response.json',
+ );
+
+ final model = OrderIdToConversationModel.fromJson(json);
+
+ expect(model.chosenPlanId, 12);
+ expect(
+ model.conversationUuid,
+ 'fbd81429-8019-499a-80e8-e3933cc4d872',
+ );
+ });
+ });
+}
diff --git a/test/features/chat/models/payment_model_test.dart b/test/features/chat/models/payment_model_test.dart
index 8e26a01e..12019bff 100644
--- a/test/features/chat/models/payment_model_test.dart
+++ b/test/features/chat/models/payment_model_test.dart
@@ -1,25 +1,47 @@
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:flutter_test/flutter_test.dart';
+import '../../../fixtures/fixture_reader.dart';
+
void main() {
group('PaymentModel', () {
test('создает PaymentModel с правильными значениями', () {
- final model = PaymentModel(
- paymentUrl: 'https://example.com/payment',
- );
+ final model = PaymentModel(paymentUrl: 'https://example.com/payment');
expect(model.paymentUrl, 'https://example.com/payment');
});
test('fromJson создает PaymentModel из JSON', () {
- final json = {
- 'checkout_url': 'https://example.com/payment',
- };
+ final json = {'checkout_url': 'https://example.com/payment'};
final model = PaymentModel.fromJson(json);
expect(model.paymentUrl, 'https://example.com/payment');
});
+
+ test(
+ 'fromJson парсит ответ alfa-bank (checkout_url, order_id, payment_provider)',
+ () {
+ final json = FixtureReader.readAsMap(
+ 'chat/payments/payment_response_alfa_bank.json',
+ );
+
+ final model = PaymentModel.fromJson(json);
+
+ expect(model.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
+ expect(model.orderId, 'alfa_c29_k15138_t1712345678_r1234');
+ expect(model.paymentProvider, 'alfa-bank');
+ },
+ );
+
+ test('fromJson парсит mock-ответ (checkout_url без order_id)', () {
+ final json = FixtureReader.readAsMap('chat/payments/payment_response.json');
+
+ final model = PaymentModel.fromJson(json);
+
+ expect(model.paymentUrl, 'https://example.com/checkout/payment-123');
+ expect(model.orderId, isNull);
+ expect(model.paymentProvider, isNull);
+ });
});
}
-
diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart
index 9dd43523..d1f14bba 100644
--- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart
+++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart
@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
@@ -102,6 +103,89 @@ void main() {
// Let's check that at least the repository methods were called
verify(mockChatRepository.getChatRoom(uuid)).called(1);
verify(mockChatRepository.getChatMessages(uuid, 1)).called(1);
+ verifyNever(
+ mockChatRepository.resolveOrderIdToConversation(orderId: anyNamed('orderId')),
+ );
+ });
+
+ test('initChat - с orderId резолвит conversation и инициализирует чат', () async {
+ const orderId = 'alfa_c29_k15138_t1712345678_r1234';
+ const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872';
+ const chosenPlanId = 12;
+ final resolved = OrderIdToConversationModel(
+ chosenPlanId: chosenPlanId,
+ conversationUuid: conversationUuid,
+ );
+ final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: chosenPlanId);
+ const testPlan = PlanModel(
+ id: 1,
+ slug: 'test-plan',
+ name: 'Test Plan',
+ image: 'https://example.com/image.jpg',
+ duration: 7,
+ state: 'active',
+ type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
+ details: 'Test details',
+ packetsCount: 3,
+ author: Author(
+ authorSlug: 'author',
+ authorName: 'Author Name',
+ authorProfileIcon: 'https://example.com/icon.jpg',
+ authorIsMe: false,
+ ),
+ totalLikes: 10,
+ totalViews: 100,
+ totalComments: 5,
+ isFavorite: false,
+ minPriceOfOptions: 1000,
+ maxPriceOfOptions: 2000,
+ wasLikedByMe: false,
+ );
+ final chosenPlan = ChosenPlanModel(
+ id: chosenPlanId,
+ optionType: 'options1',
+ startDate: DateTime.now(),
+ chosenPlanState: ChosenPlanStateEnum.statePendingPayment,
+ chatId: 'chat-1',
+ consultantId: 2,
+ plan: testPlan,
+ );
+
+ when(
+ mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
+ ).thenAnswer((_) async => resolved);
+ when(mockChatRepository.getChatRoom(conversationUuid)).thenAnswer((_) async => chatRoomWithPlan);
+ when(mockChatRepository.getChatMessages(conversationUuid, 1)).thenAnswer((_) async => testChatMessages);
+ when(mockChatRepository.getChosenPlan(id: chosenPlanId)).thenAnswer((_) async => chosenPlan);
+ when(mockWsManager.client).thenReturn(null);
+ when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
+ when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
+ when(mockWsManager.directChatStream).thenReturn(null);
+ when(mockWsManager.clientTypingStream).thenReturn(null);
+
+ await chatRoomCubit.initChat(null, orderId: orderId);
+ await Future.delayed(const Duration(milliseconds: 200));
+
+ verify(mockChatRepository.resolveOrderIdToConversation(orderId: orderId)).called(1);
+ verify(mockChatRepository.getChatRoom(conversationUuid)).called(1);
+ verify(mockChatRepository.getChosenPlan(id: chosenPlanId)).called(1);
+ verify(mockChatRepository.getChatMessages(conversationUuid, 1)).called(1);
+ expect(chatRoomCubit.state, isA());
+ final loaded = chatRoomCubit.state as ChatRoomStateLoaded;
+ expect(loaded.chatRoom.chosenPlan?.id, chosenPlanId);
+ });
+
+ test('initChat - при ошибке resolve не вызывает getChatRoom', () async {
+ const orderId = 'alfa_c29_k15138_t1712345678_r1234';
+ when(
+ mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
+ ).thenThrow(Exception('Resolve failed'));
+
+ await chatRoomCubit.initChat(null, orderId: orderId);
+ await Future.delayed(const Duration(milliseconds: 100));
+
+ expect(chatRoomCubit.state, isA());
+ verifyNever(mockChatRepository.getChatRoom(any));
});
test('initChat - обрабатывает ошибку', () async {
@@ -678,7 +762,7 @@ void main() {
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan);
- when(mockChatRepository.getPaymentLink(id: planId)).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl));
+ when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl));
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
@@ -691,7 +775,7 @@ void main() {
final result = await chatRoomCubit.getPaymentLink();
expect(result, paymentUrl);
- verify(mockChatRepository.getPaymentLink(id: planId)).called(1);
+ verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).called(1);
});
test('getPaymentLink - обрабатывает ошибку', () async {
@@ -730,7 +814,7 @@ void main() {
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan);
- when(mockChatRepository.getPaymentLink(id: planId)).thenThrow(Exception('Payment error'));
+ when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenThrow(Exception('Payment error'));
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
diff --git a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart
index e4bbd786..d017b976 100644
--- a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart
+++ b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart
@@ -149,7 +149,8 @@ void main() {
when(chatRoomCubit.state).thenReturn(state);
when(chatRoomCubit.stream).thenAnswer((_) => const Stream.empty());
when(chatRoomCubit.close()).thenAnswer((_) async => Future.value());
- when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future.value());
+ when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
+ .thenAnswer((_) async => 'uuid');
when(chatRoomCubit.setTimer()).thenReturn(null);
diff --git a/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart
index 3a59cd8e..cf6d27cf 100644
--- a/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart
+++ b/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart
@@ -33,7 +33,8 @@ void main() {
when(cubit.state).thenReturn(const ChatRoomState.loading());
when(cubit.stream).thenAnswer((_) => const Stream.empty());
when(cubit.close()).thenAnswer((_) async => Future.value());
- when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future.value());
+ when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
+ .thenAnswer((_) async => 'uuid');
when(cubit.setTimer()).thenReturn(null);
sl.registerFactory(() => cubit);
diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart
index 53f8a9bd..247d1311 100644
--- a/test/features/chat/repositories/chat_repository_test.dart
+++ b/test/features/chat/repositories/chat_repository_test.dart
@@ -5,12 +5,15 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/core/models/pagination_model.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
-import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room;
+import 'package:eatvio_app/features/chat/models/chat_room_model.dart'
+ as chat_room;
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/services/chat_service.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:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
@@ -32,7 +35,9 @@ void main() {
});
test('emitNewMessageToChats - эмитит новое сообщение в поток', () async {
- final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json');
+ final messageFixture = FixtureReader.readAsMap(
+ 'chat/chat_message_response.json',
+ );
final message = ChatMessageModel.fromJson(messageFixture);
chatRepository.emitNewMessageToChats(message);
@@ -45,12 +50,14 @@ void main() {
const data = 'Test message';
const socketId = 'socket-id-123';
- when(mockChatService.sendMessage(
- slug: slug,
- data: data,
- mediaData: null,
- socketId: socketId,
- )).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.sendMessage(
+ slug: slug,
+ data: data,
+ mediaData: null,
+ socketId: socketId,
+ ),
+ ).thenAnswer((_) async => Future.value());
// Note: SocketIdStorage is static and can't be easily mocked
// This test verifies the repository calls the service correctly
@@ -68,12 +75,14 @@ void main() {
const data = 'Test message with files';
const socketId = 'socket-id-123';
- when(mockChatService.sendMessage(
- slug: slug,
- data: data,
- mediaData: anyNamed('mediaData'),
- socketId: socketId,
- )).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.sendMessage(
+ slug: slug,
+ data: data,
+ mediaData: anyNamed('mediaData'),
+ socketId: socketId,
+ ),
+ ).thenAnswer((_) async => Future.value());
// Создаем временные файлы для теста
final tempFile1 = File('test1.jpg');
@@ -113,11 +122,14 @@ void main() {
test('getChatRoom - получает комнату чата по UUID', () async {
const uuid = 'chat-1';
- final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
+ final roomFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_response.json',
+ );
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
- when(mockChatService.getChatRoom(uuid: uuid))
- .thenAnswer((_) async => expectedRoom);
+ when(
+ mockChatService.getChatRoom(uuid: uuid),
+ ).thenAnswer((_) async => expectedRoom);
final result = await chatRepository.getChatRoom(uuid);
@@ -132,18 +144,20 @@ void main() {
when(mockChatService.getChatRoom(uuid: uuid)).thenThrow(error);
- expect(
- () => chatRepository.getChatRoom(uuid),
- throwsException,
- );
+ expect(() => chatRepository.getChatRoom(uuid), throwsException);
});
test('getChatRooms - получает список комнат чата', () async {
- final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
- final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
+ final paginationFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_pagination_response.json',
+ );
+ final expectedPagination = ChatRoomPaginationModel.fromJson(
+ paginationFixture,
+ );
- when(mockChatService.getChatRooms(query: null, page: 1))
- .thenAnswer((_) async => expectedPagination);
+ when(
+ mockChatService.getChatRooms(query: null, page: 1),
+ ).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms();
@@ -154,11 +168,16 @@ void main() {
test('getChatRooms - получает список комнат с запросом', () async {
const query = 'test query';
- final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
- final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
+ final paginationFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_pagination_response.json',
+ );
+ final expectedPagination = ChatRoomPaginationModel.fromJson(
+ paginationFixture,
+ );
- when(mockChatService.getChatRooms(query: query, page: 1))
- .thenAnswer((_) async => expectedPagination);
+ when(
+ mockChatService.getChatRooms(query: query, page: 1),
+ ).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms(query: query);
@@ -181,8 +200,9 @@ void main() {
),
);
- when(mockChatService.getChatRooms(query: null, page: 2))
- .thenAnswer((_) async => expectedPagination);
+ when(
+ mockChatService.getChatRooms(query: null, page: 2),
+ ).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms(page: 2);
@@ -193,11 +213,14 @@ void main() {
test('getChatMessages - получает сообщения чата', () async {
const chatId = 'chat-1';
const page = 1;
- final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json');
+ final messagesFixture = FixtureReader.readAsMap(
+ 'chat/chat_messages_response.json',
+ );
final expectedMessages = ChatMessagesModel.fromJson(messagesFixture);
- when(mockChatService.getChatMessages(id: chatId, page: page))
- .thenAnswer((_) async => expectedMessages);
+ when(
+ mockChatService.getChatMessages(id: chatId, page: page),
+ ).thenAnswer((_) async => expectedMessages);
final result = await chatRepository.getChatMessages(chatId, page);
@@ -206,42 +229,53 @@ void main() {
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
});
- test('getChatRoomIdByUserSlug - получает комнату по slug пользователя', () async {
- const slug = 'user-1';
- final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
- final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
+ test(
+ 'getChatRoomIdByUserSlug - получает комнату по slug пользователя',
+ () async {
+ const slug = 'user-1';
+ final roomFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_response.json',
+ );
+ final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
- when(mockChatService.getChatRoomIdByUserSlug(user: slug))
- .thenAnswer((_) async => expectedRoom);
+ when(
+ mockChatService.getChatRoomIdByUserSlug(user: slug),
+ ).thenAnswer((_) async => expectedRoom);
- final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug);
+ final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug);
- expect(result, isA());
- verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
- });
+ expect(result, isA());
+ verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
+ },
+ );
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id-123';
final messageIds = [1, 2, 3];
- when(mockChatService.markMessagesAsRead(
- id: chatId,
- body: {'messages_ids': messageIds},
- )).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.markMessagesAsRead(
+ id: chatId,
+ body: {'messages_ids': messageIds},
+ ),
+ ).thenAnswer((_) async => Future.value());
await chatRepository.markMessagesAsRead(chatId, messageIds);
- verify(mockChatService.markMessagesAsRead(
- id: chatId,
- body: {'messages_ids': messageIds},
- )).called(1);
+ verify(
+ mockChatService.markMessagesAsRead(
+ id: chatId,
+ body: {'messages_ids': messageIds},
+ ),
+ ).called(1);
});
test('cancelChosenPlan - отменяет выбранный план', () async {
const planId = 123;
- when(mockChatService.cancelChosenPlan(id: planId))
- .thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.cancelChosenPlan(id: planId),
+ ).thenAnswer((_) async => Future.value());
await chatRepository.cancelChosenPlan(id: planId);
@@ -252,36 +286,114 @@ void main() {
const planId = 123;
final date = {'start_date': '2024-01-15'};
- when(mockChatService.choseStartPlanDate(id: planId, date: date))
- .thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.choseStartPlanDate(id: planId, date: date),
+ ).thenAnswer((_) async => Future.value());
await chatRepository.choseStartPlanDate(id: planId, date: date);
- verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1);
+ verify(
+ mockChatService.choseStartPlanDate(id: planId, date: date),
+ ).called(1);
});
test('getPaymentLink - получает ссылку на оплату', () async {
const planId = 123;
- final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json');
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response.json',
+ );
final expectedPayment = PaymentModel.fromJson(paymentFixture);
- when(mockChatService.getPaymentLink(id: planId))
- .thenAnswer((_) async => expectedPayment);
+ when(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'mock'},
+ ),
+ ).thenAnswer((_) async => expectedPayment);
- final result = await chatRepository.getPaymentLink(id: planId);
+ final result = await chatRepository.getPaymentLink(
+ id: planId,
+ paymentProvider: 'mock',
+ );
expect(result, isA());
expect(result.paymentUrl, 'https://example.com/checkout/payment-123');
- verify(mockChatService.getPaymentLink(id: planId)).called(1);
+ verify(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'mock'},
+ ),
+ ).called(1);
+ });
+
+ test(
+ 'getPaymentLink - передаёт payment_provider в тело запроса alfa-bank',
+ () async {
+ const planId = 123;
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response_alfa_bank.json',
+ );
+ final expectedPayment = PaymentModel.fromJson(paymentFixture);
+
+ when(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).thenAnswer((_) async => expectedPayment);
+
+ final result = await chatRepository.getPaymentLink(
+ id: planId,
+ paymentProvider: 'alfa-bank',
+ );
+
+ expect(result, isA());
+ expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
+ expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234');
+ expect(result.paymentProvider, 'alfa-bank');
+ verify(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).called(1);
+ },
+ );
+
+ test('resolveOrderIdToConversation - получает модель по orderId', () async {
+ const orderId = 'alfa_c29_k15138_t1712345678_r1234';
+ final fixture = FixtureReader.readAsMap(
+ 'chat/payments/order_id_to_chat_uuid_response.json',
+ );
+ final expected = OrderIdToConversationModel.fromJson(fixture);
+
+ when(
+ mockChatService.resolveOrderIdToConversation(orderId: orderId),
+ ).thenAnswer((_) async => expected);
+
+ final result = await chatRepository.resolveOrderIdToConversation(
+ orderId: orderId,
+ );
+
+ expect(result, isA());
+ expect(result.chosenPlanId, 12);
+ expect(result.conversationUuid, "fbd81429-8019-499a-80e8-e3933cc4d872");
+
+ verify(
+ mockChatService.resolveOrderIdToConversation(orderId: orderId),
+ ).called(1);
});
test('getChosenPlan - получает выбранный план', () async {
const planId = 1;
- final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json');
+ final chosenPlanFixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_in_progress_response.json',
+ );
final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture);
- when(mockChatService.getChosenPlan(id: planId))
- .thenAnswer((_) async => expectedPlan);
+ when(
+ mockChatService.getChosenPlan(id: planId),
+ ).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId);
@@ -290,66 +402,140 @@ void main() {
verify(mockChatService.getChosenPlan(id: planId)).called(1);
});
+ group('getChosenPlan payment polling statuses', () {
+ test('getChosenPlan - возвращает план в статусе pending_payment', () async {
+ const planId = 1;
+ final fixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_pending_payment_response.json',
+ );
+ final expectedPlan = ChosenPlanModel.fromJson(fixture);
+
+ when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
+
+ final result = await chatRepository.getChosenPlan(id: planId);
+
+ expect(result.chosenPlanState, ChosenPlanStateEnum.statePendingPayment);
+ verify(mockChatService.getChosenPlan(id: planId)).called(1);
+ });
+
+ test('getChosenPlan - возвращает план в статусе paid', () async {
+ const planId = 1;
+ final fixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_paid_response.json',
+ );
+ final expectedPlan = ChosenPlanModel.fromJson(fixture);
+
+ when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
+
+ final result = await chatRepository.getChosenPlan(id: planId);
+
+ expect(result.chosenPlanState, ChosenPlanStateEnum.statePaid);
+ verify(mockChatService.getChosenPlan(id: planId)).called(1);
+ });
+
+ test('getChosenPlan - возвращает план в статусе payment_failed', () async {
+ const planId = 1;
+ final fixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_payment_failed_response.json',
+ );
+ final expectedPlan = ChosenPlanModel.fromJson(fixture);
+
+ when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
+
+ final result = await chatRepository.getChosenPlan(id: planId);
+
+ expect(result.chosenPlanState, ChosenPlanStateEnum.statePaymentFailed);
+ verify(mockChatService.getChosenPlan(id: planId)).called(1);
+ });
+
+ test('getChosenPlan - возвращает план в статусе canceled_by_the_initiator', () async {
+ const planId = 1;
+ final fixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json',
+ );
+ final expectedPlan = ChosenPlanModel.fromJson(fixture);
+
+ when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
+
+ final result = await chatRepository.getChosenPlan(id: planId);
+
+ expect(result.chosenPlanState, ChosenPlanStateEnum.stateCanceledByTheInitiator);
+ verify(mockChatService.getChosenPlan(id: planId)).called(1);
+ });
+ });
+
test('finishChosenPlan - завершает выбранный план', () async {
const planId = 123;
- when(mockChatService.finishChosenPlan(id: planId))
- .thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.finishChosenPlan(id: planId),
+ ).thenAnswer((_) async => Future.value());
await chatRepository.finishChosenPlan(id: planId);
verify(mockChatService.finishChosenPlan(id: planId)).called(1);
});
- test('complainAboutConsultation - отправляет жалобу на консультацию', () async {
- const planId = 123;
- final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json');
- final complainModel = PlanComplainModel.fromJson(complainFixture);
+ test(
+ 'complainAboutConsultation - отправляет жалобу на консультацию',
+ () async {
+ const planId = 123;
+ final complainFixture = FixtureReader.readAsMap(
+ 'chat/plan_complain_response.json',
+ );
+ final complainModel = PlanComplainModel.fromJson(complainFixture);
- when(mockChatService.complainAboutConsultation(id: planId, body: complainModel))
- .thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ ),
+ ).thenAnswer((_) async => Future.value());
- await chatRepository.complainAboutConsultation(
- id: planId,
- body: complainModel,
- );
+ await chatRepository.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ );
- verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel))
- .called(1);
- });
+ verify(
+ mockChatService.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ ),
+ ).called(1);
+ },
+ );
- test('getChatByUserSlug - возвращает ID чата при успешном ответе', () async {
- const slug = 'user-slug';
- const chatId = 'chat-id-123';
- final response = HttpResponse(
- {'id': chatId},
- Response(
- requestOptions: RequestOptions(path: ''),
- statusCode: 200,
- ),
- );
+ test(
+ 'getChatByUserSlug - возвращает ID чата при успешном ответе',
+ () async {
+ const slug = 'user-slug';
+ const chatId = 'chat-id-123';
+ final response = HttpResponse({
+ 'id': chatId,
+ }, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
- when(mockChatService.getChatByUserSlug(slug: slug))
- .thenAnswer((_) async => response);
+ when(
+ mockChatService.getChatByUserSlug(slug: slug),
+ ).thenAnswer((_) async => response);
- final result = await chatRepository.getChatByUserSlug(slug);
+ final result = await chatRepository.getChatByUserSlug(slug);
- expect(result, chatId);
- verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
- });
+ expect(result, chatId);
+ verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
+ },
+ );
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
const slug = 'user-slug';
final response = HttpResponse(
{},
- Response(
- requestOptions: RequestOptions(path: ''),
- statusCode: 404,
- ),
+ Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
);
- when(mockChatService.getChatByUserSlug(slug: slug))
- .thenAnswer((_) async => response);
+ when(
+ mockChatService.getChatByUserSlug(slug: slug),
+ ).thenAnswer((_) async => response);
final result = await chatRepository.getChatByUserSlug(slug);
@@ -357,17 +543,20 @@ void main() {
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
});
- test('getChatByUserSlug - пробрасывает исключение при ошибке сети', () async {
- const slug = 'user-slug';
+ test(
+ 'getChatByUserSlug - пробрасывает исключение при ошибке сети',
+ () async {
+ const slug = 'user-slug';
- when(mockChatService.getChatByUserSlug(slug: slug))
- .thenThrow(Exception('Network error'));
+ when(
+ mockChatService.getChatByUserSlug(slug: slug),
+ ).thenThrow(Exception('Network error'));
- await expectLater(
- chatRepository.getChatByUserSlug(slug),
- throwsA(isA()),
- );
- });
+ await expectLater(
+ chatRepository.getChatByUserSlug(slug),
+ throwsA(isA()),
+ );
+ },
+ );
});
}
-
diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart
index 3e4a924c..d544e6a2 100644
--- a/test/features/chat/services/chat_service_test.dart
+++ b/test/features/chat/services/chat_service_test.dart
@@ -1,8 +1,10 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
-import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room;
+import 'package:eatvio_app/features/chat/models/chat_room_model.dart'
+ as chat_room;
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
+import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart';
@@ -24,10 +26,16 @@ void main() {
});
test('getChatRooms - возвращает список комнат чата', () async {
- final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
- final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
+ final paginationFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_pagination_response.json',
+ );
+ final expectedPagination = ChatRoomPaginationModel.fromJson(
+ paginationFixture,
+ );
- when(mockChatService.getChatRooms(query: null, page: 1)).thenAnswer((_) async => expectedPagination);
+ when(
+ mockChatService.getChatRooms(query: null, page: 1),
+ ).thenAnswer((_) async => expectedPagination);
final result = await mockChatService.getChatRooms(query: null, page: 1);
@@ -38,10 +46,16 @@ void main() {
test('getChatRooms - возвращает список комнат с запросом', () async {
const query = 'test query';
- final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
- final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
+ final paginationFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_pagination_response.json',
+ );
+ final expectedPagination = ChatRoomPaginationModel.fromJson(
+ paginationFixture,
+ );
- when(mockChatService.getChatRooms(query: query, page: 1)).thenAnswer((_) async => expectedPagination);
+ when(
+ mockChatService.getChatRooms(query: query, page: 1),
+ ).thenAnswer((_) async => expectedPagination);
final result = await mockChatService.getChatRooms(query: query, page: 1);
@@ -51,10 +65,14 @@ void main() {
test('getChatRoom - возвращает комнату чата по UUID', () async {
const uuid = 'chat-1';
- final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
+ final roomFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_response.json',
+ );
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
- when(mockChatService.getChatRoom(uuid: uuid)).thenAnswer((_) async => expectedRoom);
+ when(
+ mockChatService.getChatRoom(uuid: uuid),
+ ).thenAnswer((_) async => expectedRoom);
final result = await mockChatService.getChatRoom(uuid: uuid);
@@ -67,62 +85,114 @@ void main() {
const slug = 'user-slug';
const data = 'Test message';
const socketId = 'socket-id-123';
- final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json');
+ final messageFixture = FixtureReader.readAsMap(
+ 'chat/chat_message_response.json',
+ );
final expectedMessage = ChatMessageModel.fromJson(messageFixture);
// ignore: null_argument_to_non_null_type
- when(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).thenAnswer((_) async => expectedMessage);
+ when(
+ mockChatService.sendMessage(
+ slug: slug,
+ data: data,
+ mediaData: null,
+ socketId: socketId,
+ ),
+ ).thenAnswer((_) async => expectedMessage);
- final result = await mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId);
+ final result = await mockChatService.sendMessage(
+ slug: slug,
+ data: data,
+ mediaData: null,
+ socketId: socketId,
+ );
expect(result, isA());
expect(result.data, data);
- verify(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).called(1);
+ verify(
+ mockChatService.sendMessage(
+ slug: slug,
+ data: data,
+ mediaData: null,
+ socketId: socketId,
+ ),
+ ).called(1);
});
test('getChatMessages - возвращает сообщения чата', () async {
const chatId = 'chat-1';
const page = 1;
- final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json');
+ final messagesFixture = FixtureReader.readAsMap(
+ 'chat/chat_messages_response.json',
+ );
final expectedMessages = ChatMessagesModel.fromJson(messagesFixture);
- when(mockChatService.getChatMessages(id: chatId, page: page)).thenAnswer((_) async => expectedMessages);
+ when(
+ mockChatService.getChatMessages(id: chatId, page: page),
+ ).thenAnswer((_) async => expectedMessages);
- final result = await mockChatService.getChatMessages(id: chatId, page: page);
+ final result = await mockChatService.getChatMessages(
+ id: chatId,
+ page: page,
+ );
expect(result, isA());
expect(result.messagesList.length, 2);
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
});
- test('getChatRoomIdByUserSlug - возвращает комнату по slug пользователя', () async {
- const slug = 'user-1';
- final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
- final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
+ test(
+ 'getChatRoomIdByUserSlug - возвращает комнату по slug пользователя',
+ () async {
+ const slug = 'user-1';
+ final roomFixture = FixtureReader.readAsMap(
+ 'chat/chat_room_response.json',
+ );
+ final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
- when(mockChatService.getChatRoomIdByUserSlug(user: slug)).thenAnswer((_) async => expectedRoom);
+ when(
+ mockChatService.getChatRoomIdByUserSlug(user: slug),
+ ).thenAnswer((_) async => expectedRoom);
- final result = await mockChatService.getChatRoomIdByUserSlug(user: slug);
+ final result = await mockChatService.getChatRoomIdByUserSlug(
+ user: slug,
+ );
- expect(result, isA());
- verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
- });
+ expect(result, isA());
+ verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
+ },
+ );
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id-123';
final messageIds = [1, 2, 3];
- when(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.markMessagesAsRead(
+ id: chatId,
+ body: {'messages_ids': messageIds},
+ ),
+ ).thenAnswer((_) async => Future.value());
- await mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds});
+ await mockChatService.markMessagesAsRead(
+ id: chatId,
+ body: {'messages_ids': messageIds},
+ );
- verify(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).called(1);
+ verify(
+ mockChatService.markMessagesAsRead(
+ id: chatId,
+ body: {'messages_ids': messageIds},
+ ),
+ ).called(1);
});
test('cancelChosenPlan - отменяет выбранный план', () async {
const planId = 123;
- when(mockChatService.cancelChosenPlan(id: planId)).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.cancelChosenPlan(id: planId),
+ ).thenAnswer((_) async => Future.value());
await mockChatService.cancelChosenPlan(id: planId);
@@ -133,33 +203,80 @@ void main() {
const planId = 123;
final date = {'start_date': '2024-01-15'};
- when(mockChatService.choseStartPlanDate(id: planId, date: date)).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.choseStartPlanDate(id: planId, date: date),
+ ).thenAnswer((_) async => Future.value());
await mockChatService.choseStartPlanDate(id: planId, date: date);
- verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1);
+ verify(
+ mockChatService.choseStartPlanDate(id: planId, date: date),
+ ).called(1);
});
test('getPaymentLink - возвращает ссылку на оплату', () async {
const planId = 123;
- final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json');
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response.json',
+ );
final expectedPayment = PaymentModel.fromJson(paymentFixture);
- when(mockChatService.getPaymentLink(id: planId)).thenAnswer((_) async => expectedPayment);
+ when(
+ mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'}),
+ ).thenAnswer((_) async => expectedPayment);
- final result = await mockChatService.getPaymentLink(id: planId);
+ final result = await mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'mock'},
+ );
expect(result, isA());
expect(result.paymentUrl, 'https://example.com/checkout/payment-123');
- verify(mockChatService.getPaymentLink(id: planId)).called(1);
+ verify(mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'})).called(1);
});
+ test(
+ 'getPaymentLink - возвращает ссылку на оплату alfa-bank',
+ () async {
+ const planId = 123;
+ final requestFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_alfabank_request.json',
+ );
+ final requestBody = Map.from(requestFixture);
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response_alfa_bank.json',
+ );
+ final expectedPayment = PaymentModel.fromJson(paymentFixture);
+
+ when(
+ mockChatService.getPaymentLink(id: planId, body: requestBody),
+ ).thenAnswer((_) async => expectedPayment);
+
+ final result = await mockChatService.getPaymentLink(
+ id: planId,
+ body: requestBody,
+ );
+
+ expect(result, isA());
+ expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
+ expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234');
+ expect(result.paymentProvider, 'alfa-bank');
+ verify(
+ mockChatService.getPaymentLink(id: planId, body: requestBody),
+ ).called(1);
+ },
+ );
+
test('getChosenPlan - возвращает выбранный план', () async {
const planId = 1;
- final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json');
+ final chosenPlanFixture = FixtureReader.readAsMap(
+ 'chat/chosen_plan/chosen_plan_in_progress_response.json',
+ );
final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture);
- when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
+ when(
+ mockChatService.getChosenPlan(id: planId),
+ ).thenAnswer((_) async => expectedPlan);
final result = await mockChatService.getChosenPlan(id: planId);
@@ -171,31 +288,80 @@ void main() {
test('finishChosenPlan - завершает выбранный план', () async {
const planId = 123;
- when(mockChatService.finishChosenPlan(id: planId)).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.finishChosenPlan(id: planId),
+ ).thenAnswer((_) async => Future.value());
await mockChatService.finishChosenPlan(id: planId);
verify(mockChatService.finishChosenPlan(id: planId)).called(1);
});
- test('complainAboutConsultation - отправляет жалобу на консультацию', () async {
- const planId = 123;
- final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json');
- final complainModel = PlanComplainModel.fromJson(complainFixture);
+ test(
+ 'complainAboutConsultation - отправляет жалобу на консультацию',
+ () async {
+ const planId = 123;
+ final complainFixture = FixtureReader.readAsMap(
+ 'chat/plan_complain_response.json',
+ );
+ final complainModel = PlanComplainModel.fromJson(complainFixture);
- when(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).thenAnswer((_) async => Future.value());
+ when(
+ mockChatService.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ ),
+ ).thenAnswer((_) async => Future.value());
- await mockChatService.complainAboutConsultation(id: planId, body: complainModel);
+ await mockChatService.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ );
- verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).called(1);
- });
+ verify(
+ mockChatService.complainAboutConsultation(
+ id: planId,
+ body: complainModel,
+ ),
+ ).called(1);
+ },
+ );
+
+ test(
+ 'resolveOrderIdToConversation - возвращает модель по orderId',
+ () async {
+ const orderId = 'alfa_c29_k15138_t1712345678_r1234';
+ final fixture = FixtureReader.readAsMap(
+ 'chat/payments/order_id_to_chat_uuid_response.json',
+ );
+ final expected = OrderIdToConversationModel.fromJson(fixture);
+
+ when(
+ mockChatService.resolveOrderIdToConversation(orderId: orderId),
+ ).thenAnswer((_) async => expected);
+
+ final result = await mockChatService.resolveOrderIdToConversation(
+ orderId: orderId,
+ );
+
+ expect(result, isA());
+ expect(result.chosenPlanId, 12);
+ verify(
+ mockChatService.resolveOrderIdToConversation(orderId: orderId),
+ ).called(1);
+ },
+ );
test('getChatByUserSlug - возвращает HttpResponse', () async {
const slug = 'user-slug';
const chatId = 'chat-id-123';
- final response = HttpResponse({'id': chatId}, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
+ final response = HttpResponse({
+ 'id': chatId,
+ }, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
- when(mockChatService.getChatByUserSlug(slug: slug)).thenAnswer((_) async => response);
+ when(
+ mockChatService.getChatByUserSlug(slug: slug),
+ ).thenAnswer((_) async => response);
final result = await mockChatService.getChatByUserSlug(slug: slug);
diff --git a/test/features/order_info/cubit/order_info_cubit_test.dart b/test/features/order_info/cubit/order_info_cubit_test.dart
index de00ad94..f62ff961 100644
--- a/test/features/order_info/cubit/order_info_cubit_test.dart
+++ b/test/features/order_info/cubit/order_info_cubit_test.dart
@@ -200,20 +200,20 @@ void main() {
.thenAnswer((_) async => chosenPlan);
await cubit.getChosenPlan(id: 1);
- when(mockChatRepository.getPaymentLink(id: 1))
+ when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank'))
.thenAnswer((_) async => paymentModel);
final result = await cubit.getPaymentLink();
expect(result, equals('https://payment.url'));
- verify(mockChatRepository.getPaymentLink(id: 1)).called(1);
+ verify(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank')).called(1);
});
test('getPaymentLink - возвращает null, если план не выбран', () async {
final result = await cubit.getPaymentLink();
expect(result, isNull);
- verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id')));
+ verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id'), paymentProvider: anyNamed('paymentProvider')));
});
test('getPaymentLink - обработка ошибки', () async {
@@ -254,7 +254,7 @@ void main() {
.thenAnswer((_) async => chosenPlan);
await cubit.getChosenPlan(id: 1);
- when(mockChatRepository.getPaymentLink(id: 1))
+ when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank'))
.thenThrow(Exception('Ошибка'));
final result = await cubit.getPaymentLink();
diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart
new file mode 100644
index 00000000..f0544e6d
--- /dev/null
+++ b/test/features/payment/consultation_payment_flow_test.dart
@@ -0,0 +1,131 @@
+import 'package:eatvio_app/generated/l10n.dart';
+import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
+import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ Widget createTestApp() {
+ return const MaterialApp(
+ localizationsDelegates: [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: [Locale('ru')],
+ home: Scaffold(body: SizedBox()),
+ );
+ }
+
+ group('showConsultationPaymentFlow', () {
+ late bool gateCalled;
+ late bool? gateResult;
+ late bool formCalled;
+ late PromoCodeResult? formResult;
+ late bool sbpCalled;
+ late int sbpPlanId;
+
+ Future mockGate(BuildContext context) async {
+ gateCalled = true;
+ return gateResult;
+ }
+
+ Future mockForm(
+ BuildContext context, {
+ Future Function(String)? onValidate,
+ }) async {
+ formCalled = true;
+ return formResult;
+ }
+
+ Future mockSbp(BuildContext context, {required int planId}) async {
+ sbpCalled = true;
+ sbpPlanId = planId;
+ }
+
+ setUp(() {
+ gateCalled = false;
+ gateResult = null;
+ formCalled = false;
+ formResult = null;
+ sbpCalled = false;
+ sbpPlanId = 0;
+ });
+
+ testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async {
+ gateResult = false;
+ await tester.pumpWidget(createTestApp());
+
+ await showConsultationPaymentFlow(
+ tester.element(find.byType(SizedBox)),
+ planId: 42,
+ showGate: mockGate,
+ showForm: mockForm,
+ showSbp: mockSbp,
+ );
+
+ expect(gateCalled, isTrue);
+ expect(formCalled, isFalse);
+ expect(sbpCalled, isTrue);
+ expect(sbpPlanId, 42);
+ });
+
+ testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
+ gateResult = true;
+ formResult = const PromoCodeResult();
+ await tester.pumpWidget(createTestApp());
+
+ await showConsultationPaymentFlow(
+ tester.element(find.byType(SizedBox)),
+ planId: 42,
+ showGate: mockGate,
+ showForm: mockForm,
+ showSbp: mockSbp,
+ );
+
+ expect(gateCalled, isTrue);
+ expect(formCalled, isTrue);
+ expect(sbpCalled, isTrue);
+ expect(sbpPlanId, 42);
+ });
+
+ testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
+ gateResult = true;
+ formResult = null;
+ await tester.pumpWidget(createTestApp());
+
+ await showConsultationPaymentFlow(
+ tester.element(find.byType(SizedBox)),
+ planId: 42,
+ showGate: mockGate,
+ showForm: mockForm,
+ showSbp: mockSbp,
+ );
+
+ expect(gateCalled, isTrue);
+ expect(formCalled, isTrue);
+ expect(sbpCalled, isFalse);
+ });
+
+ testWidgets('гейт отменён (null) → ничего не вызывается', (tester) async {
+ gateResult = null;
+ await tester.pumpWidget(createTestApp());
+
+ await showConsultationPaymentFlow(
+ tester.element(find.byType(SizedBox)),
+ planId: 42,
+ showGate: mockGate,
+ showForm: mockForm,
+ showSbp: mockSbp,
+ );
+
+ expect(gateCalled, isTrue);
+ expect(formCalled, isFalse);
+ expect(sbpCalled, isFalse);
+ });
+ });
+}
diff --git a/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart
new file mode 100644
index 00000000..68586ec9
--- /dev/null
+++ b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart
@@ -0,0 +1,188 @@
+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/cubit/consultation_payment_cubit/consultation_payment_state.dart';
+import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
+import 'package:eatvio_app/features/payment/repositories/sbp_repository.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 'consultation_payment_cubit_test.mocks.dart';
+
+@GenerateNiceMocks([
+ MockSpec(),
+ MockSpec(),
+])
+void main() {
+ group('ConsultationPaymentCubit', () {
+ late ConsultationPaymentCubit cubit;
+ late MockPaymentRepository mockPaymentRepository;
+ late MockSbpRepository mockSbpRepository;
+
+ final installedBank = C2bmemberModel.fromJson(const {
+ 'bankName': 'Сбербанк',
+ 'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
+ 'schema': 'bank100000000111',
+ 'package_name': 'ru.sberbankmobile',
+ 'icon': 'packages/sbp/assets/icons/sber.png',
+ });
+
+ late PaymentModel alfaPayment;
+
+ 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();
+ });
+
+ test('начальный state - isLoading=false, banks=[], checkoutUrl=null', () {
+ expect(cubit.state, isA());
+ expect(cubit.state.isLoading, isFalse);
+ expect(cubit.state.banks, isEmpty);
+ expect(cubit.state.checkoutUrl, isNull);
+ expect(cubit.state.orderId, isNull);
+ expect(cubit.state.errorMessage, isNull);
+ });
+
+ test(
+ 'startPayment - успешно получает checkout_url и загружает банки',
+ () async {
+ const planId = 123;
+
+ when(
+ mockPaymentRepository.startPaymentProcess(planId: planId),
+ ).thenAnswer((_) async => alfaPayment);
+ when(
+ mockSbpRepository.getInstalledBanks(),
+ ).thenAnswer((_) async => [installedBank]);
+
+ await cubit.startPayment(planId: planId);
+
+ expect(cubit.state.isLoading, isFalse);
+ expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl);
+ expect(cubit.state.orderId, alfaPayment.orderId);
+ expect(cubit.state.banks, [installedBank]);
+ expect(cubit.state.errorMessage, isNull);
+ verify(
+ mockPaymentRepository.startPaymentProcess(planId: planId),
+ ).called(1);
+ verify(mockSbpRepository.getInstalledBanks()).called(1);
+ },
+ );
+
+ test('startPayment - при ошибке API emit errorMessage', () async {
+ const planId = 123;
+
+ when(
+ mockPaymentRepository.startPaymentProcess(planId: planId),
+ ).thenThrow(Exception('Payment start failed'));
+
+ await cubit.startPayment(planId: planId);
+
+ expect(cubit.state.isLoading, isFalse);
+ expect(cubit.state.errorMessage, isNotNull);
+ expect(cubit.state.banks, isEmpty);
+ expect(cubit.state.checkoutUrl, isNull);
+ verifyNever(mockSbpRepository.getInstalledBanks());
+ });
+
+ test(
+ 'startPayment - при ошибке SBP после успешного payment emit error',
+ () async {
+ const planId = 123;
+
+ when(
+ mockPaymentRepository.startPaymentProcess(planId: planId),
+ ).thenAnswer((_) async => alfaPayment);
+ when(
+ mockSbpRepository.getInstalledBanks(),
+ ).thenThrow(Exception('SBP plugin error'));
+
+ await cubit.startPayment(planId: planId);
+
+ expect(cubit.state.isLoading, isFalse);
+ expect(cubit.state.errorMessage, isNotNull);
+ expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl);
+ expect(cubit.state.orderId, alfaPayment.orderId);
+ expect(cubit.state.banks, isEmpty);
+ },
+ );
+
+ test('openBank - успешно открывает банк после startPayment', () async {
+ const planId = 123;
+
+ 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 cubit.startPayment(planId: planId);
+ final result = await cubit.openBank(bank: installedBank);
+
+ expect(result, isTrue);
+ verify(
+ mockSbpRepository.openBank(
+ url: alfaPayment.paymentUrl,
+ bank: installedBank,
+ ),
+ ).called(1);
+ });
+
+ test(
+ 'openBank - без checkoutUrl возвращает false и не вызывает SbpRepository',
+ () async {
+ final result = await cubit.openBank(bank: installedBank);
+
+ expect(result, isFalse);
+ verifyNever(
+ mockSbpRepository.openBank(
+ url: anyNamed('url'),
+ bank: anyNamed('bank'),
+ ),
+ );
+ },
+ );
+
+ test('openBank - возвращает false когда банк не открылся', () async {
+ const planId = 123;
+
+ when(
+ mockPaymentRepository.startPaymentProcess(planId: planId),
+ ).thenAnswer((_) async => alfaPayment);
+ when(
+ mockSbpRepository.getInstalledBanks(),
+ ).thenAnswer((_) async => [installedBank]);
+ when(
+ mockSbpRepository.openBank(
+ url: alfaPayment.paymentUrl,
+ bank: installedBank,
+ ),
+ ).thenAnswer((_) async => false);
+
+ await cubit.startPayment(planId: planId);
+ final result = await cubit.openBank(bank: installedBank);
+
+ expect(result, isFalse);
+ });
+ });
+}
diff --git a/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart
new file mode 100644
index 00000000..48cf3859
--- /dev/null
+++ b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart
@@ -0,0 +1,65 @@
+import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
+import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.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';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('PromoCodeGateBottomSheet', () {
+ Widget createTestWidget({required ValueChanged onResult}) {
+ return MaterialApp(
+ theme: ThemeHelper().themeData(),
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: const [Locale('ru')],
+ home: Scaffold(
+ body: PromoCodeGateBottomSheet(onResult: onResult),
+ ),
+ );
+ }
+
+ testWidgets('показывает заголовок, BottomSheetHandlerBar и кнопки Да/Нет', (tester) async {
+ await tester.pumpWidget(createTestWidget(onResult: (_) {}));
+ await tester.pump();
+
+ expect(find.byType(BottomSheetHandlerBar), findsOneWidget);
+ expect(find.text('У вас есть промокод?'), findsOneWidget);
+ expect(find.text('Да'), findsOneWidget);
+ expect(find.text('Нет'), findsOneWidget);
+ });
+
+ testWidgets('tap Да вызывает onResult(true)', (tester) async {
+ bool? result;
+ await tester.pumpWidget(
+ createTestWidget(onResult: (v) => result = v),
+ );
+ await tester.pump();
+
+ await tester.tap(find.text('Да'));
+ await tester.pump();
+
+ expect(result, isTrue);
+ });
+
+ testWidgets('tap Нет вызывает onResult(false)', (tester) async {
+ bool? result;
+ await tester.pumpWidget(
+ createTestWidget(onResult: (v) => result = v),
+ );
+ await tester.pump();
+
+ await tester.tap(find.text('Нет'));
+ await tester.pump();
+
+ expect(result, isFalse);
+ });
+ });
+}
diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart
new file mode 100644
index 00000000..e1b2e923
--- /dev/null
+++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart
@@ -0,0 +1,101 @@
+import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.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';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('ItvioPromoCodeModal', () {
+ Widget createTestWidget({
+ Future Function(String)? onValidate,
+ }) {
+ return MaterialApp(
+ theme: ThemeHelper().themeData(),
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: const [Locale('ru')],
+ home: Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => ItvioPromoCodeModal.show(
+ context,
+ onValidate: onValidate,
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ );
+ }
+
+ testWidgets('показывает заголовок Есть промокод? и кнопки Применить/Пропустить', (tester) async {
+ await tester.pumpWidget(createTestWidget());
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Есть промокод?'), findsOneWidget);
+ expect(find.text('Применить'), findsOneWidget);
+ expect(find.text('Пропустить'), findsOneWidget);
+ });
+
+ testWidgets('Применить с пустым полем показывает ошибку и не закрывает', (tester) async {
+ await tester.pumpWidget(createTestWidget());
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Применить'));
+ await tester.pump();
+
+ expect(find.text('Введите промокод'), findsAtLeast(1));
+ expect(find.text('Применить'), findsOneWidget);
+ });
+
+ testWidgets('Применить с вводом вызывает onValidate и показывает ошибку', (tester) async {
+ PromoValidation? captured;
+ await tester.pumpWidget(
+ createTestWidget(
+ onValidate: (code) async {
+ captured ??= const PromoValidation.failure('Промокод не найден или истёк');
+ return captured!;
+ },
+ ),
+ );
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ await tester.enterText(find.byType(TextField), 'TEST123');
+ await tester.tap(find.text('Применить'));
+ await tester.pump();
+
+ expect(captured, isNotNull);
+ expect(find.text('Промокод не найден или истёк'), findsOneWidget);
+ expect(find.text('Применить'), findsOneWidget);
+ });
+
+ testWidgets('Пропустить закрывает шторку', (tester) async {
+ await tester.pumpWidget(createTestWidget());
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Пропустить'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Есть промокод?'), findsNothing);
+ });
+
+ testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async {
+ await tester.pumpWidget(createTestWidget());
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Промокод применён!'), findsNothing);
+ expect(find.text('Продолжить'), findsNothing);
+ expect(find.text('Убрать промокод'), findsNothing);
+ });
+ });
+}
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
new file mode 100644
index 00000000..773a1d74
--- /dev/null
+++ b/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart
@@ -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(),
+ MockSpec(),
+])
+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();
+
+ 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);
+ });
+ });
+}
diff --git a/test/features/payment/repositories/payment_repository_test.dart b/test/features/payment/repositories/payment_repository_test.dart
new file mode 100644
index 00000000..28d3d2f6
--- /dev/null
+++ b/test/features/payment/repositories/payment_repository_test.dart
@@ -0,0 +1,97 @@
+import 'package:eatvio_app/features/chat/models/payment_model.dart';
+import 'package:eatvio_app/features/chat/services/chat_service.dart';
+import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+
+import '../../../fixtures/fixture_reader.dart';
+import 'payment_repository_test.mocks.dart';
+
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ group('PaymentRepository', () {
+ late PaymentRepository paymentRepository;
+ late MockChatService mockChatService;
+
+ setUp(() {
+ mockChatService = MockChatService();
+ paymentRepository = PaymentRepository(chatService: mockChatService);
+ });
+
+ test(
+ 'startPaymentProcess - возвращает PaymentModel из ответа alfa-bank',
+ () async {
+ const planId = 123;
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response_alfa_bank.json',
+ );
+ final expectedPayment = PaymentModel.fromJson(paymentFixture);
+ final requestBody = Map.from(
+ FixtureReader.readAsMap('chat/payments/payment_alfabank_request.json'),
+ );
+
+ when(
+ mockChatService.getPaymentLink(id: planId, body: requestBody),
+ ).thenAnswer((_) async => expectedPayment);
+
+ final result = await paymentRepository.startPaymentProcess(planId: planId);
+
+ expect(result, isA());
+ expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
+ expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234');
+ expect(result.paymentProvider, 'alfa-bank');
+ verify(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).called(1);
+ },
+ );
+
+ test(
+ 'startPaymentProcess - передаёт payment_provider alfa-bank в тело запроса',
+ () async {
+ const planId = 42;
+ final paymentFixture = FixtureReader.readAsMap(
+ 'chat/payments/payment_response_alfa_bank.json',
+ );
+ final expectedPayment = PaymentModel.fromJson(paymentFixture);
+
+ when(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).thenAnswer((_) async => expectedPayment);
+
+ await paymentRepository.startPaymentProcess(planId: planId);
+
+ verify(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).called(1);
+ },
+ );
+
+ test('startPaymentProcess - пробрасывает ошибку сервиса', () async {
+ const planId = 123;
+ final error = Exception('Payment start failed');
+
+ when(
+ mockChatService.getPaymentLink(
+ id: planId,
+ body: {'payment_provider': 'alfa-bank'},
+ ),
+ ).thenThrow(error);
+
+ expect(
+ () => paymentRepository.startPaymentProcess(planId: planId),
+ throwsA(isA()),
+ );
+ });
+ });
+}
diff --git a/test/features/payment/repositories/sbp_repository_test.dart b/test/features/payment/repositories/sbp_repository_test.dart
new file mode 100644
index 00000000..87e3b8e6
--- /dev/null
+++ b/test/features/payment/repositories/sbp_repository_test.dart
@@ -0,0 +1,118 @@
+import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
+import 'package:eatvio_app/features/payment/services/sbp_service.dart';
+import 'package:flutter/services.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 'sbp_repository_test.mocks.dart';
+
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ group('SbpRepository', () {
+ late SbpRepository sbpRepository;
+ late MockSbpService mockSbpService;
+
+ final installedBank = C2bmemberModel.fromJson(const {
+ 'bankName': 'Сбербанк',
+ 'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
+ 'schema': 'bank100000000111',
+ 'package_name': 'ru.sberbankmobile',
+ 'icon': 'packages/sbp/assets/icons/sber.png',
+ });
+
+ setUp(() {
+ mockSbpService = MockSbpService();
+ sbpRepository = SbpRepository(sbpService: mockSbpService);
+ });
+
+ group('getInstalledBanks', () {
+ test(
+ 'делегирует сервису и возвращает список установленных банков',
+ () async {
+ when(
+ mockSbpService.getInstalledBanks(),
+ ).thenAnswer((_) async => [installedBank]);
+
+ final result = await sbpRepository.getInstalledBanks();
+
+ expect(result, [installedBank]);
+ verify(mockSbpService.getInstalledBanks()).called(1);
+ },
+ );
+
+ test(
+ 'возвращает пустой список, когда сервис вернул пустой список',
+ () async {
+ when(mockSbpService.getInstalledBanks()).thenAnswer((_) async => []);
+
+ final result = await sbpRepository.getInstalledBanks();
+
+ expect(result, isEmpty);
+ },
+ );
+
+ test(
+ 'бросает exception при ошибке нативного плагина (graceful)',
+ () async {
+ when(
+ mockSbpService.getInstalledBanks(),
+ ).thenThrow(MissingPluginException('no sbp handler'));
+
+ expect(() => sbpRepository.getInstalledBanks(), throwsException);
+ },
+ );
+ });
+
+ group('openBank', () {
+ const checkoutUrl = 'https://qr.nspk.ru/ASDFASDFASDFASDF';
+
+ test(
+ 'делегирует сервису и возвращает true при успешном запуске',
+ () async {
+ when(
+ mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
+ ).thenAnswer((_) async => true);
+
+ final result = await sbpRepository.openBank(
+ url: checkoutUrl,
+ bank: installedBank,
+ );
+
+ expect(result, isTrue);
+ verify(
+ mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
+ ).called(1);
+ },
+ );
+
+ test('возвращает false, когда сервис вернул false', () async {
+ when(
+ mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
+ ).thenAnswer((_) async => false);
+
+ final result = await sbpRepository.openBank(
+ url: checkoutUrl,
+ bank: installedBank,
+ );
+
+ expect(result, isFalse);
+ });
+
+ test(
+ 'возвращает false при ошибке нативного плагина (graceful)',
+ () async {
+ when(
+ mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
+ ).thenThrow(MissingPluginException('no sbp handler'));
+
+ expect(
+ () => sbpRepository.openBank(url: checkoutUrl, bank: installedBank),
+ throwsException,
+ );
+ },
+ );
+ });
+ });
+}
diff --git a/test/features/payment/services/sbp_service_test.dart b/test/features/payment/services/sbp_service_test.dart
new file mode 100644
index 00000000..cfbf2ed7
--- /dev/null
+++ b/test/features/payment/services/sbp_service_test.dart
@@ -0,0 +1,119 @@
+import 'package:eatvio_app/features/payment/services/sbp_service.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:sbp/models/c2bmembers_model.dart';
+
+/// Тесты реализации [SbpServiceImpl] — обёртки над статичным классом [Sbp].
+///
+/// Поскольку unit-тесты исполняются на хост-машине (не iOS), срабатывает
+/// Android-ветка плагина: `getInstalledBanks` возвращает список банков через
+/// `package_name`, а `openBank` подставляет схему банка в начало checkout_url.
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ const channel = MethodChannel('sbp');
+
+ /// Банк-фикстура: Сбербанк (присутствует в справочнике c2bmembersData пакета).
+ final sberbank = C2bmemberModel.fromJson(const {
+ 'bankName': 'Сбербанк',
+ 'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
+ 'schema': 'bank100000000111',
+ 'package_name': 'ru.sberbankmobile',
+ 'icon': 'packages/sbp/assets/icons/sber.png',
+ });
+
+ late SbpService sbpService;
+
+ setUp(() {
+ sbpService = const SbpServiceImpl();
+ });
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ });
+
+ group('SbpServiceImpl.getInstalledBanks', () {
+ test('возвращает список банков, установленных на устройстве (Android)', () async {
+ // Плагин (Android) возвращает список установленных банков в формате
+ // {name, bitmap, package_name}; обёртка сопоставляет со справочником.
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (MethodCall call) async {
+ return [
+ {'name': 'Сбербанк', 'bitmap': null, 'package_name': 'ru.sberbankmobile'},
+ ];
+ });
+
+ final result = await sbpService.getInstalledBanks();
+
+ expect(result, isNotEmpty);
+ expect(
+ result.any(
+ (bank) =>
+ bank.bankName == 'Сбербанк' && bank.packageName == 'ru.sberbankmobile',
+ ),
+ isTrue,
+ );
+ });
+
+ test('возвращает пустой список, если нет установленных банков', () async {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (MethodCall call) async {
+ return