Починка показа людей для перессылки(не отдельный экран, потом), возможность написать тому кто переслал сообщение, какая то дохлая система показа и перехода к последнему прочитанному сообщению(не работает), копирование пересланных сообщений
This commit is contained in:
@@ -59,6 +59,44 @@ class DateSeparatorItem extends ChatItem {
|
||||
DateSeparatorItem(this.date);
|
||||
}
|
||||
|
||||
class UnreadSeparatorItem extends ChatItem {}
|
||||
|
||||
class _UnreadSeparatorChip extends StatelessWidget {
|
||||
const _UnreadSeparatorChip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(thickness: 1)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'НЕПРОЧИТАННЫЕ СООБЩЕНИЯ',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Divider(thickness: 1)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyChatWidget extends StatelessWidget {
|
||||
final Map<String, dynamic>? sticker;
|
||||
final VoidCallback? onStickerTap;
|
||||
@@ -69,7 +107,9 @@ class _EmptyChatWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
print('🎨 _EmptyChatWidget.build: sticker=${sticker != null ? "есть" : "null"}');
|
||||
print(
|
||||
'🎨 _EmptyChatWidget.build: sticker=${sticker != null ? "есть" : "null"}',
|
||||
);
|
||||
if (sticker != null) {
|
||||
print('🎨 Стикер данные: ${sticker}');
|
||||
}
|
||||
@@ -90,9 +130,7 @@ class _EmptyChatWidget extends StatelessWidget {
|
||||
const SizedBox(
|
||||
width: 170,
|
||||
height: 170,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@@ -115,7 +153,9 @@ class _EmptyChatWidget extends StatelessWidget {
|
||||
final width = (sticker['width'] as num?)?.toDouble() ?? 170.0;
|
||||
final height = (sticker['height'] as num?)?.toDouble() ?? 170.0;
|
||||
|
||||
print('🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height');
|
||||
print(
|
||||
'🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height',
|
||||
);
|
||||
|
||||
// Для отображения используем обычный url (статичное изображение)
|
||||
// lottieUrl - это для анимации, но пока используем статичное изображение
|
||||
@@ -136,12 +176,14 @@ class _EmptyChatWidget extends StatelessWidget {
|
||||
print('✅ Стикер успешно загружен');
|
||||
return child;
|
||||
}
|
||||
print('⏳ Загрузка стикера: ${loadingProgress.cumulativeBytesLoaded}/${loadingProgress.expectedTotalBytes}');
|
||||
print(
|
||||
'⏳ Загрузка стикера: ${loadingProgress.cumulativeBytesLoaded}/${loadingProgress.expectedTotalBytes}',
|
||||
);
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
);
|
||||
@@ -149,22 +191,14 @@ class _EmptyChatWidget extends StatelessWidget {
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
print('❌ Ошибка загрузки стикера: $error');
|
||||
print('❌ StackTrace: $stackTrace');
|
||||
return Icon(
|
||||
Icons.emoji_emotions,
|
||||
size: width,
|
||||
color: Colors.grey,
|
||||
);
|
||||
return Icon(Icons.emoji_emotions, size: width, color: Colors.grey);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
print('❌ URL стикера пустой или null');
|
||||
return Icon(
|
||||
Icons.emoji_emotions,
|
||||
size: width,
|
||||
color: Colors.grey,
|
||||
);
|
||||
return Icon(Icons.emoji_emotions, size: width, color: Colors.grey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +216,7 @@ class ChatScreen extends StatefulWidget {
|
||||
final bool isChannel;
|
||||
final int? participantCount;
|
||||
final bool isDesktopMode;
|
||||
final int initialUnreadCount;
|
||||
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
@@ -194,6 +229,7 @@ class ChatScreen extends StatefulWidget {
|
||||
this.isChannel = false,
|
||||
this.participantCount,
|
||||
this.isDesktopMode = false,
|
||||
this.initialUnreadCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -225,7 +261,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final Map<int, Contact> _contactDetailsCache = {};
|
||||
final Set<int> _loadingContactIds = {};
|
||||
|
||||
final Map<String, String> _lastReadMessageIdByParticipant = {};
|
||||
String?
|
||||
_lastReadMessageId; // последнее прочитанное нами сообщение в этом чате
|
||||
int _initialUnreadCount = 0;
|
||||
bool _hasUnreadSeparator = false;
|
||||
|
||||
int? _actualMyId;
|
||||
|
||||
@@ -463,6 +502,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialUnreadCount = widget.initialUnreadCount;
|
||||
_currentContact = widget.contact;
|
||||
_pinnedMessage =
|
||||
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
|
||||
@@ -781,17 +821,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_isUserAtBottom = isAtBottom;
|
||||
_showScrollToBottomNotifier.value = !isAtBottom;
|
||||
|
||||
// Если мы внизу и была плашка непрочитанных — считаем, что всё прочитано:
|
||||
// сбрасываем lastRead и просто скрываем саму плашку (без изменения списка).
|
||||
if (isAtBottom && _hasUnreadSeparator) {
|
||||
_lastReadMessageId = null;
|
||||
_hasUnreadSeparator = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, доскроллил ли пользователь до самого старого сообщения (вверх)
|
||||
// При reverse: true, последний визуальный элемент (самый большой index) = самое старое сообщение
|
||||
if (positions.isNotEmpty && _chatItems.isNotEmpty) {
|
||||
final maxIndex = positions.map((p) => p.index).reduce((a, b) => a > b ? a : b);
|
||||
final maxIndex = positions
|
||||
.map((p) => p.index)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
// При reverse: true, когда maxIndex близок к _chatItems.length - 1, мы вверху (старые сообщения)
|
||||
final threshold = _chatItems.length > 5 ? 3 : 1; // Загружаем когда осталось 3 элемента до верха
|
||||
final threshold = _chatItems.length > 5
|
||||
? 3
|
||||
: 1; // Загружаем когда осталось 3 элемента до верха
|
||||
final isNearTop = maxIndex >= _chatItems.length - threshold;
|
||||
|
||||
// Если доскроллили близко к верху и есть еще сообщения, загружаем
|
||||
if (isNearTop && _hasMore && !_isLoadingMore && _messages.isNotEmpty && _oldestLoadedTime != null) {
|
||||
print('📜 Пользователь доскроллил близко к верху (maxIndex: $maxIndex, total: ${_chatItems.length}), загружаем старые сообщения...');
|
||||
if (isNearTop &&
|
||||
_hasMore &&
|
||||
!_isLoadingMore &&
|
||||
_messages.isNotEmpty &&
|
||||
_oldestLoadedTime != null) {
|
||||
print(
|
||||
'📜 Пользователь доскроллил близко к верху (maxIndex: $maxIndex, total: ${_chatItems.length}), загружаем старые сообщения...',
|
||||
);
|
||||
// Вызываем после build фазы, чтобы избежать setState() во время build
|
||||
Future.microtask(() {
|
||||
if (mounted && _hasMore && !_isLoadingMore) {
|
||||
@@ -970,7 +1030,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_oldestLoadedTime = _messages.first.time;
|
||||
// Предполагаем, что могут быть еще сообщения (будет обновлено после загрузки с сервера)
|
||||
_hasMore = true;
|
||||
print('📜 Загружено из кэша: ${_messages.length} сообщений, _oldestLoadedTime=$_oldestLoadedTime');
|
||||
print(
|
||||
'📜 Загружено из кэша: ${_messages.length} сообщений, _oldestLoadedTime=$_oldestLoadedTime',
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isGroupChat) {
|
||||
@@ -1066,11 +1128,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_oldestLoadedTime = _messages.isNotEmpty ? _messages.first.time : null;
|
||||
// Если получили максимальное количество сообщений (1000), возможно есть еще
|
||||
// Также проверяем, есть ли сообщения старше самого старого загруженного
|
||||
_hasMore = allMessages.length >= 1000 || allMessages.length > _messages.length;
|
||||
print('📜 Первая загрузка: загружено ${allMessages.length} сообщений, показано ${_messages.length}, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime');
|
||||
_hasMore =
|
||||
allMessages.length >= 1000 || allMessages.length > _messages.length;
|
||||
print(
|
||||
'📜 Первая загрузка: загружено ${allMessages.length} сообщений, показано ${_messages.length}, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime',
|
||||
);
|
||||
|
||||
// Если есть непрочитанные, пытаемся вычислить последнее прочитанное сообщение
|
||||
// очень грубо: берём N сообщений перед концом списка (N = initialUnreadCount),
|
||||
// и считаем последним прочитанным то, что стоит ровно перед ними.
|
||||
if (widget.initialUnreadCount > 0 &&
|
||||
allMessages.length > widget.initialUnreadCount) {
|
||||
final lastReadGlobalIndex =
|
||||
allMessages.length - widget.initialUnreadCount - 1;
|
||||
final lastReadMessage = allMessages[lastReadGlobalIndex];
|
||||
_lastReadMessageId = lastReadMessage.id;
|
||||
} else {
|
||||
_lastReadMessageId = null;
|
||||
}
|
||||
|
||||
_buildChatItems();
|
||||
_isLoadingHistory = false;
|
||||
});
|
||||
|
||||
// После первой загрузки истории скроллим к последнему прочитанному
|
||||
if (_lastReadMessageId != null) {
|
||||
_scrollToLastReadMessage();
|
||||
} else {
|
||||
// Если нечего читать (нет lastRead), просто остаёмся внизу
|
||||
_scrollToBottom();
|
||||
}
|
||||
_updatePinnedMessage();
|
||||
|
||||
// Если чат пустой, загружаем стикер для пустого состояния
|
||||
@@ -1105,15 +1192,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
print('📜 _loadMore() вызвана: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime');
|
||||
print(
|
||||
'📜 _loadMore() вызвана: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime',
|
||||
);
|
||||
|
||||
if (_isLoadingMore || !_hasMore) {
|
||||
print('📜 _loadMore() пропущена: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore');
|
||||
print(
|
||||
'📜 _loadMore() пропущена: _isLoadingMore=$_isLoadingMore, _hasMore=$_hasMore',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_messages.isEmpty || _oldestLoadedTime == null) {
|
||||
print('📜 _loadMore() пропущена: _messages.isEmpty=${_messages.isEmpty}, _oldestLoadedTime=$_oldestLoadedTime');
|
||||
print(
|
||||
'📜 _loadMore() пропущена: _messages.isEmpty=${_messages.isEmpty}, _oldestLoadedTime=$_oldestLoadedTime',
|
||||
);
|
||||
_hasMore = false;
|
||||
return;
|
||||
}
|
||||
@@ -1122,13 +1215,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
setState(() {});
|
||||
|
||||
try {
|
||||
print('📜 Загружаем старые сообщения для chatId=${widget.chatId}, fromTimestamp=$_oldestLoadedTime');
|
||||
// Загружаем старые сообщения начиная с timestamp самого старого загруженного сообщения
|
||||
final olderMessages = await ApiService.instance.loadOlderMessagesByTimestamp(
|
||||
widget.chatId,
|
||||
_oldestLoadedTime!,
|
||||
backward: 30,
|
||||
print(
|
||||
'📜 Загружаем старые сообщения для chatId=${widget.chatId}, fromTimestamp=$_oldestLoadedTime',
|
||||
);
|
||||
// Загружаем старые сообщения начиная с timestamp самого старого загруженного сообщения
|
||||
final olderMessages = await ApiService.instance
|
||||
.loadOlderMessagesByTimestamp(
|
||||
widget.chatId,
|
||||
_oldestLoadedTime!,
|
||||
backward: 30,
|
||||
);
|
||||
|
||||
print('📜 Получено ${olderMessages.length} старых сообщений');
|
||||
|
||||
@@ -1144,7 +1240,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
// Фильтруем дубликаты - оставляем только те сообщения, которых еще нет в списке
|
||||
final existingMessageIds = _messages.map((m) => m.id).toSet();
|
||||
final newMessages = olderMessages.where((m) => !existingMessageIds.contains(m.id)).toList();
|
||||
final newMessages = olderMessages
|
||||
.where((m) => !existingMessageIds.contains(m.id))
|
||||
.toList();
|
||||
|
||||
if (newMessages.isEmpty) {
|
||||
// Все сообщения уже есть в списке
|
||||
@@ -1154,7 +1252,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
print('📜 Добавляем ${newMessages.length} новых старых сообщений (отфильтровано ${olderMessages.length - newMessages.length} дубликатов)');
|
||||
print(
|
||||
'📜 Добавляем ${newMessages.length} новых старых сообщений (отфильтровано ${olderMessages.length - newMessages.length} дубликатов)',
|
||||
);
|
||||
|
||||
// Добавляем старые сообщения в начало списка
|
||||
_messages.insertAll(0, newMessages);
|
||||
@@ -1207,6 +1307,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final List<ChatItem> items = [];
|
||||
final source = _messages;
|
||||
|
||||
// Находим индекс последнего прочитанного сообщения (если оно есть)
|
||||
int? lastReadIndex;
|
||||
if (_lastReadMessageId != null) {
|
||||
lastReadIndex = source.indexWhere((m) => m.id == _lastReadMessageId);
|
||||
if (lastReadIndex == -1) {
|
||||
lastReadIndex = null;
|
||||
}
|
||||
}
|
||||
_hasUnreadSeparator = false;
|
||||
|
||||
for (int i = 0; i < source.length; i++) {
|
||||
final currentMessage = source[i];
|
||||
final previousMessage = (i > 0) ? source[i - 1] : null;
|
||||
@@ -1254,12 +1364,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isGrouped: isGrouped,
|
||||
),
|
||||
);
|
||||
|
||||
// Если это последнее прочитанное сообщение, сразу после него вставляем разделитель
|
||||
if (lastReadIndex != null && i == lastReadIndex) {
|
||||
items.add(UnreadSeparatorItem());
|
||||
_hasUnreadSeparator = true;
|
||||
}
|
||||
}
|
||||
_chatItems = items;
|
||||
|
||||
// Очищаем ключи для сообщений, которых больше нет в списке
|
||||
final currentMessageIds = _messages.map((m) => m.id).toSet();
|
||||
final keysToRemove = _messageKeys.keys.where((id) => !currentMessageIds.contains(id)).toList();
|
||||
final keysToRemove = _messageKeys.keys
|
||||
.where((id) => !currentMessageIds.contains(id))
|
||||
.toList();
|
||||
for (final id in keysToRemove) {
|
||||
_messageKeys.remove(id);
|
||||
}
|
||||
@@ -1273,7 +1391,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// Список доступных ID стикеров для пустого чата
|
||||
final availableStickerIds = [272821, 295349, 13571];
|
||||
// Выбираем случайный ID
|
||||
final random = DateTime.now().millisecondsSinceEpoch % availableStickerIds.length;
|
||||
final random =
|
||||
DateTime.now().millisecondsSinceEpoch % availableStickerIds.length;
|
||||
final selectedStickerId = availableStickerIds[random];
|
||||
|
||||
print('🎨 Загружаем стикер для пустого чата (ID: $selectedStickerId)...');
|
||||
@@ -1310,7 +1429,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final sticker = stickers.first as Map<String, dynamic>;
|
||||
// Сохраняем также stickerId для отправки
|
||||
final stickerId = sticker['id'] as int?;
|
||||
print('🎨 Данные стикера: id=$stickerId, url=${sticker['url']}, lottieUrl=${sticker['lottieUrl']}, width=${sticker['width']}, height=${sticker['height']}');
|
||||
print(
|
||||
'🎨 Данные стикера: id=$stickerId, url=${sticker['url']}, lottieUrl=${sticker['lottieUrl']}, width=${sticker['width']}, height=${sticker['height']}',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_emptyChatSticker = {
|
||||
@@ -1318,7 +1439,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
'stickerId': stickerId, // Сохраняем ID для отправки
|
||||
};
|
||||
});
|
||||
print('✅ Стикер для пустого чата загружен и сохранен (ID: $stickerId)');
|
||||
print(
|
||||
'✅ Стикер для пустого чата загружен и сохранен (ID: $stickerId)',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
print('❌ Стикеры не найдены в ответе');
|
||||
@@ -1402,6 +1525,35 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollToLastReadMessage() {
|
||||
final lastReadId = _lastReadMessageId;
|
||||
if (lastReadId == null) return;
|
||||
|
||||
int? targetChatItemIndex;
|
||||
for (int i = 0; i < _chatItems.length; i++) {
|
||||
final item = _chatItems[i];
|
||||
if (item is MessageItem && item.message.id == lastReadId) {
|
||||
targetChatItemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChatItemIndex == null) return;
|
||||
if (!_itemScrollController.isAttached) return;
|
||||
|
||||
final visualIndex = _chatItems.length - 1 - targetChatItemIndex;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_itemScrollController.isAttached) {
|
||||
_itemScrollController.scrollTo(
|
||||
index: visualIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutCubic,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _addMessage(Message message, {bool forceScroll = false}) {
|
||||
if (_messages.any((m) => m.id == message.id)) {
|
||||
print('Сообщение ${message.id} уже существует, пропускаем добавление');
|
||||
@@ -1669,10 +1821,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
"message": {
|
||||
"cid": cid,
|
||||
"attaches": [
|
||||
{
|
||||
"_type": "STICKER",
|
||||
"stickerId": stickerId,
|
||||
}
|
||||
{"_type": "STICKER", "stickerId": stickerId},
|
||||
],
|
||||
},
|
||||
"notify": true,
|
||||
@@ -1988,12 +2137,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
subtitle = '${participants.length} участников';
|
||||
} else {
|
||||
final otherParticipantId = participants.keys
|
||||
.map((id) => int.parse(id))
|
||||
.firstWhere((id) => id != _actualMyId, orElse: () => 0);
|
||||
// Обычный диалог «1 на 1»
|
||||
final participantIds = participants.keys
|
||||
.map((id) => int.tryParse(id) ?? 0)
|
||||
.toList();
|
||||
|
||||
int otherParticipantId = 0;
|
||||
if (participantIds.isNotEmpty) {
|
||||
// Пытаемся найти участника, отличного от нашего собственного ID
|
||||
otherParticipantId = participantIds.firstWhere(
|
||||
(id) => _actualMyId == null || id != _actualMyId,
|
||||
orElse: () => participantIds.first,
|
||||
);
|
||||
}
|
||||
|
||||
// Пытаемся взять контакт из локального кэша;
|
||||
// если его нет — загружаем в фоне.
|
||||
final contact = _contactDetailsCache[otherParticipantId];
|
||||
chatName = contact?.name ?? chatTitle ?? 'Чат $chatId';
|
||||
if (contact == null && otherParticipantId != 0) {
|
||||
_loadContactIfNeeded(otherParticipantId);
|
||||
}
|
||||
|
||||
// Для личных чатов никогда не используем server title вида
|
||||
// «пользователь {id}» — вместо этого показываем имя контакта
|
||||
// либо хотя бы «ID {userId}».
|
||||
chatName = contact?.name ?? 'ID $otherParticipantId';
|
||||
|
||||
final avatarUrl = contact?.photoBaseUrl;
|
||||
|
||||
@@ -2001,7 +2169,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
avatarUrl,
|
||||
userId: otherParticipantId,
|
||||
size: 48,
|
||||
fallbackText: contact?.name ?? chatTitle ?? 'Чат $chatId',
|
||||
fallbackText: chatName,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
);
|
||||
|
||||
@@ -2725,20 +2893,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: _messages.isEmpty && !widget.isChannel
|
||||
? _EmptyChatWidget(
|
||||
sticker: _emptyChatSticker,
|
||||
onStickerTap: _sendEmptyChatSticker,
|
||||
)
|
||||
: AnimatedPadding(
|
||||
key: const ValueKey('chat_list'),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutCubic,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(
|
||||
context,
|
||||
).viewInsets.bottom,
|
||||
),
|
||||
child: ScrollablePositionedList.builder(
|
||||
? _EmptyChatWidget(
|
||||
sticker: _emptyChatSticker,
|
||||
onStickerTap: _sendEmptyChatSticker,
|
||||
)
|
||||
: AnimatedPadding(
|
||||
key: const ValueKey('chat_list'),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutCubic,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(
|
||||
context,
|
||||
).viewInsets.bottom,
|
||||
),
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemScrollController: _itemScrollController,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
reverse: true,
|
||||
@@ -3069,6 +3237,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return finalMessageWidget;
|
||||
} else if (item is DateSeparatorItem) {
|
||||
return _DateSeparatorChip(date: item.date);
|
||||
} else if (item is UnreadSeparatorItem) {
|
||||
return _hasUnreadSeparator
|
||||
? const _UnreadSeparatorChip()
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
if (isLastVisual && _isLoadingMore) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
|
||||
@@ -4334,6 +4334,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
isGroupChat: isGroupChat,
|
||||
isChannel: isChannel,
|
||||
participantCount: participantCount,
|
||||
initialUnreadCount: chat.newMessages,
|
||||
onChatRemoved: () {
|
||||
_removeChatLocally(chat.id);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:gwid/screens/chat_screen.dart';
|
||||
import 'package:gwid/services/avatar_cache_service.dart';
|
||||
import 'package:gwid/widgets/user_profile_panel.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -479,117 +480,152 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: textColor.withOpacity(0.08 * messageTextOpacity),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: textColor.withOpacity(0.3 * messageTextOpacity),
|
||||
width: 3, // Делаем рамку жирнее для отличия от ответа
|
||||
// Пытаемся определить userId оригинального отправителя для открытия панели профиля
|
||||
int? originalSenderId;
|
||||
if (forwardedMessage['sender'] is int) {
|
||||
originalSenderId = forwardedMessage['sender'] as int;
|
||||
}
|
||||
|
||||
void handleTap() {
|
||||
final myId = myUserId ?? 0;
|
||||
if (originalSenderId == null || myId == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => UserProfilePanel(
|
||||
userId: originalSenderId!,
|
||||
name: forwardedSenderName,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
avatarUrl: forwardedSenderAvatarUrl,
|
||||
description: null,
|
||||
myId: myId,
|
||||
currentChatId: chatId,
|
||||
contactData: null,
|
||||
dialogChatId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: handleTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: textColor.withOpacity(0.08 * messageTextOpacity),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: textColor.withOpacity(0.3 * messageTextOpacity),
|
||||
width: 3, // Делаем рамку жирнее для отличия от ответа
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// "Заголовок" с именем автора и аватаркой
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forward,
|
||||
size: 14,
|
||||
color: textColor.withOpacity(0.6 * messageTextOpacity),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (forwardedSenderAvatarUrl != null)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: textColor.withOpacity(0.2 * messageTextOpacity),
|
||||
width: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// "Заголовок" с именем автора и аватаркой
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forward,
|
||||
size: 14,
|
||||
color: textColor.withOpacity(0.6 * messageTextOpacity),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (forwardedSenderAvatarUrl != null)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: textColor.withOpacity(0.2 * messageTextOpacity),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
forwardedSenderAvatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: textColor.withOpacity(
|
||||
0.1 * messageTextOpacity,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 12,
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
forwardedSenderAvatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
color: textColor.withOpacity(
|
||||
0.5 * messageTextOpacity,
|
||||
0.1 * messageTextOpacity,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 12,
|
||||
color: textColor.withOpacity(
|
||||
0.5 * messageTextOpacity,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: textColor.withOpacity(0.1 * messageTextOpacity),
|
||||
border: Border.all(
|
||||
color: textColor.withOpacity(0.2 * messageTextOpacity),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 12,
|
||||
color: textColor.withOpacity(0.5 * messageTextOpacity),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: textColor.withOpacity(0.1 * messageTextOpacity),
|
||||
border: Border.all(
|
||||
color: textColor.withOpacity(0.2 * messageTextOpacity),
|
||||
width: 1,
|
||||
Flexible(
|
||||
child: Text(
|
||||
forwardedSenderName,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor.withOpacity(0.9 * messageTextOpacity),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 12,
|
||||
color: textColor.withOpacity(0.5 * messageTextOpacity),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
forwardedSenderName,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor.withOpacity(0.9 * messageTextOpacity),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Содержимое пересланного сообщения (фото и/или текст)
|
||||
if (attaches.isNotEmpty) ...[
|
||||
..._buildPhotosWithCaption(
|
||||
context,
|
||||
attaches, // Передаем вложения из вложенного сообщения
|
||||
textColor,
|
||||
isUltraOptimized,
|
||||
messageTextOpacity,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (text.isNotEmpty)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.9 * messageTextOpacity),
|
||||
fontSize: 14,
|
||||
|
||||
// Содержимое пересланного сообщения (фото и/или текст)
|
||||
if (attaches.isNotEmpty) ...[
|
||||
..._buildPhotosWithCaption(
|
||||
context,
|
||||
attaches, // Передаем вложения из вложенного сообщения
|
||||
textColor,
|
||||
isUltraOptimized,
|
||||
messageTextOpacity,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (text.isNotEmpty)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.9 * messageTextOpacity),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1207,7 +1243,12 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
!message.isReply &&
|
||||
!message.isForwarded;
|
||||
|
||||
final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity, context);
|
||||
final bubbleColor = _getBubbleColor(
|
||||
isMe,
|
||||
themeProvider,
|
||||
messageOpacity,
|
||||
context,
|
||||
);
|
||||
final textColor = _getTextColor(
|
||||
isMe,
|
||||
bubbleColor,
|
||||
@@ -1774,7 +1815,12 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
final themeProvider = Provider.of<ThemeProvider>(context);
|
||||
final isUltraOptimized = themeProvider.ultraOptimizeChats;
|
||||
final messageOpacity = themeProvider.messageBubbleOpacity;
|
||||
final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity, context);
|
||||
final bubbleColor = _getBubbleColor(
|
||||
isMe,
|
||||
themeProvider,
|
||||
messageOpacity,
|
||||
context,
|
||||
);
|
||||
final textColor = _getTextColor(
|
||||
isMe,
|
||||
bubbleColor,
|
||||
@@ -3879,7 +3925,8 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
final bool isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final baseColor = isMe
|
||||
? (themeProvider.myBubbleColor ?? const Color(0xFF2b5278))
|
||||
: (themeProvider.theirBubbleColor ?? (isDark ? const Color(0xFF182533) : const Color(0xFF464646)));
|
||||
: (themeProvider.theirBubbleColor ??
|
||||
(isDark ? const Color(0xFF182533) : const Color(0xFF464646)));
|
||||
return baseColor.withOpacity(1.0 - messageOpacity);
|
||||
}
|
||||
|
||||
@@ -5146,7 +5193,31 @@ class _MessageContextMenuState extends State<_MessageContextMenu>
|
||||
}
|
||||
|
||||
void _onCopy() {
|
||||
Clipboard.setData(ClipboardData(text: widget.message.text));
|
||||
String textToCopy = widget.message.text;
|
||||
|
||||
// Для пересланных сообщений пробуем взять текст оригинального сообщения
|
||||
if (textToCopy.isEmpty &&
|
||||
widget.message.isForwarded &&
|
||||
widget.message.link is Map<String, dynamic>) {
|
||||
final link = widget.message.link as Map<String, dynamic>;
|
||||
final forwardedMessage = link['message'] as Map<String, dynamic>?;
|
||||
final forwardedText = forwardedMessage?['text'] as String? ?? '';
|
||||
textToCopy = forwardedText;
|
||||
}
|
||||
|
||||
if (textToCopy.isEmpty) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Нет текста для копирования'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Clipboard.setData(ClipboardData(text: textToCopy));
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -5294,7 +5365,9 @@ class _MessageContextMenuState extends State<_MessageContextMenu>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.message.text.isNotEmpty)
|
||||
// Для пересланных сообщений разрешаем копирование даже при пустом text,
|
||||
// т.к. текст может быть внутри link['message'].
|
||||
if (widget.message.text.isNotEmpty || widget.message.isForwarded)
|
||||
_buildActionButton(
|
||||
icon: Icons.copy_rounded,
|
||||
text: 'Копировать',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gwid/services/avatar_cache_service.dart';
|
||||
import 'package:gwid/widgets/contact_name_widget.dart';
|
||||
import 'package:gwid/widgets/contact_avatar_widget.dart';
|
||||
import 'package:gwid/services/contact_local_names_service.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/contact.dart';
|
||||
import 'package:gwid/screens/chat_screen.dart';
|
||||
|
||||
class UserProfilePanel extends StatefulWidget {
|
||||
final int userId;
|
||||
@@ -39,6 +41,7 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
final ScrollController _nameScrollController = ScrollController();
|
||||
String? _localDescription;
|
||||
StreamSubscription? _changesSubscription;
|
||||
bool _isOpeningChat = false;
|
||||
|
||||
String get _displayName {
|
||||
final displayName = getContactDisplayName(
|
||||
@@ -243,8 +246,9 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
_buildActionButton(
|
||||
icon: Icons.message,
|
||||
label: 'Написать',
|
||||
onPressed: null,
|
||||
onPressed: _isOpeningChat ? null : _handleWriteMessage,
|
||||
colors: colors,
|
||||
isLoading: _isOpeningChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -309,4 +313,77 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleWriteMessage() async {
|
||||
if (_isOpeningChat) return;
|
||||
|
||||
setState(() {
|
||||
_isOpeningChat = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Сначала пробуем использовать dialogChatId, если он уже есть
|
||||
int? chatId = widget.dialogChatId;
|
||||
|
||||
if (chatId == null || chatId == 0) {
|
||||
// Если нет, считаем chatId по формуле chatId = userId1 ^ userId2
|
||||
chatId = await ApiService.instance.getChatIdByUserId(widget.userId);
|
||||
}
|
||||
|
||||
if (chatId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Не удалось открыть чат с пользователем'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Закрываем панель профиля
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Открываем экран чата
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (ctx) => ChatScreen(
|
||||
chatId: chatId!,
|
||||
contact: Contact(
|
||||
id: widget.userId,
|
||||
name: widget.name ?? _displayName,
|
||||
firstName: widget.firstName ?? '',
|
||||
lastName: widget.lastName ?? '',
|
||||
description: widget.description,
|
||||
photoBaseUrl: widget.avatarUrl,
|
||||
accountStatus: 0,
|
||||
status: null,
|
||||
options: const [],
|
||||
),
|
||||
myId: widget.myId,
|
||||
isGroupChat: false,
|
||||
isChannel: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ошибка при открытии чата: $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isOpeningChat = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user