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:
mso7nueh
2025-12-19 12:36:00 +00:00
7 changed files with 150 additions and 55 deletions

View File

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

View File

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

View File

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

View File

@@ -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())!,
);

View File

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

View File

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

View File

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