diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 8428bae8..acbffda9 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -277,7 +277,7 @@ class CubitRegistrations { ), ); sl.registerFactory( - () => ConsultationsCubit(consultationsRepository: sl()), + () => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()), ); sl.registerFactory( () => ConsultantCommentsCubit(diaryRepository: sl()), 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 80f8b206..3b94fa8c 100644 --- a/lib/features/consultations/presentation/cubit/consultations_cubit.dart +++ b/lib/features/consultations/presentation/cubit/consultations_cubit.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:eatvio_app/core/enums/base_state.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class ConsultationsCubit extends Cubit { final ConsultationsRepository consultationsRepository; - ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState()); + final ChatRepository chatRepository; + ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState()); Future loadConsultations() async { try { @@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit { emit(state.copyWith(state: BaseState.error)); } } + + Future getChatIdByUserSlug(String slug) async { + try { + return await chatRepository.getChatByUserSlug(slug); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + return null; + } + } } diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 7efa25f7..380ad7a9 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -3,14 +3,17 @@ 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/custom_divider.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; +import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class ConsultationCardWidget extends StatefulWidget { @@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State { final List legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)]; late final List<(int, DiaryModel)> givenDaysWithDayNumber; + bool _isOpeningChat = false; final List> legendStates = [ [DiaryStateEnum.init], [DiaryStateEnum.started, DiaryStateEnum.lastDayStarted], @@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State { const SizedBox(width: 8), InkWell( borderRadius: BorderRadius.circular(20), - onTap: () {}, + onTap: _isOpeningChat ? null : _openChat, child: Ink( height: 52, width: 52, @@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State { ); } + Future _openChat() async { + if (_isOpeningChat) return; + setState(() => _isOpeningChat = true); + 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 || chatId.isEmpty) { + showErrorToast(S.of(context).msg_something_went_wrong); + return; + } + GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId}); + } + Row _legendTitle(String text, Color color) { return Row( crossAxisAlignment: CrossAxisAlignment.start, 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 c85a7e63..d191f294 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -145,13 +145,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 26ab5974..0e3fe5ef 100644 --- a/test/features/consultations/presentation/cubit/consultations_cubit_test.dart +++ b/test/features/consultations/presentation/cubit/consultations_cubit_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/enums/base_state.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart'; @@ -8,14 +9,21 @@ import 'package:mocktail/mocktail.dart'; class MockConsultationsRepository extends Mock implements ConsultationsRepository {} +class MockChatRepository extends Mock implements ChatRepository {} + void main() { group('ConsultationsCubit', () { late ConsultationsCubit cubit; late MockConsultationsRepository mockRepository; + late MockChatRepository mockChatRepository; setUp(() { mockRepository = MockConsultationsRepository(); - cubit = ConsultationsCubit(consultationsRepository: mockRepository); + mockChatRepository = MockChatRepository(); + cubit = ConsultationsCubit( + consultationsRepository: mockRepository, + chatRepository: mockChatRepository, + ); }); tearDown(() { @@ -47,5 +55,33 @@ void main() { expect(cubit.state.state, BaseState.error); }); + + test('getChatIdByUserSlug - возвращает id чата', () async { + when(() => mockChatRepository.getChatByUserSlug('john-doe')) + .thenAnswer((_) async => 'chat-uuid'); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, 'chat-uuid'); + verify(() => mockChatRepository.getChatByUserSlug('john-doe')).called(1); + }); + + test('getChatIdByUserSlug - возвращает null при ошибке', () async { + when(() => mockChatRepository.getChatByUserSlug(any())) + .thenThrow(Exception('Error')); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, isNull); + }); + + test('getChatIdByUserSlug - возвращает null когда чата нет', () async { + when(() => mockChatRepository.getChatByUserSlug(any())) + .thenAnswer((_) async => null); + + final chatId = await cubit.getChatIdByUserSlug('john-doe'); + + expect(chatId, isNull); + }); }); } 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 c897d8e2..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,18 +1,26 @@ +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'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; +import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_card_widget.dart'; 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'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:network_image_mock/network_image_mock.dart'; +class MockConsultationsCubit extends Mock implements ConsultationsCubit {} + void main() { group('ConsultationCardWidget smoke', () { setUpAll(() async { @@ -28,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( @@ -64,7 +86,8 @@ void main() { personalDietId: null, ); - Widget wrap(Widget child) { + Widget wrap(Widget child, {ConsultationsCubit? cubit}) { + final consultationsCubit = cubit ?? MockConsultationsCubit(); return MaterialApp.router( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -77,7 +100,20 @@ void main() { routerConfig: GoRouter( initialLocation: '/', routes: [ - GoRoute(path: '/', builder: (context, state) => child), + GoRoute( + path: '/', + builder: (context, state) => BlocProvider.value( + value: consultationsCubit, + child: child, + ), + ), + GoRoute( + path: '/chat-screen', + name: 'chatScreen', + builder: (context, state) => Scaffold( + body: Text('chat:${(state.extra as Map)['uuid']}'), + ), + ), GoRoute( path: '/profile-screen', name: 'profileScreen', @@ -163,5 +199,118 @@ void main() { }); }, ); + + testWidgets('opens chat with consultation user when chat icon tapped', ( + tester, + ) async { + final cubit = MockConsultationsCubit(); + when(() => cubit.stream).thenAnswer((_) => const Stream.empty()); + when(() => cubit.getChatIdByUserSlug('john-doe')) + .thenAnswer((_) async => 'chat-uuid'); + + 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(); + + expect(find.text('chat:chat-uuid'), findsOneWidget); + 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'; } }