Получение и отправка текста, статусы сообщения

This commit is contained in:
Халимов Рустам
2026-05-15 14:59:54 +03:00
parent 4d4bc8edd1
commit a426b63b7d
22 changed files with 3777 additions and 358 deletions
+5
View File
@@ -83,6 +83,11 @@
"registrationFailed": "Registration failed", "registrationFailed": "Registration failed",
"online": "online", "online": "online",
"lastSeen": "last seen {date} at {time}", "lastSeen": "last seen {date} at {time}",
"lastSeenRelative": "last seen {hours}h {minutes}m ago",
"lastSeenRelativeMinutes": "last seen {minutes}m ago",
"lastSeenJustNow": "last seen just now",
"lastSeenYesterday": "last seen yesterday at {time}",
"lastSeenDayBeforeYesterday": "last seen day before yesterday at {time}",
"noMessages": "No messages yet", "noMessages": "No messages yet",
"messageHint": "Message" "messageHint": "Message"
} }
+5
View File
@@ -83,6 +83,11 @@
"registrationFailed": "Ошибка регистрации", "registrationFailed": "Ошибка регистрации",
"online": "в сети", "online": "в сети",
"lastSeen": "был(а) в сети {date} в {time}", "lastSeen": "был(а) в сети {date} в {time}",
"lastSeenRelative": "был(а) {hours} ч. {minutes} мин. назад",
"lastSeenRelativeMinutes": "был(а) {minutes} мин. назад",
"lastSeenJustNow": "был(а) только что",
"lastSeenYesterday": "был(а) вчера в {time}",
"lastSeenDayBeforeYesterday": "был(а) позавчера в {time}",
"noMessages": "Сообщений пока нет", "noMessages": "Сообщений пока нет",
"messageHint": "Сообщение" "messageHint": "Сообщение"
} }
@@ -7,8 +7,14 @@ class SignalRService {
final SharedPreferences prefs; final SharedPreferences prefs;
HubConnection? _hubConnection; HubConnection? _hubConnection;
final _messageController = StreamController<Message>.broadcast(); final _messageController = StreamController<Message>.broadcast();
final _typingController = StreamController<Map<String, dynamic>>.broadcast();
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
final _readReceiptController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Message> get messages => _messageController.stream; Stream<Message> get messages => _messageController.stream;
Stream<Map<String, dynamic>> get typingUpdates => _typingController.stream;
Stream<Map<String, dynamic>> get statusUpdates => _statusController.stream;
Stream<Map<String, dynamic>> get readReceipts => _readReceiptController.stream;
SignalRService(this.prefs); SignalRService(this.prefs);
@@ -54,13 +60,46 @@ class SignalRService {
print('[SignalR] Reconnected! ID: $connectionId'); print('[SignalR] Reconnected! ID: $connectionId');
}); });
// Handle multiple possible event names to be safe // Event names verified from backend source code (MessageNotifier.cs)
_hubConnection?.on('ReceiveMessage', _handleReceiveMessage); _hubConnection?.on('new_message', _handleReceiveMessage);
_hubConnection?.on('receiveMessage', _handleReceiveMessage); _hubConnection?.on('new_message_received', _handleReceiveMessage); // Optional fallback
_hubConnection?.on('UserTyping', _handleUserTyping);
_hubConnection?.on('user_typing', _handleUserTyping); _hubConnection?.on('user_typing', _handleUserTyping);
_hubConnection?.on('UserStoppedTyping', _handleUserStoppedTyping);
_hubConnection?.on('user_stopped_typing', _handleUserStoppedTyping); _hubConnection?.on('user_stopped_typing', _handleUserStoppedTyping);
_hubConnection?.on('user_online', (args) {
// ignore: avoid_print
print('[SignalR] user_online: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({'userId': data['userId'] ?? data['UserId'], 'isOnline': true});
}
});
_hubConnection?.on('user_offline', (args) {
// ignore: avoid_print
print('[SignalR] user_offline: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_statusController.add({
'userId': data['userId'] ?? data['UserId'],
'isOnline': false,
'lastSeen': data['lastSeen'] ?? data['LastSeen']
});
}
});
_hubConnection?.on('messages_read', (args) {
// ignore: avoid_print
print('[SignalR] messages_read: $args');
if (args != null && args.isNotEmpty) {
final data = args[0] as Map<String, dynamic>;
_readReceiptController.add({
'chatId': data['chatId'] ?? data['ChatId'],
'userId': data['userId'] ?? data['UserId'],
'lastReadMessageId': data['lastReadMessageId'] ?? data['LastReadMessageId'],
'lastReadSequenceId': data['lastReadSequenceId'] ?? data['LastReadSequenceId'],
});
}
});
try { try {
// ignore: avoid_print // ignore: avoid_print
@@ -74,8 +113,17 @@ class SignalRService {
} }
} }
final _typingController = StreamController<Map<String, dynamic>>.broadcast(); Future<void> markMessagesAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) async {
Stream<Map<String, dynamic>> get typingUpdates => _typingController.stream; // ignore: avoid_print
print('[SignalR] markMessagesAsRead: chatId=$chatId, msgId=$lastReadMessageId, seqId=$lastReadSequenceId');
await _hubConnection?.invoke('read_messages', args: [
{
'ChatId': chatId,
'LastReadMessageId': lastReadMessageId,
'LastReadSequenceId': lastReadSequenceId,
}
]);
}
void _handleUserTyping(List<dynamic>? arguments) { void _handleUserTyping(List<dynamic>? arguments) {
// ignore: avoid_print // ignore: avoid_print
@@ -109,15 +157,18 @@ class SignalRService {
if (arguments != null && arguments.isNotEmpty) { if (arguments != null && arguments.isNotEmpty) {
final data = arguments[0] as Map<String, dynamic>; final data = arguments[0] as Map<String, dynamic>;
final message = Message( final message = Message(
id: data['id'] ?? '', id: (data['id'] ?? data['Id'] ?? '').toString(),
chatId: data['chatId'] ?? '', chatId: (data['chatId'] ?? data['ChatId'] ?? '').toString(),
senderId: data['userId'] ?? (data['senderId'] ?? ''), senderId: (data['senderId'] ?? data['SenderId'] ?? data['userId'] ?? data['UserId'] ?? '').toString(),
content: data['content'] ?? '', content: data['content'] ?? data['Content'] ?? '',
messageType: data['type']?.toString().toLowerCase() ?? 'text', messageType: (data['type'] ?? data['Type'] ?? 'text').toString().toLowerCase(),
createdAt: _parseDateTime(data['createdAt']), sequenceId: int.tryParse((data['sequenceId'] ?? data['SequenceId'] ?? '0').toString()) ?? 0,
updatedAt: _parseDateTime(data['updatedAt']), createdAt: _parseDateTime(data['createdAt'] ?? data['CreatedAt']),
isRead: data['isRead'] ?? false, updatedAt: _parseDateTime(data['updatedAt'] ?? data['UpdatedAt']),
media: data['media'] is Map<String, dynamic> ? data['media'] as Map<String, dynamic> : null, status: (data['isRead'] ?? data['IsRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
media: (data['media'] ?? data['Media']) is Map<String, dynamic>
? (data['media'] ?? data['Media']) as Map<String, dynamic>
: null,
); );
_messageController.add(message); _messageController.add(message);
} }
@@ -194,9 +194,10 @@ class ChatRemoteDataSourceImpl implements ChatRemoteDataSource {
senderId: json['userId'] ?? (json['senderId'] ?? ''), senderId: json['userId'] ?? (json['senderId'] ?? ''),
content: json['content'] ?? '', content: json['content'] ?? '',
messageType: json['type']?.toString().toLowerCase() ?? 'text', messageType: json['type']?.toString().toLowerCase() ?? 'text',
sequenceId: int.tryParse(json['sequenceId']?.toString() ?? '0') ?? 0,
createdAt: _parseDateTime(json['createdAt']), createdAt: _parseDateTime(json['createdAt']),
updatedAt: _parseDateTime(json['updatedAt']), updatedAt: _parseDateTime(json['updatedAt']),
isRead: json['isRead'] ?? false, status: (json['isRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
media: json['media'] is Map<String, dynamic> ? json['media'] as Map<String, dynamic> : null, media: json['media'] is Map<String, dynamic> ? json['media'] as Map<String, dynamic> : null,
); );
} }
@@ -62,8 +62,12 @@ class LastMessageModel {
late String id; late String id;
late String content; late String content;
late String messageType; late String messageType;
late int sequenceId;
DateTime? createdAt; DateTime? createdAt;
@Enumerated(EnumType.name)
MessageStatus status = MessageStatus.delivered;
Message toEntity() { Message toEntity() {
return Message( return Message(
id: id, id: id,
@@ -71,7 +75,9 @@ class LastMessageModel {
senderId: '', senderId: '',
content: content, content: content,
messageType: messageType, messageType: messageType,
sequenceId: sequenceId,
createdAt: createdAt, createdAt: createdAt,
status: status,
); );
} }
@@ -80,7 +86,9 @@ class LastMessageModel {
..id = message.id ..id = message.id
..content = message.content ..content = message.content
..messageType = message.messageType ..messageType = message.messageType
..createdAt = message.createdAt; ..sequenceId = message.sequenceId
..createdAt = message.createdAt
..status = message.status;
} }
} }
@@ -1663,6 +1663,17 @@ const LastMessageModelSchema = Schema(
id: 3, id: 3,
name: r'messageType', name: r'messageType',
type: IsarType.string, type: IsarType.string,
),
r'sequenceId': PropertySchema(
id: 4,
name: r'sequenceId',
type: IsarType.long,
),
r'status': PropertySchema(
id: 5,
name: r'status',
type: IsarType.string,
enumMap: _LastMessageModelstatusEnumValueMap,
) )
}, },
estimateSize: _lastMessageModelEstimateSize, estimateSize: _lastMessageModelEstimateSize,
@@ -1680,6 +1691,7 @@ int _lastMessageModelEstimateSize(
bytesCount += 3 + object.content.length * 3; bytesCount += 3 + object.content.length * 3;
bytesCount += 3 + object.id.length * 3; bytesCount += 3 + object.id.length * 3;
bytesCount += 3 + object.messageType.length * 3; bytesCount += 3 + object.messageType.length * 3;
bytesCount += 3 + object.status.name.length * 3;
return bytesCount; return bytesCount;
} }
@@ -1693,6 +1705,8 @@ void _lastMessageModelSerialize(
writer.writeDateTime(offsets[1], object.createdAt); writer.writeDateTime(offsets[1], object.createdAt);
writer.writeString(offsets[2], object.id); writer.writeString(offsets[2], object.id);
writer.writeString(offsets[3], object.messageType); writer.writeString(offsets[3], object.messageType);
writer.writeLong(offsets[4], object.sequenceId);
writer.writeString(offsets[5], object.status.name);
} }
LastMessageModel _lastMessageModelDeserialize( LastMessageModel _lastMessageModelDeserialize(
@@ -1706,6 +1720,10 @@ LastMessageModel _lastMessageModelDeserialize(
object.createdAt = reader.readDateTimeOrNull(offsets[1]); object.createdAt = reader.readDateTimeOrNull(offsets[1]);
object.id = reader.readString(offsets[2]); object.id = reader.readString(offsets[2]);
object.messageType = reader.readString(offsets[3]); object.messageType = reader.readString(offsets[3]);
object.sequenceId = reader.readLong(offsets[4]);
object.status = _LastMessageModelstatusValueEnumMap[
reader.readStringOrNull(offsets[5])] ??
MessageStatus.sending;
return object; return object;
} }
@@ -1724,11 +1742,30 @@ P _lastMessageModelDeserializeProp<P>(
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
case 3: case 3:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
case 4:
return (reader.readLong(offset)) as P;
case 5:
return (_LastMessageModelstatusValueEnumMap[
reader.readStringOrNull(offset)] ??
MessageStatus.sending) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); throw IsarError('Unknown property with id $propertyId');
} }
} }
const _LastMessageModelstatusEnumValueMap = {
r'sending': r'sending',
r'delivered': r'delivered',
r'read': r'read',
r'error': r'error',
};
const _LastMessageModelstatusValueEnumMap = {
r'sending': MessageStatus.sending,
r'delivered': MessageStatus.delivered,
r'read': MessageStatus.read,
r'error': MessageStatus.error,
};
extension LastMessageModelQueryFilter extension LastMessageModelQueryFilter
on QueryBuilder<LastMessageModel, LastMessageModel, QFilterCondition> { on QueryBuilder<LastMessageModel, LastMessageModel, QFilterCondition> {
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition> QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
@@ -2212,6 +2249,198 @@ extension LastMessageModelQueryFilter
)); ));
}); });
} }
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
sequenceIdEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
sequenceIdGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
sequenceIdLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
sequenceIdBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'sequenceId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusEqualTo(
MessageStatus value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusGreaterThan(
MessageStatus value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusLessThan(
MessageStatus value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusBetween(
MessageStatus lower,
MessageStatus upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'status',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'status',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'status',
value: '',
));
});
}
QueryBuilder<LastMessageModel, LastMessageModel, QAfterFilterCondition>
statusIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'status',
value: '',
));
});
}
} }
extension LastMessageModelQueryObject extension LastMessageModelQueryObject
@@ -16,12 +16,15 @@ class MessageModel {
late String senderId; late String senderId;
late String content; late String content;
late String messageType; late String messageType;
late int sequenceId;
@Index() @Index()
DateTime? createdAt; DateTime? createdAt;
DateTime? updatedAt; DateTime? updatedAt;
bool isRead = false;
@Enumerated(EnumType.name)
MessageStatus status = MessageStatus.delivered;
// For simplicity, store media as a JSON string or ignore for now // For simplicity, store media as a JSON string or ignore for now
String? mediaJson; String? mediaJson;
@@ -33,9 +36,10 @@ class MessageModel {
senderId: senderId, senderId: senderId,
content: content, content: content,
messageType: messageType, messageType: messageType,
sequenceId: sequenceId,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
isRead: isRead, status: status,
); );
} }
@@ -46,8 +50,9 @@ class MessageModel {
..senderId = message.senderId ..senderId = message.senderId
..content = message.content ..content = message.content
..messageType = message.messageType ..messageType = message.messageType
..sequenceId = message.sequenceId
..createdAt = message.createdAt ..createdAt = message.createdAt
..updatedAt = message.updatedAt ..updatedAt = message.updatedAt
..isRead = message.isRead ?? false; ..status = message.status;
} }
} }
@@ -37,28 +37,34 @@ const MessageModelSchema = CollectionSchema(
name: r'id', name: r'id',
type: IsarType.string, type: IsarType.string,
), ),
r'isRead': PropertySchema(
id: 4,
name: r'isRead',
type: IsarType.bool,
),
r'mediaJson': PropertySchema( r'mediaJson': PropertySchema(
id: 5, id: 4,
name: r'mediaJson', name: r'mediaJson',
type: IsarType.string, type: IsarType.string,
), ),
r'messageType': PropertySchema( r'messageType': PropertySchema(
id: 6, id: 5,
name: r'messageType', name: r'messageType',
type: IsarType.string, type: IsarType.string,
), ),
r'senderId': PropertySchema( r'senderId': PropertySchema(
id: 7, id: 6,
name: r'senderId', name: r'senderId',
type: IsarType.string, type: IsarType.string,
), ),
r'updatedAt': PropertySchema( r'sequenceId': PropertySchema(
id: 7,
name: r'sequenceId',
type: IsarType.long,
),
r'status': PropertySchema(
id: 8, id: 8,
name: r'status',
type: IsarType.string,
enumMap: _MessageModelstatusEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 9,
name: r'updatedAt', name: r'updatedAt',
type: IsarType.dateTime, type: IsarType.dateTime,
) )
@@ -134,6 +140,7 @@ int _messageModelEstimateSize(
} }
bytesCount += 3 + object.messageType.length * 3; bytesCount += 3 + object.messageType.length * 3;
bytesCount += 3 + object.senderId.length * 3; bytesCount += 3 + object.senderId.length * 3;
bytesCount += 3 + object.status.name.length * 3;
return bytesCount; return bytesCount;
} }
@@ -147,11 +154,12 @@ void _messageModelSerialize(
writer.writeString(offsets[1], object.content); writer.writeString(offsets[1], object.content);
writer.writeDateTime(offsets[2], object.createdAt); writer.writeDateTime(offsets[2], object.createdAt);
writer.writeString(offsets[3], object.id); writer.writeString(offsets[3], object.id);
writer.writeBool(offsets[4], object.isRead); writer.writeString(offsets[4], object.mediaJson);
writer.writeString(offsets[5], object.mediaJson); writer.writeString(offsets[5], object.messageType);
writer.writeString(offsets[6], object.messageType); writer.writeString(offsets[6], object.senderId);
writer.writeString(offsets[7], object.senderId); writer.writeLong(offsets[7], object.sequenceId);
writer.writeDateTime(offsets[8], object.updatedAt); writer.writeString(offsets[8], object.status.name);
writer.writeDateTime(offsets[9], object.updatedAt);
} }
MessageModel _messageModelDeserialize( MessageModel _messageModelDeserialize(
@@ -165,12 +173,15 @@ MessageModel _messageModelDeserialize(
object.content = reader.readString(offsets[1]); object.content = reader.readString(offsets[1]);
object.createdAt = reader.readDateTimeOrNull(offsets[2]); object.createdAt = reader.readDateTimeOrNull(offsets[2]);
object.id = reader.readString(offsets[3]); object.id = reader.readString(offsets[3]);
object.isRead = reader.readBool(offsets[4]);
object.isarId = id; object.isarId = id;
object.mediaJson = reader.readStringOrNull(offsets[5]); object.mediaJson = reader.readStringOrNull(offsets[4]);
object.messageType = reader.readString(offsets[6]); object.messageType = reader.readString(offsets[5]);
object.senderId = reader.readString(offsets[7]); object.senderId = reader.readString(offsets[6]);
object.updatedAt = reader.readDateTimeOrNull(offsets[8]); object.sequenceId = reader.readLong(offsets[7]);
object.status =
_MessageModelstatusValueEnumMap[reader.readStringOrNull(offsets[8])] ??
MessageStatus.sending;
object.updatedAt = reader.readDateTimeOrNull(offsets[9]);
return object; return object;
} }
@@ -190,20 +201,37 @@ P _messageModelDeserializeProp<P>(
case 3: case 3:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
case 4: case 4:
return (reader.readBool(offset)) as P;
case 5:
return (reader.readStringOrNull(offset)) as P; return (reader.readStringOrNull(offset)) as P;
case 5:
return (reader.readString(offset)) as P;
case 6: case 6:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
case 7: case 7:
return (reader.readString(offset)) as P; return (reader.readLong(offset)) as P;
case 8: case 8:
return (_MessageModelstatusValueEnumMap[
reader.readStringOrNull(offset)] ??
MessageStatus.sending) as P;
case 9:
return (reader.readDateTimeOrNull(offset)) as P; return (reader.readDateTimeOrNull(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); throw IsarError('Unknown property with id $propertyId');
} }
} }
const _MessageModelstatusEnumValueMap = {
r'sending': r'sending',
r'delivered': r'delivered',
r'read': r'read',
r'error': r'error',
};
const _MessageModelstatusValueEnumMap = {
r'sending': MessageStatus.sending,
r'delivered': MessageStatus.delivered,
r'read': MessageStatus.read,
r'error': MessageStatus.error,
};
Id _messageModelGetId(MessageModel object) { Id _messageModelGetId(MessageModel object) {
return object.isarId; return object.isarId;
} }
@@ -1041,16 +1069,6 @@ extension MessageModelQueryFilter
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> isReadEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isRead',
value: value,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> isarIdEqualTo( QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> isarIdEqualTo(
Id value) { Id value) {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
@@ -1532,6 +1550,197 @@ extension MessageModelQueryFilter
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
sequenceIdEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
sequenceIdGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
sequenceIdLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'sequenceId',
value: value,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
sequenceIdBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'sequenceId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> statusEqualTo(
MessageStatus value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusGreaterThan(
MessageStatus value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusLessThan(
MessageStatus value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> statusBetween(
MessageStatus lower,
MessageStatus upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'status',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'status',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> statusMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'status',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'status',
value: '',
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
statusIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'status',
value: '',
));
});
}
QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition> QueryBuilder<MessageModel, MessageModel, QAfterFilterCondition>
updatedAtIsNull() { updatedAtIsNull() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
@@ -1663,18 +1872,6 @@ extension MessageModelQuerySortBy
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByIsRead() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isRead', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByIsReadDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isRead', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByMediaJson() { QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByMediaJson() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'mediaJson', Sort.asc); return query.addSortBy(r'mediaJson', Sort.asc);
@@ -1712,6 +1909,31 @@ extension MessageModelQuerySortBy
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortBySequenceId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'sequenceId', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy>
sortBySequenceIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'sequenceId', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByStatus() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'status', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByStatusDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'status', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByUpdatedAt() { QueryBuilder<MessageModel, MessageModel, QAfterSortBy> sortByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc); return query.addSortBy(r'updatedAt', Sort.asc);
@@ -1775,18 +1997,6 @@ extension MessageModelQuerySortThenBy
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByIsRead() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isRead', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByIsReadDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isRead', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByIsarId() { QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByIsarId() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isarId', Sort.asc); return query.addSortBy(r'isarId', Sort.asc);
@@ -1836,6 +2046,31 @@ extension MessageModelQuerySortThenBy
}); });
} }
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenBySequenceId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'sequenceId', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy>
thenBySequenceIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'sequenceId', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByStatus() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'status', Sort.asc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByStatusDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'status', Sort.desc);
});
}
QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByUpdatedAt() { QueryBuilder<MessageModel, MessageModel, QAfterSortBy> thenByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc); return query.addSortBy(r'updatedAt', Sort.asc);
@@ -1878,12 +2113,6 @@ extension MessageModelQueryWhereDistinct
}); });
} }
QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByIsRead() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'isRead');
});
}
QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByMediaJson( QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByMediaJson(
{bool caseSensitive = true}) { {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
@@ -1905,6 +2134,19 @@ extension MessageModelQueryWhereDistinct
}); });
} }
QueryBuilder<MessageModel, MessageModel, QDistinct> distinctBySequenceId() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'sequenceId');
});
}
QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByStatus(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'status', caseSensitive: caseSensitive);
});
}
QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByUpdatedAt() { QueryBuilder<MessageModel, MessageModel, QDistinct> distinctByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'updatedAt'); return query.addDistinctBy(r'updatedAt');
@@ -1944,12 +2186,6 @@ extension MessageModelQueryProperty
}); });
} }
QueryBuilder<MessageModel, bool, QQueryOperations> isReadProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isRead');
});
}
QueryBuilder<MessageModel, String?, QQueryOperations> mediaJsonProperty() { QueryBuilder<MessageModel, String?, QQueryOperations> mediaJsonProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'mediaJson'); return query.addPropertyName(r'mediaJson');
@@ -1968,6 +2204,18 @@ extension MessageModelQueryProperty
}); });
} }
QueryBuilder<MessageModel, int, QQueryOperations> sequenceIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'sequenceId');
});
}
QueryBuilder<MessageModel, MessageStatus, QQueryOperations> statusProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'status');
});
}
QueryBuilder<MessageModel, DateTime?, QQueryOperations> updatedAtProperty() { QueryBuilder<MessageModel, DateTime?, QQueryOperations> updatedAtProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'updatedAt'); return query.addPropertyName(r'updatedAt');
@@ -24,9 +24,19 @@ class ChatRepositoryImpl implements ChatRepository {
@override @override
Stream<Map<String, dynamic>> get typingStream => signalRService.typingUpdates; Stream<Map<String, dynamic>> get typingStream => signalRService.typingUpdates;
@override
Stream<Map<String, dynamic>> get statusStream => signalRService.statusUpdates;
@override
Stream<Map<String, dynamic>> get readReceiptStream => signalRService.readReceipts;
@override @override
Future<void> sendTypingStatus(String chatId, bool isTyping) => signalRService.sendTypingStatus(chatId, isTyping); Future<void> sendTypingStatus(String chatId, bool isTyping) => signalRService.sendTypingStatus(chatId, isTyping);
@override
Future<void> markAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) =>
signalRService.markMessagesAsRead(chatId, lastReadMessageId, lastReadSequenceId);
@override @override
Future<void> initSignalR() => signalRService.init(); Future<void> initSignalR() => signalRService.init();
@@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'message.freezed.dart'; part 'message.freezed.dart';
// part 'message.g.dart'; // part 'message.g.dart';
enum MessageStatus { sending, delivered, read, error }
@freezed @freezed
class Message with _$Message { class Message with _$Message {
const factory Message({ const factory Message({
@@ -10,12 +12,11 @@ class Message with _$Message {
required String chatId, required String chatId,
required String senderId, required String senderId,
required String content, required String content,
required String messageType, // 'text', 'image', 'video', 'audio', 'file' required String messageType,
required int sequenceId,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
bool? isRead, @Default(MessageStatus.delivered) MessageStatus status,
Map<String, dynamic>? media, Map<String, dynamic>? media,
}) = _Message; }) = _Message;
// factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(json);
} }
@@ -20,11 +20,11 @@ mixin _$Message {
String get chatId => throw _privateConstructorUsedError; String get chatId => throw _privateConstructorUsedError;
String get senderId => throw _privateConstructorUsedError; String get senderId => throw _privateConstructorUsedError;
String get content => throw _privateConstructorUsedError; String get content => throw _privateConstructorUsedError;
String get messageType => String get messageType => throw _privateConstructorUsedError;
throw _privateConstructorUsedError; // 'text', 'image', 'video', 'audio', 'file' int get sequenceId => throw _privateConstructorUsedError;
DateTime? get createdAt => throw _privateConstructorUsedError; DateTime? get createdAt => throw _privateConstructorUsedError;
DateTime? get updatedAt => throw _privateConstructorUsedError; DateTime? get updatedAt => throw _privateConstructorUsedError;
bool? get isRead => throw _privateConstructorUsedError; MessageStatus get status => throw _privateConstructorUsedError;
Map<String, dynamic>? get media => throw _privateConstructorUsedError; Map<String, dynamic>? get media => throw _privateConstructorUsedError;
@JsonKey(ignore: true) @JsonKey(ignore: true)
@@ -42,9 +42,10 @@ abstract class $MessageCopyWith<$Res> {
String senderId, String senderId,
String content, String content,
String messageType, String messageType,
int sequenceId,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
bool? isRead, MessageStatus status,
Map<String, dynamic>? media}); Map<String, dynamic>? media});
} }
@@ -66,9 +67,10 @@ class _$MessageCopyWithImpl<$Res, $Val extends Message>
Object? senderId = null, Object? senderId = null,
Object? content = null, Object? content = null,
Object? messageType = null, Object? messageType = null,
Object? sequenceId = null,
Object? createdAt = freezed, Object? createdAt = freezed,
Object? updatedAt = freezed, Object? updatedAt = freezed,
Object? isRead = freezed, Object? status = null,
Object? media = freezed, Object? media = freezed,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
@@ -92,6 +94,10 @@ class _$MessageCopyWithImpl<$Res, $Val extends Message>
? _value.messageType ? _value.messageType
: messageType // ignore: cast_nullable_to_non_nullable : messageType // ignore: cast_nullable_to_non_nullable
as String, as String,
sequenceId: null == sequenceId
? _value.sequenceId
: sequenceId // ignore: cast_nullable_to_non_nullable
as int,
createdAt: freezed == createdAt createdAt: freezed == createdAt
? _value.createdAt ? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
@@ -100,10 +106,10 @@ class _$MessageCopyWithImpl<$Res, $Val extends Message>
? _value.updatedAt ? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,
isRead: freezed == isRead status: null == status
? _value.isRead ? _value.status
: isRead // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as bool?, as MessageStatus,
media: freezed == media media: freezed == media
? _value.media ? _value.media
: media // ignore: cast_nullable_to_non_nullable : media // ignore: cast_nullable_to_non_nullable
@@ -125,9 +131,10 @@ abstract class _$$MessageImplCopyWith<$Res> implements $MessageCopyWith<$Res> {
String senderId, String senderId,
String content, String content,
String messageType, String messageType,
int sequenceId,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
bool? isRead, MessageStatus status,
Map<String, dynamic>? media}); Map<String, dynamic>? media});
} }
@@ -147,9 +154,10 @@ class __$$MessageImplCopyWithImpl<$Res>
Object? senderId = null, Object? senderId = null,
Object? content = null, Object? content = null,
Object? messageType = null, Object? messageType = null,
Object? sequenceId = null,
Object? createdAt = freezed, Object? createdAt = freezed,
Object? updatedAt = freezed, Object? updatedAt = freezed,
Object? isRead = freezed, Object? status = null,
Object? media = freezed, Object? media = freezed,
}) { }) {
return _then(_$MessageImpl( return _then(_$MessageImpl(
@@ -173,6 +181,10 @@ class __$$MessageImplCopyWithImpl<$Res>
? _value.messageType ? _value.messageType
: messageType // ignore: cast_nullable_to_non_nullable : messageType // ignore: cast_nullable_to_non_nullable
as String, as String,
sequenceId: null == sequenceId
? _value.sequenceId
: sequenceId // ignore: cast_nullable_to_non_nullable
as int,
createdAt: freezed == createdAt createdAt: freezed == createdAt
? _value.createdAt ? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
@@ -181,10 +193,10 @@ class __$$MessageImplCopyWithImpl<$Res>
? _value.updatedAt ? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,
isRead: freezed == isRead status: null == status
? _value.isRead ? _value.status
: isRead // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as bool?, as MessageStatus,
media: freezed == media media: freezed == media
? _value._media ? _value._media
: media // ignore: cast_nullable_to_non_nullable : media // ignore: cast_nullable_to_non_nullable
@@ -202,9 +214,10 @@ class _$MessageImpl implements _Message {
required this.senderId, required this.senderId,
required this.content, required this.content,
required this.messageType, required this.messageType,
required this.sequenceId,
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
this.isRead, this.status = MessageStatus.delivered,
final Map<String, dynamic>? media}) final Map<String, dynamic>? media})
: _media = media; : _media = media;
@@ -218,13 +231,15 @@ class _$MessageImpl implements _Message {
final String content; final String content;
@override @override
final String messageType; final String messageType;
// 'text', 'image', 'video', 'audio', 'file' @override
final int sequenceId;
@override @override
final DateTime? createdAt; final DateTime? createdAt;
@override @override
final DateTime? updatedAt; final DateTime? updatedAt;
@override @override
final bool? isRead; @JsonKey()
final MessageStatus status;
final Map<String, dynamic>? _media; final Map<String, dynamic>? _media;
@override @override
Map<String, dynamic>? get media { Map<String, dynamic>? get media {
@@ -237,7 +252,7 @@ class _$MessageImpl implements _Message {
@override @override
String toString() { String toString() {
return 'Message(id: $id, chatId: $chatId, senderId: $senderId, content: $content, messageType: $messageType, createdAt: $createdAt, updatedAt: $updatedAt, isRead: $isRead, media: $media)'; return 'Message(id: $id, chatId: $chatId, senderId: $senderId, content: $content, messageType: $messageType, sequenceId: $sequenceId, createdAt: $createdAt, updatedAt: $updatedAt, status: $status, media: $media)';
} }
@override @override
@@ -252,11 +267,13 @@ class _$MessageImpl implements _Message {
(identical(other.content, content) || other.content == content) && (identical(other.content, content) || other.content == content) &&
(identical(other.messageType, messageType) || (identical(other.messageType, messageType) ||
other.messageType == messageType) && other.messageType == messageType) &&
(identical(other.sequenceId, sequenceId) ||
other.sequenceId == sequenceId) &&
(identical(other.createdAt, createdAt) || (identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) && other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) || (identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) && other.updatedAt == updatedAt) &&
(identical(other.isRead, isRead) || other.isRead == isRead) && (identical(other.status, status) || other.status == status) &&
const DeepCollectionEquality().equals(other._media, _media)); const DeepCollectionEquality().equals(other._media, _media));
} }
@@ -268,9 +285,10 @@ class _$MessageImpl implements _Message {
senderId, senderId,
content, content,
messageType, messageType,
sequenceId,
createdAt, createdAt,
updatedAt, updatedAt,
isRead, status,
const DeepCollectionEquality().hash(_media)); const DeepCollectionEquality().hash(_media));
@JsonKey(ignore: true) @JsonKey(ignore: true)
@@ -287,9 +305,10 @@ abstract class _Message implements Message {
required final String senderId, required final String senderId,
required final String content, required final String content,
required final String messageType, required final String messageType,
required final int sequenceId,
final DateTime? createdAt, final DateTime? createdAt,
final DateTime? updatedAt, final DateTime? updatedAt,
final bool? isRead, final MessageStatus status,
final Map<String, dynamic>? media}) = _$MessageImpl; final Map<String, dynamic>? media}) = _$MessageImpl;
@override @override
@@ -302,12 +321,14 @@ abstract class _Message implements Message {
String get content; String get content;
@override @override
String get messageType; String get messageType;
@override // 'text', 'image', 'video', 'audio', 'file' @override
int get sequenceId;
@override
DateTime? get createdAt; DateTime? get createdAt;
@override @override
DateTime? get updatedAt; DateTime? get updatedAt;
@override @override
bool? get isRead; MessageStatus get status;
@override @override
Map<String, dynamic>? get media; Map<String, dynamic>? get media;
@override @override
@@ -16,7 +16,10 @@ abstract class ChatRepository {
Stream<Message> get messageStream; Stream<Message> get messageStream;
Stream<Map<String, dynamic>> get typingStream; Stream<Map<String, dynamic>> get typingStream;
Stream<Map<String, dynamic>> get statusStream;
Stream<Map<String, dynamic>> get readReceiptStream;
Future<void> sendTypingStatus(String chatId, bool isTyping); Future<void> sendTypingStatus(String chatId, bool isTyping);
Future<void> markAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId);
Future<void> initSignalR(); Future<void> initSignalR();
Future<void> disposeSignalR(); Future<void> disposeSignalR();
} }
@@ -10,6 +10,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ChatRepository chatRepository; final ChatRepository chatRepository;
StreamSubscription? _messageSubscription; StreamSubscription? _messageSubscription;
StreamSubscription? _typingSubscription; StreamSubscription? _typingSubscription;
StreamSubscription? _statusSubscription;
StreamSubscription? _readReceiptSubscription;
Timer? _refreshTimer;
Timer? _retryTimer;
String? _currentUserId;
final Set<String> _sendingIds = {};
ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) { ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) {
on<ChatEventStarted>(_onStarted); on<ChatEventStarted>(_onStarted);
@@ -20,7 +26,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
on<ChatEventMessageSent>(_onMessageSent); on<ChatEventMessageSent>(_onMessageSent);
on<ChatEventFavoritesRequested>(_onFavoritesRequested); on<ChatEventFavoritesRequested>(_onFavoritesRequested);
on<ChatEventTypingUpdated>(_onTypingUpdated); on<ChatEventTypingUpdated>(_onTypingUpdated);
on<ChatEventUserStatusChanged>(_onUserStatusChanged);
on<ChatEventRefreshPresence>(_onRefreshPresence);
on<ChatEventSendTypingStatus>(_onSendTypingStatus); on<ChatEventSendTypingStatus>(_onSendTypingStatus);
on<ChatEventMarkAsRead>(_onMarkAsRead);
on<ChatEventReadReceiptReceived>(_onReadReceiptReceived);
on<ChatEventMessageStatusUpdated>(_onMessageStatusUpdated);
on<ChatEventMessageReplaced>(_onMessageReplaced);
on<ChatEventCacheCleared>(_onCacheCleared); on<ChatEventCacheCleared>(_onCacheCleared);
_messageSubscription = chatRepository.messageStream.listen((message) { _messageSubscription = chatRepository.messageStream.listen((message) {
@@ -30,9 +42,37 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
_typingSubscription = chatRepository.typingStream.listen((data) { _typingSubscription = chatRepository.typingStream.listen((data) {
add(ChatEvent.typingUpdated(data['chatId'], data['userId'], data['isTyping'])); add(ChatEvent.typingUpdated(data['chatId'], data['userId'], data['isTyping']));
}); });
_statusSubscription = chatRepository.statusStream.listen((data) {
add(ChatEvent.userStatusChanged(
data['userId'],
data['isOnline'],
lastSeen: data['lastSeen'] != null ? DateTime.tryParse(data['lastSeen'].toString()) : null,
));
});
_readReceiptSubscription = chatRepository.readReceiptStream.listen((data) {
add(ChatEvent.readReceiptReceived(
chatId: data['chatId'],
userId: data['userId'],
lastReadMessageId: data['lastReadMessageId'],
lastReadSequenceId: int.tryParse(data['lastReadSequenceId'].toString()) ?? 0,
));
});
// Refresh presence status every minute to update "X minutes ago"
_refreshTimer = Timer.periodic(const Duration(minutes: 1), (_) {
add(const ChatEvent.refreshPresence());
});
// Retry sending messages every 10 seconds
_retryTimer = Timer.periodic(const Duration(seconds: 10), (_) {
_retryPendingMessages();
});
} }
Future<void> _onStarted(ChatEventStarted event, Emitter<ChatState> emit) async { Future<void> _onStarted(ChatEventStarted event, Emitter<ChatState> emit) async {
_currentUserId = event.userId;
// ignore: avoid_print // ignore: avoid_print
print('[DEBUG] ChatBloc._onStarted for user: ${event.userId}'); print('[DEBUG] ChatBloc._onStarted for user: ${event.userId}');
// Start SignalR in background // Start SignalR in background
@@ -129,12 +169,39 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
orElse: () => [], orElse: () => [],
); );
// Check for duplicates final List<Message> mutableList = List<Message>.from(currentMessages);
if (currentMessages.any((m) => m.id == message.id)) return;
final updatedMessages = List<Message>.from(currentMessages)..insert(0, message); // Check if this is a message from me that should replace a temp one
// Ensure sorting if needed (usually messages are already in order from stream) if (message.senderId == _currentUserId) {
// Look for a temp message with same content and type
final tempIdx = mutableList.indexWhere((m) =>
m.id.startsWith('temp_') &&
m.messageType.toLowerCase() == message.messageType.toLowerCase() &&
(m.content.trim() == message.content.trim() || (m.content.isEmpty && message.content.isEmpty)));
if (tempIdx != -1) {
// Replace temp with real one
// ignore: avoid_print
print('[DEBUG] Smart replacement (SignalR): replacing ${mutableList[tempIdx].id} with ${message.id} (Type: ${message.messageType})');
mutableList[tempIdx] = message;
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
return;
}
// If no temp message found, check if we already have this message by ID (prevent double add)
if (mutableList.any((m) => m.id == message.id)) {
// ignore: avoid_print
print('[DEBUG] Self-message already in list, skipping: ${message.id}');
return;
}
// ignore: avoid_print
print('[DEBUG] Self-message received but no matching temp found: ${message.id} (Content: "${message.content}", Type: ${message.messageType})');
} else {
// From someone else, check for duplicates
if (mutableList.any((m) => m.id == message.id)) return;
}
final updatedMessages = mutableList..insert(0, message);
emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers)); emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers));
} }
@@ -146,12 +213,80 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
} }
Future<void> _onMessageSent(ChatEventMessageSent event, Emitter<ChatState> emit) async { Future<void> _onMessageSent(ChatEventMessageSent event, Emitter<ChatState> emit) async {
final currentChat = state.maybeMap(
chatSelected: (s) => s.chat,
messagesLoaded: (s) => s.chat,
orElse: () => null,
);
if (currentChat == null) return;
final userId = _currentUserId ?? '';
final List<Message> currentMessages = state.maybeMap(
chatSelected: (s) => s.messages,
messagesLoaded: (s) => s.messages,
orElse: () => [],
);
// Create optimistic "sending" message
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final tempMessage = Message(
id: tempId,
chatId: event.chatId,
senderId: userId,
content: event.content,
messageType: 'text',
sequenceId: -1, // Temporary
createdAt: DateTime.now(),
status: MessageStatus.sending,
);
final updatedMessages = List<Message>.from(currentMessages)..insert(0, tempMessage);
emit(ChatState.messagesLoaded(currentChat, updatedMessages, typingUsers: state.typingUsers));
final result = await chatRepository.sendMessage(event.chatId, event.content); final result = await chatRepository.sendMessage(event.chatId, event.content);
result.fold( result.fold(
(error) => emit(ChatState.error(error.message ?? 'Failed to send message', typingUsers: state.typingUsers)), (error) {
(_) { // ignore: avoid_print
// Optimistically or via refresh print('[DEBUG] sendMessage failed for $tempId: ${error.toString()}');
add(ChatEvent.messagesRequested(event.chatId)); final isNetworkError = error.maybeMap(
network: (_) => true,
orElse: () => false,
);
if (isNetworkError) {
// Keep as sending, retry timer will pick it up
return;
}
final List<Message> currentList = state.maybeMap(
messagesLoaded: (s) => s.messages,
orElse: () => updatedMessages,
);
final List<Message> mutableList = List<Message>.from(currentList);
final idx = mutableList.indexWhere((m) => m.id == tempId);
if (idx != -1) {
mutableList[idx] = mutableList[idx].copyWith(status: MessageStatus.error);
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
}
},
(sentMessage) {
// Replace temp message with the actual one from server
// ignore: avoid_print
print('[DEBUG] sendMessage success for $tempId, replacing with ${sentMessage.id}');
final List<Message> currentList = state.maybeMap(
messagesLoaded: (s) => s.messages,
orElse: () => updatedMessages,
);
final List<Message> mutableList = List<Message>.from(currentList);
final idx = mutableList.indexWhere((m) => m.id == tempId);
if (idx != -1) {
mutableList[idx] = sentMessage;
emit(ChatState.messagesLoaded(currentChat, mutableList, typingUsers: state.typingUsers));
}
}, },
); );
} }
@@ -196,10 +331,98 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
); );
} }
Future<void> _onUserStatusChanged(ChatEventUserStatusChanged event, Emitter<ChatState> emit) async {
state.mapOrNull(
chatsLoaded: (s) {
final updatedChats = s.chats.map((chat) {
final participants = chat.participants;
if (participants != null && participants.any((p) => p.id == event.userId)) {
final updatedParticipants = participants.map((p) {
if (p.id == event.userId) {
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
}
return p;
}).toList();
return chat.copyWith(participants: updatedParticipants);
}
return chat;
}).toList();
emit(s.copyWith(chats: updatedChats));
},
chatSelected: (s) {
if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) {
final updatedParticipants = s.chat.participants!.map((p) {
if (p.id == event.userId) {
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
}
return p;
}).toList();
final updatedChat = s.chat.copyWith(participants: updatedParticipants);
emit(s.copyWith(chat: updatedChat));
}
},
messagesLoaded: (s) {
if (s.chat.participants?.any((p) => p.id == event.userId) ?? false) {
final updatedParticipants = s.chat.participants!.map((p) {
if (p.id == event.userId) {
return p.copyWith(isOnline: event.isOnline, lastSeen: event.lastSeen ?? p.lastSeen);
}
return p;
}).toList();
final updatedChat = s.chat.copyWith(participants: updatedParticipants);
emit(s.copyWith(chat: updatedChat));
}
},
);
}
Future<void> _onRefreshPresence(ChatEventRefreshPresence event, Emitter<ChatState> emit) async {
// Re-emit the current state with a new timestamp to trigger UI rebuild
state.mapOrNull(
initial: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
loading: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
chatsLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
chatSelected: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
messagesLoaded: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
error: (s) => emit(s.copyWith(lastUpdate: DateTime.now())),
);
}
Future<void> _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter<ChatState> emit) async { Future<void> _onSendTypingStatus(ChatEventSendTypingStatus event, Emitter<ChatState> emit) async {
await chatRepository.sendTypingStatus(event.chatId, event.isTyping); await chatRepository.sendTypingStatus(event.chatId, event.isTyping);
} }
Future<void> _onMarkAsRead(ChatEventMarkAsRead event, Emitter<ChatState> emit) async {
await chatRepository.markAsRead(event.chatId, event.messageId, event.sequenceId);
}
Future<void> _onReadReceiptReceived(ChatEventReadReceiptReceived event, Emitter<ChatState> emit) async {
state.mapOrNull(
messagesLoaded: (s) {
if (s.chat.id == event.chatId) {
final updatedMessages = s.messages.map((m) {
if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) {
return m.copyWith(status: MessageStatus.read);
}
return m;
}).toList();
emit(s.copyWith(messages: updatedMessages));
}
},
chatSelected: (s) {
if (s.chat.id == event.chatId) {
final updatedMessages = s.messages.map((m) {
if (m.sequenceId <= event.lastReadSequenceId && m.status != MessageStatus.read) {
return m.copyWith(status: MessageStatus.read);
}
return m;
}).toList();
emit(s.copyWith(messages: updatedMessages));
}
},
);
}
Future<void> _onCacheCleared(ChatEventCacheCleared event, Emitter<ChatState> emit) async { Future<void> _onCacheCleared(ChatEventCacheCleared event, Emitter<ChatState> emit) async {
// ignore: avoid_print // ignore: avoid_print
print('[DEBUG] ChatBloc._onCacheCleared START'); print('[DEBUG] ChatBloc._onCacheCleared START');
@@ -209,10 +432,83 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(const ChatState.initial()); emit(const ChatState.initial());
} }
void _retryPendingMessages() {
state.mapOrNull(
messagesLoaded: (s) {
final pending = s.messages.where((m) => m.status == MessageStatus.sending && m.id.startsWith('temp_')).toList();
for (final msg in pending) {
if (!_sendingIds.contains(msg.id)) {
_retrySendMessage(msg);
}
}
},
);
}
Future<void> _retrySendMessage(Message tempMessage) async {
_sendingIds.add(tempMessage.id);
final result = await chatRepository.sendMessage(tempMessage.chatId, tempMessage.content);
_sendingIds.remove(tempMessage.id);
result.fold(
(error) {
final isNetworkError = error.maybeMap(
network: (_) => true,
orElse: () => false,
);
if (!isNetworkError) {
_updateMessageStatus(tempMessage.id, MessageStatus.error);
}
},
(sentMessage) {
_replaceTempMessage(tempMessage.id, sentMessage);
},
);
}
void _updateMessageStatus(String id, MessageStatus status) {
add(ChatEvent.messageStatusUpdated(id, status));
}
void _replaceTempMessage(String tempId, Message realMessage) {
add(ChatEvent.messageReplaced(tempId, realMessage));
}
Future<void> _onMessageStatusUpdated(ChatEventMessageStatusUpdated event, Emitter<ChatState> emit) async {
state.mapOrNull(
messagesLoaded: (s) {
final List<Message> mutableList = List<Message>.from(s.messages);
final idx = mutableList.indexWhere((m) => m.id == event.messageId);
if (idx != -1) {
mutableList[idx] = mutableList[idx].copyWith(status: event.status);
emit(s.copyWith(messages: mutableList));
}
},
);
}
Future<void> _onMessageReplaced(ChatEventMessageReplaced event, Emitter<ChatState> emit) async {
state.mapOrNull(
messagesLoaded: (s) {
final List<Message> mutableList = List<Message>.from(s.messages);
final idx = mutableList.indexWhere((m) => m.id == event.tempId);
if (idx != -1) {
mutableList[idx] = event.realMessage;
emit(s.copyWith(messages: mutableList));
}
},
);
}
@override @override
Future<void> close() { Future<void> close() {
_messageSubscription?.cancel(); _messageSubscription?.cancel();
_typingSubscription?.cancel(); _typingSubscription?.cancel();
_statusSubscription?.cancel();
_readReceiptSubscription?.cancel();
_refreshTimer?.cancel();
_retryTimer?.cancel();
chatRepository.disposeSignalR(); chatRepository.disposeSignalR();
return super.close(); return super.close();
} }
@@ -13,6 +13,17 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.messageSent(String chatId, String content) = ChatEventMessageSent; const factory ChatEvent.messageSent(String chatId, String content) = ChatEventMessageSent;
const factory ChatEvent.favoritesRequested() = ChatEventFavoritesRequested; const factory ChatEvent.favoritesRequested() = ChatEventFavoritesRequested;
const factory ChatEvent.typingUpdated(String chatId, String userId, bool isTyping) = ChatEventTypingUpdated; const factory ChatEvent.typingUpdated(String chatId, String userId, bool isTyping) = ChatEventTypingUpdated;
const factory ChatEvent.userStatusChanged(String userId, bool isOnline, {DateTime? lastSeen}) = ChatEventUserStatusChanged;
const factory ChatEvent.refreshPresence() = ChatEventRefreshPresence;
const factory ChatEvent.sendTypingStatus(String chatId, bool isTyping) = ChatEventSendTypingStatus; const factory ChatEvent.sendTypingStatus(String chatId, bool isTyping) = ChatEventSendTypingStatus;
const factory ChatEvent.markAsRead(String chatId, String messageId, int sequenceId) = ChatEventMarkAsRead;
const factory ChatEvent.readReceiptReceived({
required String chatId,
required String userId,
required String lastReadMessageId,
required int lastReadSequenceId,
}) = ChatEventReadReceiptReceived;
const factory ChatEvent.messageStatusUpdated(String messageId, MessageStatus status) = ChatEventMessageStatusUpdated;
const factory ChatEvent.messageReplaced(String tempId, Message realMessage) = ChatEventMessageReplaced;
const factory ChatEvent.cacheCleared() = ChatEventCacheCleared; const factory ChatEvent.cacheCleared() = ChatEventCacheCleared;
} }
File diff suppressed because it is too large Load Diff
@@ -6,10 +6,10 @@ part 'chat_state.freezed.dart';
@freezed @freezed
class ChatState with _$ChatState { class ChatState with _$ChatState {
const factory ChatState.initial({@Default({}) Map<String, Set<String>> typingUsers}) = ChatInitial; const factory ChatState.initial({@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatInitial;
const factory ChatState.loading({@Default({}) Map<String, Set<String>> typingUsers}) = ChatLoading; const factory ChatState.loading({@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatLoading;
const factory ChatState.chatsLoaded(List<Chat> chats, {@Default({}) Map<String, Set<String>> typingUsers}) = _ChatsLoaded; const factory ChatState.chatsLoaded(List<Chat> chats, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _ChatsLoaded;
const factory ChatState.chatSelected(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers}) = _ChatSelected; const factory ChatState.chatSelected(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _ChatSelected;
const factory ChatState.messagesLoaded(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers}) = _MessagesLoaded; const factory ChatState.messagesLoaded(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = _MessagesLoaded;
const factory ChatState.error(String message, {@Default({}) Map<String, Set<String>> typingUsers}) = ChatError; const factory ChatState.error(String message, {@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatError;
} }
File diff suppressed because it is too large Load Diff
@@ -41,8 +41,8 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
title: BlocBuilder<ChatBloc, ChatState>( title: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) { builder: (context, state) {
final currentChat = state.maybeWhen( final currentChat = state.maybeWhen(
chatSelected: (chat, _, __) => chat, chatSelected: (chat, messages, typingUsers, lastUpdate) => chat,
messagesLoaded: (chat, _, __) => chat, messagesLoaded: (chat, messages, typingUsers, lastUpdate) => chat,
orElse: () => widget.chat, orElse: () => widget.chat,
); );
return Column( return Column(
@@ -65,10 +65,10 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
child: BlocBuilder<ChatBloc, ChatState>( child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) { builder: (context, state) {
return state.maybeWhen( return state.maybeWhen(
chatSelected: (_, messages, __) => _buildMessageList(messages), chatSelected: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
messagesLoaded: (_, messages, __) => _buildMessageList(messages), messagesLoaded: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
loading: (_) => const Center(child: CircularProgressIndicator()), loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
error: (msg, _) => Center(child: Text('Ошибка: $msg')), error: (msg, typingUsers, lastUpdate) => Center(child: Text('Ошибка: $msg')),
orElse: () => const Center(child: Text('Начните общение')), orElse: () => const Center(child: Text('Начните общение')),
); );
}, },
@@ -124,17 +124,46 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
], ],
); );
} else if (otherMember.lastSeen != null) { } else if (otherMember.lastSeen != null) {
final lastSeen = otherMember.lastSeen!.toLocal();
final timeStr = DateFormat.Hm(locale).format(lastSeen);
final dateStr = _formatDate(lastSeen, locale);
return Text( return Text(
l10n.lastSeen(dateStr, timeStr), _formatLastSeen(otherMember.lastSeen!, l10n, locale),
style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal),
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
String _formatLastSeen(DateTime utcDate, AppLocalizations l10n, String locale) {
final lastSeen = utcDate.toLocal();
final now = DateTime.now();
final difference = now.difference(lastSeen);
final timeStr = DateFormat.Hm(locale).format(lastSeen);
// Case 1: Same day (within 24h of NOW, but specifically checking calendar day is better for "yesterday")
// Let's use difference for "relative" and calendar days for "yesterday/day before"
final today = DateTime(now.year, now.month, now.day);
final seenDay = DateTime(lastSeen.year, lastSeen.month, lastSeen.day);
final dayDiff = today.difference(seenDay).inDays;
if (dayDiff == 0) {
// Same calendar day
if (difference.inHours > 0) {
return l10n.lastSeenRelative(difference.inHours, difference.inMinutes % 60);
} else if (difference.inMinutes > 0) {
return l10n.lastSeenRelativeMinutes(difference.inMinutes);
} else {
return l10n.lastSeenJustNow;
}
} else if (dayDiff == 1) {
return l10n.lastSeenYesterday(timeStr);
} else if (dayDiff == 2) {
return l10n.lastSeenDayBeforeYesterday(timeStr);
} else {
final dateStr = DateFormat.MMMMd(locale).format(lastSeen);
return l10n.lastSeen(dateStr, timeStr);
}
}
String _formatDate(DateTime date, String locale) { String _formatDate(DateTime date, String locale) {
final now = DateTime.now(); final now = DateTime.now();
final localDate = date.toLocal(); final localDate = date.toLocal();
@@ -152,6 +181,20 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
return Center(child: Text(l10n.noMessages)); return Center(child: Text(l10n.noMessages));
} }
// Mark as read logic
WidgetsBinding.instance.addPostFrameCallback((_) {
final unreadFromOthers = messages.where((m) =>
m.senderId != widget.currentUserId &&
m.status != MessageStatus.read
).toList();
if (unreadFromOthers.isNotEmpty) {
// Find the message with the highest sequenceId to mark everything up to it as read
final latest = unreadFromOthers.reduce((a, b) => a.sequenceId > b.sequenceId ? a : b);
context.read<ChatBloc>().add(ChatEvent.markAsRead(latest.chatId, latest.id, latest.sequenceId));
}
});
final List<dynamic> items = []; final List<dynamic> items = [];
for (int i = 0; i < messages.length; i++) { for (int i = 0; i < messages.length; i++) {
final message = messages[i]; final message = messages[i];
@@ -233,6 +276,9 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
style: const TextStyle(color: Colors.black87, fontSize: 16), style: const TextStyle(color: Colors.black87, fontSize: 16),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (message.createdAt != null) if (message.createdAt != null)
Text( Text(
DateFormat.Hm(locale).format(message.createdAt!.toLocal()), DateFormat.Hm(locale).format(message.createdAt!.toLocal()),
@@ -241,6 +287,12 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
color: Colors.black45, color: Colors.black45,
), ),
), ),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(message),
],
],
),
], ],
), ),
), ),
@@ -249,6 +301,19 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
); );
} }
Widget _buildStatusIcon(Message message) {
switch (message.status) {
case MessageStatus.sending:
return const Icon(Icons.access_time, size: 12, color: Colors.black45);
case MessageStatus.delivered:
return const Icon(Icons.check, size: 12, color: Colors.black45);
case MessageStatus.read:
return const Icon(Icons.done_all, size: 12, color: Colors.blue);
case MessageStatus.error:
return const Icon(Icons.error_outline, size: 12, color: Colors.red);
}
}
Widget _buildMessageInput(Chat chat) { Widget _buildMessageInput(Chat chat) {
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -49,19 +49,12 @@ class _ChatsPageState extends State<ChatsPage> {
body: BlocConsumer<ChatBloc, ChatState>( body: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) { listener: (context, state) {
state.whenOrNull( state.whenOrNull(
chatSelected: (chat, _, __) { chatSelected: (chat, messages, typingUsers, lastUpdate) {
if (_isNavigating) return; if (_isNavigating) return;
// Extra safety: check if we are already on chat_detail // Only navigate if we are currently on the ChatsPage
bool isAlreadyOnDetail = false; final isCurrent = ModalRoute.of(context)?.isCurrent ?? false;
Navigator.popUntil(context, (route) { if (!isCurrent) return;
if (route.settings.name == 'chat_detail') {
isAlreadyOnDetail = true;
}
return true;
});
if (isAlreadyOnDetail) return;
_isNavigating = true; _isNavigating = true;
@@ -91,9 +84,9 @@ class _ChatsPageState extends State<ChatsPage> {
}, },
builder: (context, state) { builder: (context, state) {
return state.maybeWhen( return state.maybeWhen(
loading: (_) => const Center(child: CircularProgressIndicator()), loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
chatsLoaded: (chats, _) => _buildChatList(chats, l10n), chatsLoaded: (chats, typingUsers, lastUpdate) => _buildChatList(chats, l10n),
error: (message, _) => Center(child: Text('${l10n.error}: $message')), error: (message, typingUsers, lastUpdate) => Center(child: Text('${l10n.error}: $message')),
orElse: () => const Center(child: CircularProgressIndicator()), orElse: () => const Center(child: CircularProgressIndicator()),
); );
}, },
@@ -596,6 +596,36 @@ abstract class AppLocalizations {
/// **'был(а) в сети {date} в {time}'** /// **'был(а) в сети {date} в {time}'**
String lastSeen(Object date, Object time); String lastSeen(Object date, Object time);
/// No description provided for @lastSeenRelative.
///
/// In ru, this message translates to:
/// **'был(а) {hours} ч. {minutes} мин. назад'**
String lastSeenRelative(Object hours, Object minutes);
/// No description provided for @lastSeenRelativeMinutes.
///
/// In ru, this message translates to:
/// **'был(а) {minutes} мин. назад'**
String lastSeenRelativeMinutes(Object minutes);
/// No description provided for @lastSeenJustNow.
///
/// In ru, this message translates to:
/// **'был(а) только что'**
String get lastSeenJustNow;
/// No description provided for @lastSeenYesterday.
///
/// In ru, this message translates to:
/// **'был(а) вчера в {time}'**
String lastSeenYesterday(Object time);
/// No description provided for @lastSeenDayBeforeYesterday.
///
/// In ru, this message translates to:
/// **'был(а) позавчера в {time}'**
String lastSeenDayBeforeYesterday(Object time);
/// No description provided for @noMessages. /// No description provided for @noMessages.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
@@ -260,6 +260,29 @@ class AppLocalizationsEn extends AppLocalizations {
return 'last seen $date at $time'; return 'last seen $date at $time';
} }
@override
String lastSeenRelative(Object hours, Object minutes) {
return 'last seen ${hours}h ${minutes}m ago';
}
@override
String lastSeenRelativeMinutes(Object minutes) {
return 'last seen ${minutes}m ago';
}
@override
String get lastSeenJustNow => 'last seen just now';
@override
String lastSeenYesterday(Object time) {
return 'last seen yesterday at $time';
}
@override
String lastSeenDayBeforeYesterday(Object time) {
return 'last seen day before yesterday at $time';
}
@override @override
String get noMessages => 'No messages yet'; String get noMessages => 'No messages yet';
@@ -260,6 +260,29 @@ class AppLocalizationsRu extends AppLocalizations {
return 'был(а) в сети $date в $time'; return 'был(а) в сети $date в $time';
} }
@override
String lastSeenRelative(Object hours, Object minutes) {
return 'был(а) $hours ч. $minutes мин. назад';
}
@override
String lastSeenRelativeMinutes(Object minutes) {
return 'был(а) $minutes мин. назад';
}
@override
String get lastSeenJustNow => 'был(а) только что';
@override
String lastSeenYesterday(Object time) {
return 'был(а) вчера в $time';
}
@override
String lastSeenDayBeforeYesterday(Object time) {
return 'был(а) позавчера в $time';
}
@override @override
String get noMessages => 'Сообщений пока нет'; String get noMessages => 'Сообщений пока нет';