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
This commit is contained in:
2026-10-29 18:09:08 +03:00
parent 6a31880a85
commit f14f426381
11 changed files with 190 additions and 31 deletions

View File

@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
textEditingController.clear();
});
}
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().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<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().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<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().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<EmptyChatScreen> {
if (result != null) {
List<File> files = result.paths.map((path) => File(path!)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},

View File

@@ -137,11 +137,12 @@ class ChatRepository {
}
Future<String> getChatByUserSlug(String slug) async {
Future<String?> 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;
}
}

View File

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

View File

@@ -25,8 +25,7 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
Future<String?> 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);

View File

@@ -313,11 +313,15 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
}
Future<void> _openChat() async {
if (_isOpeningChat) return;
setState(() => _isOpeningChat = true);
final chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
var chatId = widget.consultationsModel.chatId;
if (chatId == null || chatId.isEmpty) {
chatId = await context.read<ConsultationsCubit>().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;
}

View File

@@ -141,13 +141,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> 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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ class FixtureChatRepository extends ChatRepository {
: super(chatService: _NoopChatService());
@override
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
return 'chat-uuid-123';
}
}