From ff8a4c1adec67ac0f3f103d1cc4146f5ab8ee22f Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Wed, 15 Apr 2026 17:46:40 +0200 Subject: [PATCH] feat: users in chats list, avatars and nicknames, users count in chats --- lib/backend/modules/chats.dart | 249 ++++++++++-------- lib/core/storage/app_database.dart | 41 ++- .../screens/chats/chat_list_screen.dart | 114 +++++--- lib/frontend/screens/chats/chat_screen.dart | 16 +- lib/frontend/widgets/message_bubble.dart | 128 ++++++--- 5 files changed, 363 insertions(+), 185 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f8c81d6..3b0fb27 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; class CachedChat { final int id; @@ -17,6 +20,7 @@ class CachedChat { final int dontDisturbUntil; final bool isOnline; final int seenTime; + final Map participants; const CachedChat({ required this.id, @@ -35,6 +39,7 @@ class CachedChat { required this.dontDisturbUntil, required this.isOnline, required this.seenTime, + required this.participants, }); factory CachedChat.fromDbRow(Map row) => CachedChat( @@ -54,6 +59,8 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, + // watafuc + participants: Map.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) ); Map toDbRow() => { @@ -73,6 +80,7 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, + 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))) }; } @@ -87,51 +95,72 @@ class ChatsModule { int accountId, int currentUserId, ) async { - final chats = data['chats']; - if (chats is! List || chats.isEmpty) return; + try { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; - final contactsMap = _buildContactsMap(data['contacts']); - // Config contains mute setup and fav indexes: config -> chats -> id - final configMap = data['config'] is Map ? data['config'] as Map : {}; - final chatsConfig = configMap['chats'] is Map - ? configMap['chats'] as Map - : {}; - // Presence for online statuses - final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; - final cachedAt = DateTime.now().millisecondsSinceEpoch; + final contactsMap = _buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + // Presence for online statuses + final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; + final cachedAt = DateTime.now().millisecondsSinceEpoch; - final existingRows = await AppDatabase.loadChats(accountId); - final existing = { - for (final row in existingRows) - row['id'] as int: CachedChat.fromDbRow(row), - }; + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; - final rows = chats - .whereType() - .map( - (c) => _parseChat( - c.cast(), - accountId, - currentUserId, - contactsMap, - chatsConfig, - presenceMap, - existing, - cachedAt, - ), - ) - .whereType() - .map((c) => c.toDbRow()) - .toList(); + final rows = chats + .whereType() + .map( + (c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ), + ) + .whereType() + .map((c) => c.toDbRow()) + .toList(); - if (rows.isNotEmpty) { - await AppDatabase.saveChats(rows); + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } catch (e) { + logger.e("Ошибка при синке: $e"); } } static Future> getChats(int accountId) async { - final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + try { + final rows = await AppDatabase.loadChats(accountId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чатов: $e"); + return []; + } + } + static Future> getChat(int accountId, int chatId) async { + try { + final rows = await AppDatabase.loadChat(accountId, chatId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чата: $e"); + + return []; + } } static Future clearCache(int accountId) => @@ -157,80 +186,88 @@ class ChatsModule { Map existing, int cachedAt, ) { - final id = chat['id']; - if (id is! int) return null; + try { + final id = chat['id']; + if (id is! int) return null; - final type = (chat['type'] as String?) ?? 'DIALOG'; - int? otherId; + final type = (chat['type'] as String?) ?? 'DIALOG'; + int? otherId; - String? title; - String? iconUrl; + String? title; + String? iconUrl; - if (type == 'DIALOG') { - otherId = _otherParticipantId(chat['participants'], currentUserId); - final contact = otherId != null ? contactsMap[otherId] : null; + if (type == 'DIALOG') { + otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; - if (contact != null) { - title = _nameFromContact(contact); - iconUrl = contact['baseUrl'] as String?; - } else { - title = existing[id]?.title; - iconUrl = existing[id]?.iconUrl; - } - } else { - title = chat['title'] as String?; - iconUrl = chat['baseIconUrl'] as String?; + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + int? favIndex; + int dontDisturbUntil = 0; + if (config is Map) { + favIndex = config['favIndex'] as int?; + dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; + } + + int seenTime = 0; + bool isOnline = false; + if (type == 'DIALOG' && otherId != null) { + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + seenTime = (presence['seen'] as int?) ?? 0; + isOnline = (presence['status'] as int?) == 1; + } + } + Map participants = Map.from(chat['participants']); + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + favIndex: favIndex, + dontDisturbUntil: dontDisturbUntil, + isOnline: isOnline, + seenTime: seenTime, + participants: participants + ); + } catch (e) { + logger.e("Ошибка при парсинге чата: $e"); + + return null; } - - final lastMsg = chat['lastMessage']; - int? lastMsgId; - int? lastMsgTime; - String? lastMsgText; - int? lastMsgSenderId; - - if (lastMsg is Map) { - lastMsgId = lastMsg['id'] as int?; - lastMsgTime = lastMsg['time'] as int?; - lastMsgText = lastMsg['text'] as String?; - lastMsgSenderId = lastMsg['sender'] as int?; - } - - final config = chatsConfig[id.toString()] ?? chatsConfig[id]; - int? favIndex; - int dontDisturbUntil = 0; - if (config is Map) { - favIndex = config['favIndex'] as int?; - dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; - } - - int seenTime = 0; - bool isOnline = false; - if (type == 'DIALOG' && otherId != null) { - final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; - if (presence is Map) { - seenTime = (presence['seen'] as int?) ?? 0; - isOnline = (presence['status'] as int?) == 1; - } - } - - return CachedChat( - id: id, - accountId: accountId, - type: type, - title: title, - iconUrl: iconUrl, - lastMsgId: lastMsgId, - lastMsgTime: lastMsgTime, - lastMsgText: lastMsgText, - lastMsgSenderId: lastMsgSenderId, - unreadCount: (chat['newMessages'] as int?) ?? 0, - lastEventTime: (chat['lastEventTime'] as int?) ?? 0, - cachedAt: cachedAt, - favIndex: favIndex, - dontDisturbUntil: dontDisturbUntil, - isOnline: isOnline, - seenTime: seenTime, - ); } static int? _otherParticipantId(dynamic participants, int currentUserId) { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f9bb88e..5890f21 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:komet/core/utils/logger.dart'; import 'package:path/path.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -138,7 +139,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 7, + version: 8, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -167,6 +168,11 @@ class AppDatabase { 'ALTER TABLE profile ADD COLUMN profile_options TEXT', ); } + if (oldVersion < 8) { + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', + ); + } }, ); } @@ -235,6 +241,7 @@ class AppDatabase { dont_disturb_until INTEGER NOT NULL DEFAULT 0, is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, + participants TEXT NOT NULL DEFAULT "", PRIMARY KEY (id, account_id) ) '''; @@ -375,18 +382,32 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { - final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + try { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } catch (e) { + logger.e("Ошибка при сохранении чата: $e"); } - await batch.commit(noResult: true); } + static Future>> loadChat(int accountId, int chatId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + orderBy: 'last_event_time DESC', + ); + } + static Future>> loadChats(int accountId) async { final db = await _instance; return db.query( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2f2bf70..986cbf2 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; @@ -15,7 +16,7 @@ import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show accountModule, api; +import '../../../main.dart' show accountModule, api, messagesModule; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -60,40 +61,49 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State with TickerProviderStateMixin { String? _selectedFolderId; + List _folders = []; + int _currentNavIndex = 0; - bool _navDragging = false; - double _navDragDx = 0; - double _navDragBaseLeft = 0; - late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - bool _isFabOpen = false; - bool _showCacheWarning = false; - late AnimationController _fabController; - final Set _selectedChats = {}; - late PageController _folderPageController; - final List _folderChatScrollControllers = []; - final List _folderChatScrollListenerFns = []; - double _pullRatio = 0.0; - static const double _kStoriesPullTriggerPx = 16.0; - late AnimationController _storiesRevealController; + double _navDragDx = 0; + double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; + double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + + bool _navDragging = false; + bool _isFabOpen = false; + bool _showCacheWarning = false; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; bool _storiesOverscrollRevealArmed = true; + bool _shouldCollapseSearch = false; + bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool? _foldersListKnown; + + late AnimationController _navPageAnimController; + late AnimationController _fabController; + late PageController _folderPageController; + late AnimationController _storiesRevealController; + + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; + final Set _selectedChats = {}; + DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; + List _chats = []; + SessionState _sessionState = SessionState.disconnected; + StreamSubscription? _stateSub; StreamSubscription? _loginSub; - bool? _foldersListKnown; - bool _shouldCollapseSearch = false; - - bool get _isSelectionMode => _selectedChats.isNotEmpty; void _toggleSelection(String chatId) { setState(() { @@ -1015,18 +1025,60 @@ class _ChatListScreenState extends State return _buildChatShimmer(); } final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); + + if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(secondId); + final name = ContactCache.get(secondId); + final avatar = ContactCache.getAvatar(secondId); + + return _buildChatItem( + chat.id.toString(), + name ?? "Пользователь", + chat.lastMsgText?.replaceAll('\n', ' ') ?? '', + _formatTime(chat.lastMsgTime), + avatar ?? "", + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } else { + if (chat.lastMsgSenderId != null ) { + final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); + } + + final name = chat.lastMsgSenderId != null + ? ContactCache.get(chat.lastMsgSenderId!) + : null; + + final avatar = chat.lastMsgSenderId != null + ? ContactCache.getAvatar(chat.lastMsgSenderId!) + : null; + + String fullMsg = ""; + + if (name?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$name: "; + } + + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } + + return _buildChatItem( + chat.id.toString(), + chat.id == 0 ? "Избранное" : chat.title ?? "Чат", + fullMsg, + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } }, childCount: _isInitialLoading ? 10 : chats.length), ), SliverPadding( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a05c853..40a0359 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -35,7 +36,8 @@ class _ChatScreenState extends State late AnimationController _shimmerController; List _messages = []; int _myId = 0; - + CachedChat? chat; + @override void initState() { super.initState(); @@ -51,6 +53,11 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; + ChatsModule.getChat(_myId, widget.chatId).then((value) { + chat = value[0]; + }).catchError((error) { + + }); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -240,6 +247,10 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + + // TODO: Локализация + // TODO: Cклонения + String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -284,7 +295,7 @@ class _ChatScreenState extends State ), ), Text( - 'last seen recently', + status ?? "", style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -353,6 +364,7 @@ class _ChatScreenState extends State myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, + chatType: chat!.type, ); }, ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 647da92..bbc93f9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -37,6 +40,7 @@ class MessageBubble extends StatelessWidget { final int myId; final CachedMessage? prevMessage; final CachedMessage? nextMessage; + final String chatType; const MessageBubble({ super.key, @@ -45,6 +49,7 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, + required this.chatType }); bool get isGroupedWithNext { @@ -277,6 +282,11 @@ class MessageBubble extends StatelessWidget { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? senderAvatar = ContactCache.getAvatar(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + return Padding( padding: EdgeInsets.only( left: isMe ? 60 : 12, @@ -285,22 +295,52 @@ class MessageBubble extends StatelessWidget { bottom: bottomMargin, ), child: Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), - ), + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8.0, + children: [ + if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else if (displaySender != null && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender!.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + // Заглушка для паддинга + else + CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000) + ), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ], + ) ), ); } @@ -327,30 +367,46 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) - : Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), - ), + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + // TODO: Получение цветов по хешу ника + style: TextStyle(color: cs.onPrimaryContainer) ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - _formatTime(message.time), - style: TextStyle( - color: textColor.withValues(alpha: 0.7), - fontSize: 10, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), ), - ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 10, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], ], ); }