From f14f42638133ab588ca90cd05f900edea2d03acf Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Thu, 29 Oct 2026 18:09:08 +0300 Subject: [PATCH] fix(consultations): open chat via consultation chat_id Use chat_id from the consultations payload for direct navigation and keep the chat lookup as a fallback for legacy rows without chat_id. - validate chat id in ChatRepository and make it nullable - guard repeated taps while opening chat - handle nullable chat id in ProfileCubit and EmptyChatScreen related to #1106 --- .../presentation/pages/empty_chat_screen.dart | 24 ++-- .../chat/repositories/chat_repository.dart | 9 +- .../models/consultation_model.dart | 1 + .../cubit/consultations_cubit.dart | 3 +- .../widgets/consultation_card_widget.dart | 8 +- .../bloc/profile_cubit/profile_cubit.dart | 10 +- .../repositories/chat_repository_test.dart | 6 +- .../models/consultation_model_test.dart | 49 ++++++++ .../cubit/consultations_cubit_test.dart | 4 +- .../consultation_card_widget_test.dart | 105 ++++++++++++++++++ test/fixtures/doubles/profile_doubles.dart | 2 +- 11 files changed, 190 insertions(+), 31 deletions(-) diff --git a/lib/features/chat/presentation/pages/empty_chat_screen.dart b/lib/features/chat/presentation/pages/empty_chat_screen.dart index a0ddf60a..43f20390 100644 --- a/lib/features/chat/presentation/pages/empty_chat_screen.dart +++ b/lib/features/chat/presentation/pages/empty_chat_screen.dart @@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State { textEditingController.clear(); }); } - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (context.mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && context.mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State { if (images.isNotEmpty) { List files = images.map((image) => File(image.path)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State { if (images.isNotEmpty) { List files = images.map((image) => File(image.path)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, @@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State { if (result != null) { List files = result.paths.map((path) => File(path!)).toList(); await sl().sendMessage(files: files, slug: widget.user.slug); - final chatIdOrError = await sl().getChatByUserSlug(widget.user.slug); - if (mounted) { - AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError}); + final chatId = await sl().getChatByUserSlug(widget.user.slug); + if (chatId != null && chatId.isNotEmpty && mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId}); } } }, diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index ade2ed26..9230e4d8 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -137,11 +137,12 @@ class ChatRepository { } - Future getChatByUserSlug(String slug) async { + Future getChatByUserSlug(String slug) async { final response = await chatService.getChatByUserSlug(slug: slug); - if (response.response.statusCode == 404) { - return '404'; + final data = response.data; + if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) { + return data['id'] as String; } - return response.data['id']; + return null; } } diff --git a/lib/features/consultations/models/consultation_model.dart b/lib/features/consultations/models/consultation_model.dart index c73a8d37..50280cfe 100644 --- a/lib/features/consultations/models/consultation_model.dart +++ b/lib/features/consultations/models/consultation_model.dart @@ -9,6 +9,7 @@ part 'consultation_model.g.dart'; abstract class ConsultationModel with _$ConsultationModel { factory ConsultationModel({ required int id, + @JsonKey(name: 'chat_id') String? chatId, @JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState, @JsonKey(name: 'plan_title') required String planTitle, @JsonKey(name: 'user_name') required String userName, diff --git a/lib/features/consultations/presentation/cubit/consultations_cubit.dart b/lib/features/consultations/presentation/cubit/consultations_cubit.dart index ea830c88..3b94fa8c 100644 --- a/lib/features/consultations/presentation/cubit/consultations_cubit.dart +++ b/lib/features/consultations/presentation/cubit/consultations_cubit.dart @@ -25,8 +25,7 @@ class ConsultationsCubit extends Cubit { Future getChatIdByUserSlug(String slug) async { try { - final chatIdOrError = await chatRepository.getChatByUserSlug(slug); - return chatIdOrError == '404' ? null : chatIdOrError; + return await chatRepository.getChatByUserSlug(slug); } catch (e, st) { await reportFrom(e, st, this); log(e.toString(), stackTrace: st); diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index e579c9b3..380ad7a9 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -313,11 +313,15 @@ class _ConsultationCardWidgetState extends State { } Future _openChat() async { + if (_isOpeningChat) return; setState(() => _isOpeningChat = true); - final chatId = await context.read().getChatIdByUserSlug(widget.consultationsModel.userSlug); + var chatId = widget.consultationsModel.chatId; + if (chatId == null || chatId.isEmpty) { + chatId = await context.read().getChatIdByUserSlug(widget.consultationsModel.userSlug); + } if (!mounted) return; setState(() => _isOpeningChat = false); - if (chatId == null) { + if (chatId == null || chatId.isEmpty) { showErrorToast(S.of(context).msg_something_went_wrong); return; } diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 2801c2b4..304aeefe 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -141,13 +141,13 @@ class ProfileCubit extends Cubit { Future getChatRoomIdBySlug({required String slug}) async { try { - final chatIdOrError = await chatRepository.getChatByUserSlug(slug); - final userProfile = await profileRepository.getProfileBySlug(slug); - if (chatIdOrError == '404') { + final chatId = await chatRepository.getChatByUserSlug(slug); + if (chatId == null || chatId.isEmpty) { + final userProfile = await profileRepository.getProfileBySlug(slug); AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile); } else { - AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError}); - return chatIdOrError; + AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId}); + return chatId; } } catch (e, st) { await reportFrom(e, st, this); diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 247d1311..e0c271ab 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -526,11 +526,11 @@ void main() { }, ); - test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async { + test('getChatByUserSlug - возвращает null, если в ответе нет id', () async { const slug = 'user-slug'; final response = HttpResponse( {}, - Response(requestOptions: RequestOptions(path: ''), statusCode: 404), + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), ); when( @@ -539,7 +539,7 @@ void main() { final result = await chatRepository.getChatByUserSlug(slug); - expect(result, '404'); + expect(result, isNull); verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); }); diff --git a/test/features/consultations/models/consultation_model_test.dart b/test/features/consultations/models/consultation_model_test.dart index 6d927618..64ac25f9 100644 --- a/test/features/consultations/models/consultation_model_test.dart +++ b/test/features/consultations/models/consultation_model_test.dart @@ -156,6 +156,55 @@ void main() { }); group('ConsultationModel', () { + test('fromJson parses chat_id', () { + final json = { + 'id': 1, + 'chat_id': 'direct-chat-uuid', + 'last_state': 'started', + 'plan_title': 'Test Plan', + 'user_name': 'User 1', + 'user_slug': 'user-1', + 'consultant_payment': 1000.0, + 'duration': 30, + 'current_diary_day': 5, + 'user_profile_photo_url': 'photo.jpg', + 'consultation_option_price': 2000, + 'start_date': '2024-01-01T00:00:00Z', + 'end_date': '2024-01-31T00:00:00Z', + 'consultant_id': 1, + 'diaries': [], + 'personal_diet_id': null, + }; + + final model = ConsultationModel.fromJson(json); + + expect(model.chatId, 'direct-chat-uuid'); + }); + + test('fromJson allows missing chat_id', () { + final json = { + 'id': 1, + 'last_state': 'started', + 'plan_title': 'Test Plan', + 'user_name': 'User 1', + 'user_slug': 'user-1', + 'consultant_payment': 1000.0, + 'duration': 30, + 'current_diary_day': 5, + 'user_profile_photo_url': 'photo.jpg', + 'consultation_option_price': 2000, + 'start_date': '2024-01-01T00:00:00Z', + 'end_date': '2024-01-31T00:00:00Z', + 'consultant_id': 1, + 'diaries': [], + 'personal_diet_id': null, + }; + + final model = ConsultationModel.fromJson(json); + + expect(model.chatId, isNull); + }); + test('toJson serializes ConsultationModel correctly', () { final model = ConsultationModel( id: 1, diff --git a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart index c9fe20b5..0e3fe5ef 100644 --- a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart +++ b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart @@ -75,9 +75,9 @@ void main() { expect(chatId, isNull); }); - test('getChatIdByUserSlug - возвращает null когда чат не найден', () async { + test('getChatIdByUserSlug - возвращает null когда чата нет', () async { when(() => mockChatRepository.getChatByUserSlug(any())) - .thenAnswer((_) async => '404'); + .thenAnswer((_) async => null); final chatId = await cubit.getChatIdByUserSlug('john-doe'); diff --git a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart index c170f224..46c5e042 100644 --- a/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart +++ b/test/features/consultations/presentation/widgets/consultation_card_widget_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -8,6 +10,7 @@ import 'package:eatvio_app/features/consultations/presentation/widgets/consultat import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -33,6 +36,20 @@ void main() { AppConfig(buildType: AppBuildType.tests) ..setBaseUrl('http://localhost'), ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('PonnamKarthik/fluttertoast'), + (MethodCall methodCall) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('PonnamKarthik/fluttertoast'), + null, + ); }); final mockConsultation = ConsultationModel( @@ -207,5 +224,93 @@ void main() { verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); }); }); + + testWidgets( + 'opens chat directly with chat id without requesting it again', + (tester) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + final consultationWithChatId = mockConsultation.copyWith( + chatId: 'direct-chat-uuid', + ); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget( + consultationsModel: consultationWithChatId, + ), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.pumpAndSettle(); + + expect(find.text('chat:direct-chat-uuid'), findsOneWidget); + verifyNever(() => cubit.getChatIdByUserSlug(any())); + }); + }, + ); + + testWidgets('shows error and stays on card when chat id is null', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) async => null); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget(consultationsModel: mockConsultation), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + expect(find.textContaining('chat:'), findsNothing); + expect( + find.byType(ConsultationCardWidget), + findsOneWidget, + ); + verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); + }); + }); + + testWidgets('does not request chat id twice while first is pending', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + final completer = Completer(); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) => completer.future); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap( + ConsultationCardWidget(consultationsModel: mockConsultation), + cubit: cubit, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.chat_outlined)); + await tester.tap(find.byIcon(Icons.chat_outlined)); + + completer.complete('chat-uuid'); + await tester.pumpAndSettle(); + + expect(find.text('chat:chat-uuid'), findsOneWidget); + verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1); + }); + }); }); } diff --git a/test/fixtures/doubles/profile_doubles.dart b/test/fixtures/doubles/profile_doubles.dart index dfc8e935..0522fc1d 100644 --- a/test/fixtures/doubles/profile_doubles.dart +++ b/test/fixtures/doubles/profile_doubles.dart @@ -69,7 +69,7 @@ class FixtureChatRepository extends ChatRepository { : super(chatService: _NoopChatService()); @override - Future getChatByUserSlug(String slug) async { + Future getChatByUserSlug(String slug) async { return 'chat-uuid-123'; } }