diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 62a840e0..391a8650 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -123,7 +123,22 @@ 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'); + print('Deep link: finance/alfa/return/$orderId'); + 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 +204,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 +215,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 +249,7 @@ class AppRouter { ); } - final target = _resolveDeepLinkUri(uri); + final target = resolveDeepLinkTarget(uri); FlutterNativeSplash.remove(); if (target != null) { appRouter.go(target); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index b321a15c..91b6fab3 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -211,13 +211,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/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index bb4a8ebd..2161393d 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 @@ -91,17 +91,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), @@ -115,6 +136,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()); @@ -122,6 +144,7 @@ class ChatRoomCubit extends Cubit { messagesSubscription?.cancel(); wsManager.unsubscribeDirectChatChannel(); emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных')); + return null; } } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 4c7b96e6..a8415590 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -36,10 +36,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(); @@ -55,6 +56,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(); } @@ -64,26 +70,42 @@ 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, + ); + print('uuid: $uuid'); + 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); } } @@ -117,7 +139,7 @@ class _ChatScreenState extends State { }, ), ), - body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)), + body: DataErrorWidget(onReload: _initChatRoom), ); } else { return Scaffold( @@ -242,7 +264,7 @@ class _ChatScreenState extends State { GoRouter.of(context).pushNamed( VideoCallScreen.name, extra: { - 'conversationUuid': widget.uuid, + 'conversationUuid': _conversationUuid, 'counterpartName': state.chatRoom.users.receiver.name, 'localParticipantName': state.chatRoom.users.sender.name, }, diff --git a/pubspec.yaml b/pubspec.yaml index 509986fd..863f6d82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,7 +85,7 @@ dependencies: intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 - sbp: ^0.1.1 + 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/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index b59f16d8..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, paymentProvider: 'mock')).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, paymentProvider: 'mock')).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, paymentProvider: 'mock')).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 c36e6893..6d705f9b 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 @@ -147,7 +147,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/fixtures/chat/payments/order_id_to_chat_uuid_response.json b/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json index 0e74edc1..d74b93fd 100644 --- a/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json +++ b/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json @@ -2,4 +2,4 @@ { "chosen_plan_id": 12, "conversation_uuid": "fbd81429-8019-499a-80e8-e3933cc4d872" -} \ No newline at end of file +}