Получение и отправка текста, статусы сообщения
This commit is contained in:
@@ -10,6 +10,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
final ChatRepository chatRepository;
|
||||
StreamSubscription? _messageSubscription;
|
||||
StreamSubscription? _typingSubscription;
|
||||
StreamSubscription? _statusSubscription;
|
||||
StreamSubscription? _readReceiptSubscription;
|
||||
Timer? _refreshTimer;
|
||||
Timer? _retryTimer;
|
||||
String? _currentUserId;
|
||||
final Set<String> _sendingIds = {};
|
||||
|
||||
ChatBloc({required this.chatRepository}) : super(const ChatState.initial()) {
|
||||
on<ChatEventStarted>(_onStarted);
|
||||
@@ -20,7 +26,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
on<ChatEventMessageSent>(_onMessageSent);
|
||||
on<ChatEventFavoritesRequested>(_onFavoritesRequested);
|
||||
on<ChatEventTypingUpdated>(_onTypingUpdated);
|
||||
on<ChatEventUserStatusChanged>(_onUserStatusChanged);
|
||||
on<ChatEventRefreshPresence>(_onRefreshPresence);
|
||||
on<ChatEventSendTypingStatus>(_onSendTypingStatus);
|
||||
on<ChatEventMarkAsRead>(_onMarkAsRead);
|
||||
on<ChatEventReadReceiptReceived>(_onReadReceiptReceived);
|
||||
on<ChatEventMessageStatusUpdated>(_onMessageStatusUpdated);
|
||||
on<ChatEventMessageReplaced>(_onMessageReplaced);
|
||||
on<ChatEventCacheCleared>(_onCacheCleared);
|
||||
|
||||
_messageSubscription = chatRepository.messageStream.listen((message) {
|
||||
@@ -30,9 +42,37 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
_typingSubscription = chatRepository.typingStream.listen((data) {
|
||||
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 {
|
||||
_currentUserId = event.userId;
|
||||
// ignore: avoid_print
|
||||
print('[DEBUG] ChatBloc._onStarted for user: ${event.userId}');
|
||||
// Start SignalR in background
|
||||
@@ -129,12 +169,39 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
orElse: () => [],
|
||||
);
|
||||
|
||||
// Check for duplicates
|
||||
if (currentMessages.any((m) => m.id == message.id)) return;
|
||||
final List<Message> mutableList = List<Message>.from(currentMessages);
|
||||
|
||||
final updatedMessages = List<Message>.from(currentMessages)..insert(0, message);
|
||||
// Ensure sorting if needed (usually messages are already in order from stream)
|
||||
|
||||
// Check if this is a message from me that should replace a temp one
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -146,12 +213,80 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
result.fold(
|
||||
(error) => emit(ChatState.error(error.message ?? 'Failed to send message', typingUsers: state.typingUsers)),
|
||||
(_) {
|
||||
// Optimistically or via refresh
|
||||
add(ChatEvent.messagesRequested(event.chatId));
|
||||
(error) {
|
||||
// ignore: avoid_print
|
||||
print('[DEBUG] sendMessage failed for $tempId: ${error.toString()}');
|
||||
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 {
|
||||
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 {
|
||||
// ignore: avoid_print
|
||||
print('[DEBUG] ChatBloc._onCacheCleared START');
|
||||
@@ -209,10 +432,83 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
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
|
||||
Future<void> close() {
|
||||
_messageSubscription?.cancel();
|
||||
_typingSubscription?.cancel();
|
||||
_statusSubscription?.cancel();
|
||||
_readReceiptSubscription?.cancel();
|
||||
_refreshTimer?.cancel();
|
||||
_retryTimer?.cancel();
|
||||
chatRepository.disposeSignalR();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.messageSent(String chatId, String content) = ChatEventMessageSent;
|
||||
const factory ChatEvent.favoritesRequested() = ChatEventFavoritesRequested;
|
||||
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.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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,10 @@ part 'chat_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState.initial({@Default({}) Map<String, Set<String>> typingUsers}) = ChatInitial;
|
||||
const factory ChatState.loading({@Default({}) Map<String, Set<String>> typingUsers}) = ChatLoading;
|
||||
const factory ChatState.chatsLoaded(List<Chat> chats, {@Default({}) Map<String, Set<String>> typingUsers}) = _ChatsLoaded;
|
||||
const factory ChatState.chatSelected(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers}) = _ChatSelected;
|
||||
const factory ChatState.messagesLoaded(Chat chat, List<Message> messages, {@Default({}) Map<String, Set<String>> typingUsers}) = _MessagesLoaded;
|
||||
const factory ChatState.error(String message, {@Default({}) Map<String, Set<String>> typingUsers}) = ChatError;
|
||||
const factory ChatState.initial({@Default({}) Map<String, Set<String>> typingUsers, DateTime? lastUpdate}) = ChatInitial;
|
||||
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, DateTime? lastUpdate}) = _ChatsLoaded;
|
||||
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, DateTime? lastUpdate}) = _MessagesLoaded;
|
||||
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>(
|
||||
builder: (context, state) {
|
||||
final currentChat = state.maybeWhen(
|
||||
chatSelected: (chat, _, __) => chat,
|
||||
messagesLoaded: (chat, _, __) => chat,
|
||||
chatSelected: (chat, messages, typingUsers, lastUpdate) => chat,
|
||||
messagesLoaded: (chat, messages, typingUsers, lastUpdate) => chat,
|
||||
orElse: () => widget.chat,
|
||||
);
|
||||
return Column(
|
||||
@@ -65,10 +65,10 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeWhen(
|
||||
chatSelected: (_, messages, __) => _buildMessageList(messages),
|
||||
messagesLoaded: (_, messages, __) => _buildMessageList(messages),
|
||||
loading: (_) => const Center(child: CircularProgressIndicator()),
|
||||
error: (msg, _) => Center(child: Text('Ошибка: $msg')),
|
||||
chatSelected: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
|
||||
messagesLoaded: (chat, messages, typingUsers, lastUpdate) => _buildMessageList(messages),
|
||||
loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
|
||||
error: (msg, typingUsers, lastUpdate) => Center(child: Text('Ошибка: $msg')),
|
||||
orElse: () => const Center(child: Text('Начните общение')),
|
||||
);
|
||||
},
|
||||
@@ -124,17 +124,46 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
],
|
||||
);
|
||||
} else if (otherMember.lastSeen != null) {
|
||||
final lastSeen = otherMember.lastSeen!.toLocal();
|
||||
final timeStr = DateFormat.Hm(locale).format(lastSeen);
|
||||
final dateStr = _formatDate(lastSeen, locale);
|
||||
return Text(
|
||||
l10n.lastSeen(dateStr, timeStr),
|
||||
_formatLastSeen(otherMember.lastSeen!, l10n, locale),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white70, fontWeight: FontWeight.normal),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
final now = DateTime.now();
|
||||
final localDate = date.toLocal();
|
||||
@@ -152,6 +181,20 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
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 = [];
|
||||
for (int i = 0; i < messages.length; i++) {
|
||||
final message = messages[i];
|
||||
@@ -233,14 +276,23 @@ class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (message.createdAt != null)
|
||||
Text(
|
||||
DateFormat.Hm(locale).format(message.createdAt!.toLocal()),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (message.createdAt != null)
|
||||
Text(
|
||||
DateFormat.Hm(locale).format(message.createdAt!.toLocal()),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
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) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
|
||||
@@ -49,20 +49,13 @@ class _ChatsPageState extends State<ChatsPage> {
|
||||
body: BlocConsumer<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
state.whenOrNull(
|
||||
chatSelected: (chat, _, __) {
|
||||
chatSelected: (chat, messages, typingUsers, lastUpdate) {
|
||||
if (_isNavigating) return;
|
||||
|
||||
// Extra safety: check if we are already on chat_detail
|
||||
bool isAlreadyOnDetail = false;
|
||||
Navigator.popUntil(context, (route) {
|
||||
if (route.settings.name == 'chat_detail') {
|
||||
isAlreadyOnDetail = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (isAlreadyOnDetail) return;
|
||||
|
||||
// Only navigate if we are currently on the ChatsPage
|
||||
final isCurrent = ModalRoute.of(context)?.isCurrent ?? false;
|
||||
if (!isCurrent) return;
|
||||
|
||||
_isNavigating = true;
|
||||
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
@@ -91,9 +84,9 @@ class _ChatsPageState extends State<ChatsPage> {
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.maybeWhen(
|
||||
loading: (_) => const Center(child: CircularProgressIndicator()),
|
||||
chatsLoaded: (chats, _) => _buildChatList(chats, l10n),
|
||||
error: (message, _) => Center(child: Text('${l10n.error}: $message')),
|
||||
loading: (typingUsers, lastUpdate) => const Center(child: CircularProgressIndicator()),
|
||||
chatsLoaded: (chats, typingUsers, lastUpdate) => _buildChatList(chats, l10n),
|
||||
error: (message, typingUsers, lastUpdate) => Center(child: Text('${l10n.error}: $message')),
|
||||
orElse: () => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user