forked from eatvio/eatvio_app
Merge pull request 'feat(chat): enhance chat message model and repository to support improved message handling and parsing' (#816) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#816
This commit is contained in:
@@ -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<dynamic>? 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,
|
||||
|
||||
@@ -164,9 +164,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
List<ChatMessageModel> messagesList = [message, ...(state as ChatRoomStateLoaded).chatMessages];
|
||||
final currentState = state as ChatRoomStateLoaded;
|
||||
|
||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
||||
// они обрабатываются через ответ API в sendMessage
|
||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
||||
List<ChatMessageModel> 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<ChatsCubit>().getChatRooms());
|
||||
});
|
||||
|
||||
@@ -185,6 +191,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.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<ChatRoomState> {
|
||||
);
|
||||
|
||||
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<ChatRoomState> {
|
||||
if (message != null) {
|
||||
try {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
List<File>? filesToResend;
|
||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||
// Если mediaData содержит File объекты, используем их
|
||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
||||
final firstItem = message.mediaData!.first;
|
||||
if (firstItem is File) {
|
||||
filesToResend = message.mediaData!.cast<File>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: [
|
||||
@@ -341,12 +374,27 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
try {
|
||||
await chatRepository.sendMessage(data: message.data, files: message.mediaData as List<File>?, 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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ChatRepository {
|
||||
_newUserMessagesController.add(message);
|
||||
}
|
||||
|
||||
Future<void> sendMessage({
|
||||
Future<ChatMessageModel> sendMessage({
|
||||
required String slug,
|
||||
String? data,
|
||||
List<File>? 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())!,
|
||||
);
|
||||
|
||||
@@ -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<void> sendMessage({
|
||||
Future<ChatMessageModel> sendMessage({
|
||||
@Header('X-Socket-ID') required String socketId,
|
||||
@Path() required String slug,
|
||||
@Part() String? data,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user