Merge remote-tracking branch 'upstream/main' into issue-975

This commit is contained in:
misha
2026-07-15 15:17:44 +03:00
74 changed files with 3880 additions and 251 deletions

View File

@@ -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<String> 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);

View File

@@ -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>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
);
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));

View File

@@ -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>(() => BlockedUsersRepository(blockedUserService: sl()));
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));

View File

@@ -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>(() => ConsultantRatingService(sl()));
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));

View File

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

View File

@@ -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<String, dynamic>;
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(

View File

@@ -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<String, dynamic> json) =>
_$OrderIdToConversationModelFromJson(json);
}

View File

@@ -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<String, dynamic> json) => _$PaymentModelFromJson(json);
}
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
_$PaymentModelFromJson(json);
}

View File

@@ -94,17 +94,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
Future<void> 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<String?> 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<ChatRoomState> {
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<ChatRoomState> {
messagesSubscription?.cancel();
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null;
}
}
@@ -414,7 +437,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
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);

View File

@@ -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<ChatScreen> createState() => _ChatScreenState();
@@ -61,6 +62,11 @@ class _ChatScreenState extends State<ChatScreen> {
late AppLifecycleListener _listener;
List<File> 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<ChatScreen> {
chatRoomCubit.setTimer();
}
Future<void> _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<ChatScreen> {
},
),
),
body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)),
body: DataErrorWidget(onReload: _initChatRoom),
);
} else {
return Scaffold(
@@ -147,10 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
final chatCounterpart = resolveChatCounterpart(
users: state.chatRoom.users,
currentUserId: sl<WsManager>().userId,
);
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().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<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'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)),
),
),
);

View File

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

View File

@@ -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<PaymentModel> 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<OrderIdToConversationModel> resolveOrderIdToConversation({
required String orderId,
}) async {
return await chatService.resolveOrderIdToConversation(orderId: orderId);
}

View File

@@ -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<PaymentModel> getPaymentLink({
@Path() required int id,
@Body() required Map<String, String> body,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId,
});
@GET('/api/chosen_plans/show/{id}')

View File

@@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit<OrderInfoState> {
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);

View File

@@ -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<ConsultationPaymentState> {
final PaymentRepository paymentRepository;
final SbpRepository sbpRepository;
ConsultationPaymentCubit({
required this.paymentRepository,
required this.sbpRepository,
}) : super(ConsultationPaymentState());
/// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП.
Future<void> 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<bool> 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;
}
}
}

View File

@@ -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<C2bmemberModel> banks,
String? checkoutUrl,
String? orderId,
String? errorMessage,
}) = _ConsultationPaymentState;
}

View File

@@ -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<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
Future<void> 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<bool?> _showGate(BuildContext context) {
return showPromoCodeGateBottomSheet(context);
}
Future<PromoCodeResult?> _showForm(
BuildContext context, {
Future<PromoValidation> Function(String)? onValidate,
}) {
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
}

View File

@@ -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<bool?> showPromoCodeGateBottomSheet(BuildContext context) {
return showModalBottomSheet<bool?>(
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<bool?> 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),
);
}
}

View File

@@ -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<PromoValidation> Function(String code)? onValidate;
static Future<PromoCodeResult?> show(
BuildContext context, {
Future<PromoValidation> Function(String code)? onValidate,
}) {
return showModalBottomSheet<PromoCodeResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
);
}
@override
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
}
enum _ModalState { input, loading }
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
_ModalState _state = _ModalState.input;
String? _errorText;
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _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),
),
);
}
}

View File

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

View File

@@ -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<PaymentModel> startPaymentProcess({required int planId}) async {
return chatService.getPaymentLink(
id: planId,
body: const {'payment_provider': 'alfa-bank'},
);
}
}

View File

@@ -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<List<C2bmemberModel>> getInstalledBanks() async {
return await sbpService.getInstalledBanks();
}
/// Открывает банк для оплаты. Возвращает `false` при ошибке плагина.
Future<bool> openBank({
required String url,
required C2bmemberModel bank,
}) async {
return await sbpService.openBank(url: url, bank: bank);
}
}

View File

@@ -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<List<C2bmemberModel>> getInstalledBanks();
/// Открывает банковское приложение [bank] для оплаты по ссылке [url]
/// (checkout_url вида `https://qr.nspk.ru/...`).
///
/// Возвращает `true`, если приложение банка успешно запущено.
Future<bool> openBank({required String url, required C2bmemberModel bank});
}
/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`.
class SbpServiceImpl implements SbpService {
const SbpServiceImpl();
@override
Future<List<C2bmemberModel>> getInstalledBanks() {
// Используем встроенный справочник банков c2bmembersData (получаемый с
// https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что
// установлены на устройстве. Локальные иконки/названия включены, чтобы UI
// не зависел от сетевой загрузки логотипов.
return sbp.Sbp.getInstalledBanks(
C2bmembersModel.fromJson(c2bmembersData),
useAndroidLocalIcons: true,
useAndroidLocalNames: true,
);
}
@override
Future<bool> openBank({required String url, required C2bmemberModel bank}) {
return sbp.Sbp.openBank(url, bank);
}
}

View File

@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
@JsonValue('personal_diet_setted')
personalDietSet,
@JsonValue('charge_was_collected')
stateChargeWasCollected;
stateChargeWasCollected,
@JsonValue('payment_failed')
statePaymentFailed;
static List<ChosenPlanStateEnum> 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,
];
}
}

View File

@@ -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(
"Если вас что-то не устроило, вы можете подать жалобу",
),

View File

@@ -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(
"Ваше артериальное давление?",

View File

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

View File

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

View File

@@ -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к консультации",