diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index 86671314..45b6c6ac 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -4,14 +4,31 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'chat_message_model.freezed.dart'; part 'chat_message_model.g.dart'; +int _parseAuthorId(dynamic json) { + if (json == null) return 0; + if (json is num) return json.toInt(); + return 0; +} + +DateTime _parseUpdatedAt(dynamic json) { + if (json == null) return DateTime.now(); + if (json is String) return parseDate(json); + return DateTime.now(); +} + +String _parseData(dynamic json) { + if (json == null) return ''; + return json.toString(); +} + @freezed abstract class ChatMessageModel with _$ChatMessageModel { const factory ChatMessageModel({ required int id, - required String data, + @JsonKey(name: 'data', fromJson: _parseData) required String data, @JsonKey(name: 'media_data') required List? mediaData, - @JsonKey(name: 'author_id') required int authorId, - @JsonKey(name: 'updated_at', fromJson: parseDate) required DateTime updatedAt, + @JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId, + @JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt, @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'room_id') required String? roomId, 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 5206889b..d77eacc7 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 @@ -164,9 +164,15 @@ class ChatRoomCubit extends Cubit { messagesSubscription = wsManager.directChatStream?.listen((event) { AudioPlayer().play((AssetSource('audio/recive_message.wav'))); final message = ChatMessageModel.fromJson(jsonDecode(event.data)); - List messagesList = [message, ...(state as ChatRoomStateLoaded).chatMessages]; + final currentState = state as ChatRoomStateLoaded; + + // Сообщения от текущего пользователя не приходят через WebSocket, + // они обрабатываются через ответ API в sendMessage + // Здесь обрабатываем только входящие сообщения от других пользователей + List messagesList = [message, ...currentState.chatMessages]; + chatRepository.emitNewMessageToChats(message); - emit((state as ChatRoomStateLoaded).copyWith(chatMessages: messagesList, isLoading: isLoading)); + emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading)); EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl().getChatRooms()); }); @@ -185,6 +191,8 @@ class ChatRoomCubit extends Cubit { final messageUuid = uuid.v4(); final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List.from(files)) : null; AudioPlayer().play((AssetSource('audio/send_message.wav'))); + + // Создаем временное сообщение с состоянием loading emit( (state as ChatRoomStateLoaded).copyWith( chatMessages: [ @@ -206,12 +214,25 @@ class ChatRoomCubit extends Cubit { ); try { - await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); + // Отправляем сообщение и получаем ответ от сервера с правильными данными + final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); + + // Заменяем локальное сообщение данными с сервера (с правильными URL файлов) emit( (state as ChatRoomStateLoaded).copyWith( chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { if (elem.uuid == messageUuid) { - return elem.copyWith(messageState: MessageState.sent); + // Сохраняем UUID, состояние и обновляем данные из ответа сервера + // Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе + return serverMessage.copyWith( + uuid: messageUuid, + messageState: MessageState.sent, + isCurrentUserAuthor: true, + isRead: elem.isRead, + roomId: elem.roomId ?? serverMessage.roomId, + authorId: serverMessage.authorId, + updatedAt: serverMessage.updatedAt, + ); } return elem; }).toList(), @@ -320,6 +341,18 @@ class ChatRoomCubit extends Cubit { if (message != null) { try { AudioPlayer().play((AssetSource('audio/send_message.wav'))); + + // Определяем, есть ли файлы для повторной отправки + List? filesToResend; + if (message.mediaData != null && message.mediaData!.isNotEmpty) { + // Если mediaData содержит File объекты, используем их + // Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна + final firstItem = message.mediaData!.first; + if (firstItem is File) { + filesToResend = message.mediaData!.cast(); + } + } + emit( (state as ChatRoomStateLoaded).copyWith( chatMessages: [ @@ -341,12 +374,27 @@ class ChatRoomCubit extends Cubit { ); try { - await chatRepository.sendMessage(data: message.data, files: message.mediaData as List?, slug: userSlug); + // Отправляем сообщение и получаем ответ от сервера + final serverMessage = await chatRepository.sendMessage( + data: message.data, + files: filesToResend, + slug: userSlug, + ); + + // Заменяем локальное сообщение данными с сервера emit( (state as ChatRoomStateLoaded).copyWith( chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { if (elem.uuid == uuid) { - return elem.copyWith(messageState: MessageState.sent); + return serverMessage.copyWith( + uuid: uuid, + messageState: MessageState.sent, + isCurrentUserAuthor: true, + isRead: elem.isRead, + roomId: elem.roomId ?? serverMessage.roomId, + authorId: serverMessage.authorId, + updatedAt: serverMessage.updatedAt, + ); } return elem; }).toList(), diff --git a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart index 5f6f8382..2e0d3e13 100644 --- a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart @@ -107,21 +107,54 @@ class ChatBubbleWidget extends StatelessWidget { slivers: [ SliverList.separated( itemBuilder: (BuildContext context, int index) { - if (!mediaData[index].toString().contains('.png') && - !mediaData[index].toString().contains('.jpg') && - !mediaData[index].toString().contains('.jpeg')) { + final mediaItem = mediaData[index]; + final isImage = mediaItem is File + ? (mediaItem.path.toLowerCase().endsWith('.png') || + mediaItem.path.toLowerCase().endsWith('.jpg') || + mediaItem.path.toLowerCase().endsWith('.jpeg') || + mediaItem.path.toLowerCase().endsWith('.gif') || + mediaItem.path.toLowerCase().endsWith('.webp')) + : (mediaItem.toString().toLowerCase().contains('.png') || + mediaItem.toString().toLowerCase().contains('.jpg') || + mediaItem.toString().toLowerCase().contains('.jpeg') || + mediaItem.toString().toLowerCase().contains('.gif') || + mediaItem.toString().toLowerCase().contains('.webp')); + + if (!isImage) { + // Получаем имя файла + String fileName; + if (mediaItem is File) { + fileName = mediaItem.path.split('/').last; + } else { + final urlString = mediaItem.toString(); + fileName = urlString.contains('/') ? urlString.split('/').last.split('?').first : urlString.replaceAll('chat_files/', ''); + } + return ListTile( contentPadding: const EdgeInsets.all(4), horizontalTitleGap: 8, onTap: () async { - if (!await launchUrl(Uri.parse('${mediaData[index]}'))) { - throw Exception('Could not launch ${mediaData[index].toString()}'); + if (mediaItem is File) { + // Для локальных файлов используем file:// URI + final fileUri = Uri.file(mediaItem.path); + if (await canLaunchUrl(fileUri)) { + await launchUrl(fileUri, mode: LaunchMode.externalApplication); + } + } else { + // Для URL файлов используем обычный launchUrl + final urlString = mediaItem.toString(); + final uri = urlString.startsWith('http://') || urlString.startsWith('https://') + ? Uri.parse(urlString) + : Uri.parse('https://$urlString'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } } }, leading: const Icon(Icons.file_copy_outlined, color: Colors.black), trailing: const Icon(Icons.download_outlined, color: Colors.black), title: Text( - mediaData[index].toString().replaceAll('chat_files/', ''), + fileName, maxLines: 2, style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis), ), @@ -129,16 +162,16 @@ class ChatBubbleWidget extends StatelessWidget { } return GestureDetector( onTap: () { - context.pushTransparentRoute( - PhotoViewScreen(imageUrls: mediaData, index: index, isLocal: mediaData[index] is File ? true : false), - ); + // Определяем, все ли изображения локальные + final allLocal = mediaData.every((item) => item is File); + context.pushTransparentRoute(PhotoViewScreen(imageUrls: mediaData, index: index, isLocal: allLocal)); }, child: Hero( tag: '${mediaData[index]}', transitionOnUserGestures: true, child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: mediaData[index] is File ? Image.file(mediaData[index]) : CachedNetworkImage(imageUrl: '${mediaData[index]}'), + child: mediaItem is File ? Image.file(mediaItem) : CachedNetworkImage(imageUrl: '$mediaItem'), ), ), ); @@ -193,10 +226,7 @@ class ChatBubbleWidget extends StatelessWidget { ); final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16); - final linkStyle = textStyle.copyWith( - color: appTheme.blueA400, - decoration: TextDecoration.underline, - ); + final linkStyle = textStyle.copyWith(color: appTheme.blueA400, decoration: TextDecoration.underline); final matches = urlRegex.allMatches(text); if (matches.isEmpty) { @@ -209,47 +239,39 @@ class ChatBubbleWidget extends StatelessWidget { for (final match in matches) { // Добавляем текст до ссылки if (match.start > lastEnd) { - spans.add(TextSpan( - text: text.substring(lastEnd, match.start), - style: textStyle, - )); + spans.add(TextSpan(text: text.substring(lastEnd, match.start), style: textStyle)); } // Добавляем кликабельную ссылку final url = match.group(0)!; - final fullUrl = url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://') - ? url - : 'https://$url'; + final fullUrl = url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://') ? url : 'https://$url'; - spans.add(TextSpan( - text: url, - style: linkStyle, - recognizer: TapGestureRecognizer() - ..onTap = () async { - try { - final uri = Uri.parse(fullUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); + spans.add( + TextSpan( + text: url, + style: linkStyle, + recognizer: TapGestureRecognizer() + ..onTap = () async { + try { + final uri = Uri.parse(fullUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + // Игнорируем ошибки открытия ссылок } - } catch (e) { - // Игнорируем ошибки открытия ссылок - } - }, - )); + }, + ), + ); lastEnd = match.end; } // Добавляем оставшийся текст после последней ссылки if (lastEnd < text.length) { - spans.add(TextSpan( - text: text.substring(lastEnd), - style: textStyle, - )); + spans.add(TextSpan(text: text.substring(lastEnd), style: textStyle)); } - return RichText( - text: TextSpan(children: spans), - ); + return RichText(text: TextSpan(children: spans)); } } diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index b1961b2a..0a3e2960 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -28,7 +28,7 @@ class ChatRepository { _newUserMessagesController.add(message); } - Future sendMessage({ + Future sendMessage({ required String slug, String? data, List? files, @@ -48,9 +48,13 @@ class ChatRepository { } } - await chatService.sendMessage( + // Если data пустая строка или null, не передаем её вообще + // Сервер требует либо непустую строку, либо отсутствие поля + final dataToSend = (data == null || data.trim().isEmpty) ? null : data; + + return await chatService.sendMessage( slug: slug, - data: data, + data: dataToSend, mediaData: multipartFiles.isEmpty ? null : multipartFiles, socketId: (await SocketIdStorage.getSocketIdFromStorage())!, ); diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart index 89d65749..a2bf52cf 100644 --- a/lib/features/chat/services/chat_service.dart +++ b/lib/features/chat/services/chat_service.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +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'; @@ -26,7 +27,7 @@ abstract class ChatService { @POST('/api/v2/chat/message/send/{slug}') @MultiPart() - Future sendMessage({ + Future sendMessage({ @Header('X-Socket-ID') required String socketId, @Path() required String slug, @Part() String? data, diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 828c0e38..ea0bda09 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: null_argument_to_non_null_type + import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/models/pagination_model.dart'; diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart index 76fb32bf..103c4944 100644 --- a/test/features/chat/services/chat_service_test.dart +++ b/test/features/chat/services/chat_service_test.dart @@ -67,6 +67,7 @@ void main() { const data = 'Test message'; const socketId = 'socket-id-123'; + // ignore: null_argument_to_non_null_type when(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).thenAnswer((_) async => Future.value()); await mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId);