убрал скрол к непрочитанныm GET OUT, статус отправки реакций, более заметная кнопка сохранения фото, возможность выбрать деррикторию для сохранения файлов на пк и телiфоне, совместил отправку файлов и отправку медиав одну кнопку, добавил функционал кнопкам 'добавить в контакты' и написать сообщение который там в этом ну этом ну вы поняли, возможность написать человеку прям с чата(добавил кнопки в то меню где можно редактировать локально контакт), выход не чекал
This commit is contained in:
@@ -129,11 +129,29 @@ extension ApiServiceAuth on ApiService {
|
|||||||
await prefs.setString('userId', userId);
|
await prefs.setString('userId', userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Полный сброс сессии как при переключении аккаунта
|
||||||
|
_messageQueue.clear();
|
||||||
|
_lastChatsPayload = null;
|
||||||
|
_chatsFetchedInThisSession = false;
|
||||||
|
_isSessionOnline = false;
|
||||||
|
_isSessionReady = false;
|
||||||
|
_handshakeSent = false;
|
||||||
|
|
||||||
disconnect();
|
disconnect();
|
||||||
|
|
||||||
await connect();
|
await connect();
|
||||||
|
await waitUntilOnline();
|
||||||
|
|
||||||
await getChatsAndContacts(force: true);
|
await getChatsAndContacts(force: true);
|
||||||
print("Токен и UserID успешно сохранены");
|
|
||||||
|
// Обновляем профиль аккаунта из свежих данных
|
||||||
|
final profileJson = _lastChatsPayload?['profile'];
|
||||||
|
if (profileJson != null) {
|
||||||
|
final profileObj = Profile.fromJson(profileJson);
|
||||||
|
await accountManager.updateAccountProfile(account.id, profileObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Токен и UserID успешно сохранены, сессия перезапущена");
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> hasToken() async {
|
Future<bool> hasToken() async {
|
||||||
@@ -286,7 +304,9 @@ extension ApiServiceAuth on ApiService {
|
|||||||
_sendMessage(17, payload);
|
_sendMessage(17, payload);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await completer.future.timeout(const Duration(seconds: 30));
|
final response = await completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
);
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
|
|
||||||
final payload = response['payload'];
|
final payload = response['payload'];
|
||||||
@@ -318,7 +338,9 @@ extension ApiServiceAuth on ApiService {
|
|||||||
_sendMessage(18, payload);
|
_sendMessage(18, payload);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await completer.future.timeout(const Duration(seconds: 30));
|
final response = await completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
);
|
||||||
subscription.cancel();
|
subscription.cancel();
|
||||||
|
|
||||||
final payload = response['payload'];
|
final payload = response['payload'];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'package:gwid/services/avatar_cache_service.dart';
|
|||||||
import 'package:gwid/services/chat_read_settings_service.dart';
|
import 'package:gwid/services/chat_read_settings_service.dart';
|
||||||
import 'package:gwid/services/contact_local_names_service.dart';
|
import 'package:gwid/services/contact_local_names_service.dart';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:gwid/screens/group_settings_screen.dart';
|
import 'package:gwid/screens/group_settings_screen.dart';
|
||||||
import 'package:gwid/screens/edit_contact_screen.dart';
|
import 'package:gwid/screens/edit_contact_screen.dart';
|
||||||
import 'package:gwid/widgets/contact_name_widget.dart';
|
import 'package:gwid/widgets/contact_name_widget.dart';
|
||||||
@@ -30,6 +31,7 @@ import 'package:video_player/video_player.dart';
|
|||||||
import 'package:gwid/screens/chat_encryption_settings_screen.dart';
|
import 'package:gwid/screens/chat_encryption_settings_screen.dart';
|
||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:gwid/services/chat_encryption_service.dart';
|
import 'package:gwid/services/chat_encryption_service.dart';
|
||||||
|
import 'package:lottie/lottie.dart';
|
||||||
|
|
||||||
bool _debugShowExactDate = false;
|
bool _debugShowExactDate = false;
|
||||||
|
|
||||||
@@ -59,43 +61,8 @@ class DateSeparatorItem extends ChatItem {
|
|||||||
DateSeparatorItem(this.date);
|
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 {
|
class _EmptyChatWidget extends StatelessWidget {
|
||||||
final Map<String, dynamic>? sticker;
|
final Map<String, dynamic>? sticker;
|
||||||
@@ -157,12 +124,32 @@ class _EmptyChatWidget extends StatelessWidget {
|
|||||||
'🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height',
|
'🎨 _buildSticker: url=$url, lottieUrl=$lottieUrl, width=$width, height=$height',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Для отображения используем обычный url (статичное изображение)
|
// Если есть Lottie-анимация — пытаемся показать её (особенно актуально на телефоне)
|
||||||
// lottieUrl - это для анимации, но пока используем статичное изображение
|
if (lottieUrl != null && lottieUrl.isNotEmpty) {
|
||||||
// Если есть url, используем его, иначе пробуем lottieUrl
|
print('🎨 Пытаемся показать Lottie-анимацию: $lottieUrl');
|
||||||
final imageUrl = url ?? lottieUrl;
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: Lottie.network(
|
||||||
|
lottieUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
print('❌ Ошибка загрузки Lottie: $error');
|
||||||
|
print('❌ StackTrace Lottie: $stackTrace');
|
||||||
|
// Фоллбек: пробуем статичное изображение по url
|
||||||
|
if (url != null && url.isNotEmpty) {
|
||||||
|
return Image.network(url, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
|
return Icon(Icons.emoji_emotions, size: width, color: Colors.grey);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
print('🎨 Используемый URL для стикера: $imageUrl');
|
// Иначе показываем статичную картинку по обычному url
|
||||||
|
final imageUrl = url;
|
||||||
|
|
||||||
|
print('🎨 Используемый URL для статичного стикера: $imageUrl');
|
||||||
|
|
||||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -261,10 +248,180 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final Map<int, Contact> _contactDetailsCache = {};
|
final Map<int, Contact> _contactDetailsCache = {};
|
||||||
final Set<int> _loadingContactIds = {};
|
final Set<int> _loadingContactIds = {};
|
||||||
|
|
||||||
String?
|
// Локальный счётчик непрочитанных (используется только для первичной инициализации).
|
||||||
_lastReadMessageId; // последнее прочитанное нами сообщение в этом чате
|
|
||||||
int _initialUnreadCount = 0;
|
int _initialUnreadCount = 0;
|
||||||
bool _hasUnreadSeparator = false;
|
|
||||||
|
// Сообщения, для которых сейчас "отправляется" реакция (показываем часы на реакции).
|
||||||
|
final Set<String> _sendingReactions = {};
|
||||||
|
|
||||||
|
// ======================= Attachments helpers =======================
|
||||||
|
|
||||||
|
Future<void> _onAttachPressed() async {
|
||||||
|
// Мобильные платформы — плашка снизу
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Отправить вложение',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: colors.primary.withOpacity(0.10),
|
||||||
|
foregroundColor: colors.primary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.photo_library_outlined),
|
||||||
|
label: const Text('Фото / видео'),
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final result = await _pickPhotosFlow(context);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (result != null && result.paths.isNotEmpty) {
|
||||||
|
await ApiService.instance.sendPhotoMessages(
|
||||||
|
widget.chatId,
|
||||||
|
localPaths: result.paths,
|
||||||
|
caption: result.caption,
|
||||||
|
senderId: _actualMyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.insert_drive_file_outlined),
|
||||||
|
label: const Text('Файл с устройства'),
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
await ApiService.instance.sendFileMessage(
|
||||||
|
widget.chatId,
|
||||||
|
senderId: _actualMyId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'Скоро здесь появятся последние отправленные файлы.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Десктоп: простое меню вместо плашки
|
||||||
|
if (!mounted) return;
|
||||||
|
final choice = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Отправить вложение'),
|
||||||
|
children: [
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop('media'),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.photo_library_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Фото / видео'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop('file'),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.insert_drive_file_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Файл с устройства'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice == 'media') {
|
||||||
|
final result = await _pickPhotosFlow(context);
|
||||||
|
if (result != null && result.paths.isNotEmpty) {
|
||||||
|
await ApiService.instance.sendPhotoMessages(
|
||||||
|
widget.chatId,
|
||||||
|
localPaths: result.paths,
|
||||||
|
caption: result.caption,
|
||||||
|
senderId: _actualMyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (choice == 'file') {
|
||||||
|
await ApiService.instance.sendFileMessage(
|
||||||
|
widget.chatId,
|
||||||
|
senderId: _actualMyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int? _actualMyId;
|
int? _actualMyId;
|
||||||
|
|
||||||
@@ -301,6 +458,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
|
// Плавный скролл — используем только по явному действию (кнопка "вниз" и т.п.)
|
||||||
|
if (!_itemScrollController.isAttached) return;
|
||||||
_itemScrollController.scrollTo(
|
_itemScrollController.scrollTo(
|
||||||
index: 0,
|
index: 0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -308,6 +467,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _jumpToBottom() {
|
||||||
|
// Мгновенный прыжок в самый низ — используем при входе в чат,
|
||||||
|
// чтобы не было "подпрыгивания" списка из-за анимации.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_itemScrollController.isAttached) {
|
||||||
|
_itemScrollController.jumpTo(index: 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _loadContactDetails() {
|
void _loadContactDetails() {
|
||||||
final chatData = ApiService.instance.lastChatsPayload;
|
final chatData = ApiService.instance.lastChatsPayload;
|
||||||
if (chatData != null && chatData['contacts'] != null) {
|
if (chatData != null && chatData['contacts'] != null) {
|
||||||
@@ -821,16 +990,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_isUserAtBottom = isAtBottom;
|
_isUserAtBottom = isAtBottom;
|
||||||
_showScrollToBottomNotifier.value = !isAtBottom;
|
_showScrollToBottomNotifier.value = !isAtBottom;
|
||||||
|
|
||||||
// Если мы внизу и была плашка непрочитанных — считаем, что всё прочитано:
|
|
||||||
// сбрасываем lastRead и просто скрываем саму плашку (без изменения списка).
|
|
||||||
if (isAtBottom && _hasUnreadSeparator) {
|
|
||||||
_lastReadMessageId = null;
|
|
||||||
_hasUnreadSeparator = false;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, доскроллил ли пользователь до самого старого сообщения (вверх)
|
// Проверяем, доскроллил ли пользователь до самого старого сообщения (вверх)
|
||||||
// При reverse: true, последний визуальный элемент (самый большой index) = самое старое сообщение
|
// При reverse: true, последний визуальный элемент (самый большой index) = самое старое сообщение
|
||||||
if (positions.isNotEmpty && _chatItems.isNotEmpty) {
|
if (positions.isNotEmpty && _chatItems.isNotEmpty) {
|
||||||
@@ -981,7 +1140,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_removeMessages(deletedMessageIds);
|
_removeMessages(deletedMessageIds);
|
||||||
}
|
}
|
||||||
} else if (opcode == 178) {
|
} else if (opcode == 178) {
|
||||||
if (chatIdNormalized == widget.chatId) {
|
// cmd == 1: это ACK на отправку реакции, без messageId — просто снимаем флаг "отправляется"
|
||||||
|
if (cmd == 1) {
|
||||||
|
if (_sendingReactions.isNotEmpty) {
|
||||||
|
_sendingReactions.clear();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// cmd == 0: широковещательное обновление реакций с messageId и reactionInfo
|
||||||
|
if (cmd == 0 && chatIdNormalized == widget.chatId) {
|
||||||
final messageId = payload['messageId'] as String?;
|
final messageId = payload['messageId'] as String?;
|
||||||
final reactionInfo = payload['reactionInfo'] as Map<String, dynamic>?;
|
final reactionInfo = payload['reactionInfo'] as Map<String, dynamic>?;
|
||||||
if (messageId != null && reactionInfo != null) {
|
if (messageId != null && reactionInfo != null) {
|
||||||
@@ -1134,30 +1303,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
'📜 Первая загрузка: загружено ${allMessages.length} сообщений, показано ${_messages.length}, _hasMore=$_hasMore, _oldestLoadedTime=$_oldestLoadedTime',
|
'📜 Первая загрузка: загружено ${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();
|
_buildChatItems();
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// После первой загрузки истории скроллим к последнему прочитанному
|
// Функция "перейти к последнему непрочитанному" отключена.
|
||||||
if (_lastReadMessageId != null) {
|
// Всегда стартуем с низа истории без анимации, чтобы не было подпрыгиваний.
|
||||||
_scrollToLastReadMessage();
|
_jumpToBottom();
|
||||||
} else {
|
|
||||||
// Если нечего читать (нет lastRead), просто остаёмся внизу
|
|
||||||
_scrollToBottom();
|
|
||||||
}
|
|
||||||
_updatePinnedMessage();
|
_updatePinnedMessage();
|
||||||
|
|
||||||
// Если чат пустой, загружаем стикер для пустого состояния
|
// Если чат пустой, загружаем стикер для пустого состояния
|
||||||
@@ -1170,9 +1322,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// Не показываем всплывающее сообщение "Не удалось обновить историю чата".
|
||||||
const SnackBar(content: Text('Не удалось обновить историю чата')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,16 +1457,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final List<ChatItem> items = [];
|
final List<ChatItem> items = [];
|
||||||
final source = _messages;
|
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++) {
|
for (int i = 0; i < source.length; i++) {
|
||||||
final currentMessage = source[i];
|
final currentMessage = source[i];
|
||||||
final previousMessage = (i > 0) ? source[i - 1] : null;
|
final previousMessage = (i > 0) ? source[i - 1] : null;
|
||||||
@@ -1334,16 +1474,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
final isGrouped = _isMessageGrouped(currentMessage, previousMessage);
|
final isGrouped = _isMessageGrouped(currentMessage, previousMessage);
|
||||||
|
|
||||||
print(
|
|
||||||
'DEBUG GROUPING: Message ${i}: sender=${currentMessage.senderId}, time=${currentMessage.time}',
|
|
||||||
);
|
|
||||||
if (previousMessage != null) {
|
|
||||||
print(
|
|
||||||
'DEBUG GROUPING: Previous: sender=${previousMessage.senderId}, time=${previousMessage.time}',
|
|
||||||
);
|
|
||||||
print('DEBUG GROUPING: isGrouped=$isGrouped');
|
|
||||||
}
|
|
||||||
|
|
||||||
final isFirstInGroup =
|
final isFirstInGroup =
|
||||||
previousMessage == null ||
|
previousMessage == null ||
|
||||||
!_isMessageGrouped(currentMessage, previousMessage);
|
!_isMessageGrouped(currentMessage, previousMessage);
|
||||||
@@ -1352,10 +1482,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
i == source.length - 1 ||
|
i == source.length - 1 ||
|
||||||
!_isMessageGrouped(source[i + 1], currentMessage);
|
!_isMessageGrouped(source[i + 1], currentMessage);
|
||||||
|
|
||||||
print(
|
|
||||||
'DEBUG GROUPING: isFirstInGroup=$isFirstInGroup, isLastInGroup=$isLastInGroup',
|
|
||||||
);
|
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
MessageItem(
|
MessageItem(
|
||||||
currentMessage,
|
currentMessage,
|
||||||
@@ -1364,12 +1490,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isGrouped: isGrouped,
|
isGrouped: isGrouped,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Если это последнее прочитанное сообщение, сразу после него вставляем разделитель
|
|
||||||
if (lastReadIndex != null && i == lastReadIndex) {
|
|
||||||
items.add(UnreadSeparatorItem());
|
|
||||||
_hasUnreadSeparator = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_chatItems = items;
|
_chatItems = items;
|
||||||
|
|
||||||
@@ -1525,35 +1645,6 @@ 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}) {
|
void _addMessage(Message message, {bool forceScroll = false}) {
|
||||||
if (_messages.any((m) => m.id == message.id)) {
|
if (_messages.any((m) => m.id == message.id)) {
|
||||||
print('Сообщение ${message.id} уже существует, пропускаем добавление');
|
print('Сообщение ${message.id} уже существует, пропускаем добавление');
|
||||||
@@ -1644,6 +1735,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final updatedMessage = message.copyWith(reactionInfo: reactionInfo);
|
final updatedMessage = message.copyWith(reactionInfo: reactionInfo);
|
||||||
_messages[messageIndex] = updatedMessage;
|
_messages[messageIndex] = updatedMessage;
|
||||||
|
|
||||||
|
// Снимаем флаг "отправляется" для этой реакции (если был)
|
||||||
|
if (_sendingReactions.remove(messageId)) {
|
||||||
|
print(
|
||||||
|
'✅ Реакция для сообщения $messageId успешно подтверждена сервером',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_buildChatItems();
|
_buildChatItems();
|
||||||
|
|
||||||
print('Обновлена реакция для сообщения $messageId: $reactionInfo');
|
print('Обновлена реакция для сообщения $messageId: $reactionInfo');
|
||||||
@@ -1689,6 +1787,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
_messages[messageIndex] = updatedMessage;
|
_messages[messageIndex] = updatedMessage;
|
||||||
|
|
||||||
|
// Помечаем, что реакция для этого сообщения сейчас отправляется
|
||||||
|
_sendingReactions.add(messageId);
|
||||||
|
|
||||||
_buildChatItems();
|
_buildChatItems();
|
||||||
|
|
||||||
print('Оптимистично добавлена реакция $emoji к сообщению $messageId');
|
print('Оптимистично добавлена реакция $emoji к сообщению $messageId');
|
||||||
@@ -1739,6 +1840,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
_messages[messageIndex] = updatedMessage;
|
_messages[messageIndex] = updatedMessage;
|
||||||
|
|
||||||
|
// Помечаем, что удаление реакции сейчас отправляется
|
||||||
|
_sendingReactions.add(messageId);
|
||||||
|
|
||||||
_buildChatItems();
|
_buildChatItems();
|
||||||
|
|
||||||
print('Оптимистично удалена реакция с сообщения $messageId');
|
print('Оптимистично удалена реакция с сообщения $messageId');
|
||||||
@@ -3084,6 +3188,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
message: item.message,
|
message: item.message,
|
||||||
isMe: isMe,
|
isMe: isMe,
|
||||||
readStatus: readStatus,
|
readStatus: readStatus,
|
||||||
|
isReactionSending: _sendingReactions
|
||||||
|
.contains(item.message.id),
|
||||||
deferImageLoading: deferImageLoading,
|
deferImageLoading: deferImageLoading,
|
||||||
myUserId: _actualMyId,
|
myUserId: _actualMyId,
|
||||||
chatId: widget.chatId,
|
chatId: widget.chatId,
|
||||||
@@ -3237,10 +3343,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return finalMessageWidget;
|
return finalMessageWidget;
|
||||||
} else if (item is DateSeparatorItem) {
|
} else if (item is DateSeparatorItem) {
|
||||||
return _DateSeparatorChip(date: item.date);
|
return _DateSeparatorChip(date: item.date);
|
||||||
} else if (item is UnreadSeparatorItem) {
|
|
||||||
return _hasUnreadSeparator
|
|
||||||
? const _UnreadSeparatorChip()
|
|
||||||
: const SizedBox.shrink();
|
|
||||||
}
|
}
|
||||||
if (isLastVisual && _isLoadingMore) {
|
if (isLastVisual && _isLoadingMore) {
|
||||||
return TweenAnimationBuilder<double>(
|
return TweenAnimationBuilder<double>(
|
||||||
@@ -4187,46 +4289,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
onTap: isBlocked
|
onTap: isBlocked ? null : _onAttachPressed,
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final result = await _pickPhotosFlow(context);
|
|
||||||
if (result != null &&
|
|
||||||
result.paths.isNotEmpty) {
|
|
||||||
await ApiService.instance.sendPhotoMessages(
|
|
||||||
widget.chatId,
|
|
||||||
localPaths: result.paths,
|
|
||||||
caption: result.caption,
|
|
||||||
senderId: _actualMyId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6.0),
|
|
||||||
child: Icon(
|
|
||||||
Icons.photo_library_outlined,
|
|
||||||
color: isBlocked
|
|
||||||
? Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurface.withOpacity(0.3)
|
|
||||||
: Theme.of(context).colorScheme.primary,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
onTap: isBlocked
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await ApiService.instance.sendFileMessage(
|
|
||||||
widget.chatId,
|
|
||||||
senderId: _actualMyId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6.0),
|
padding: const EdgeInsets.all(6.0),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -5186,6 +5249,28 @@ class _SendPhotosDialogState extends State<_SendPhotosDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickMoreDesktop() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
allowMultiple: true,
|
||||||
|
type: FileType.image,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
|
||||||
|
_pickedPaths
|
||||||
|
..clear()
|
||||||
|
..addAll(result.files.where((f) => f.path != null).map((f) => f.path!));
|
||||||
|
_previews
|
||||||
|
..clear()
|
||||||
|
..addAll(_pickedPaths.map((p) => FileImage(File(p)) as ImageProvider));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Ошибка выбора фото на десктопе: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
@@ -5204,22 +5289,7 @@ class _SendPhotosDialogState extends State<_SendPhotosDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: _pickMoreDesktop,
|
||||||
try {
|
|
||||||
final imgs = await ImagePicker().pickMultiImage(
|
|
||||||
imageQuality: 100,
|
|
||||||
);
|
|
||||||
if (imgs.isNotEmpty) {
|
|
||||||
_pickedPaths
|
|
||||||
..clear()
|
|
||||||
..addAll(imgs.map((e) => e.path));
|
|
||||||
_previews
|
|
||||||
..clear()
|
|
||||||
..addAll(imgs.map((e) => FileImage(File(e.path))));
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.photo_library),
|
icon: const Icon(Icons.photo_library),
|
||||||
label: Text(
|
label: Text(
|
||||||
_pickedPaths.isEmpty
|
_pickedPaths.isEmpty
|
||||||
@@ -5837,7 +5907,7 @@ class _ContactProfileDialogState extends State<ContactProfileDialog> {
|
|||||||
else
|
else
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
if (!widget.isChannel)
|
if (!widget.isChannel) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
@@ -5874,6 +5944,94 @@ class _ContactProfileDialogState extends State<ContactProfileDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final isInContacts =
|
||||||
|
ApiService.instance.getCachedContact(
|
||||||
|
widget.contact.id,
|
||||||
|
) !=
|
||||||
|
null;
|
||||||
|
if (isInContacts) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Уже в контактах'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ApiService.instance.addContact(
|
||||||
|
widget.contact.id,
|
||||||
|
);
|
||||||
|
await ApiService.instance
|
||||||
|
.requestContactsByIds([
|
||||||
|
widget.contact.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Запрос на добавление в контакты отправлен',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Ошибка при добавлении в контакты: $e',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.person_add),
|
||||||
|
label: const Text('В контакты'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Уже в этом чате — просто закрываем панель,
|
||||||
|
// чтобы пользователь мог сразу написать.
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.message),
|
||||||
|
label: const Text('Написать сообщение'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -670,22 +670,9 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
try {
|
try {
|
||||||
String? selectedDirectory;
|
String? selectedDirectory;
|
||||||
|
|
||||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
// На всех платформах, где поддерживается, пробуем открыть диалог выбора папки.
|
||||||
// На десктопе используем getDirectoryPath
|
// На Android/iOS FilePicker сам использует системный проводник/документы.
|
||||||
selectedDirectory = await FilePicker.platform.getDirectoryPath();
|
selectedDirectory = await FilePicker.platform.getDirectoryPath();
|
||||||
} else {
|
|
||||||
// На мобильных платформах file_picker может не поддерживать выбор папки
|
|
||||||
// Используем диалог с текстовым вводом или просто показываем сообщение
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Выбор папки доступен только на десктопных платформах'),
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedDirectory != null && selectedDirectory.isNotEmpty) {
|
if (selectedDirectory != null && selectedDirectory.isNotEmpty) {
|
||||||
await DownloadPathHelper.setDownloadDirectory(selectedDirectory);
|
await DownloadPathHelper.setDownloadDirectory(selectedDirectory);
|
||||||
@@ -717,9 +704,7 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Сбросить папку загрузки'),
|
title: const Text('Сбросить папку загрузки'),
|
||||||
content: const Text(
|
content: const Text('Вернуть папку загрузки к значению по умолчанию?'),
|
||||||
'Вернуть папку загрузки к значению по умолчанию?',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
@@ -752,7 +737,8 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
future: DownloadPathHelper.getDisplayPath(),
|
future: DownloadPathHelper.getDisplayPath(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentPath = snapshot.data ?? 'Загрузка...';
|
final currentPath = snapshot.data ?? 'Загрузка...';
|
||||||
final isCustom = snapshot.hasData &&
|
final isCustom =
|
||||||
|
snapshot.hasData &&
|
||||||
currentPath != 'Не указано' &&
|
currentPath != 'Не указано' &&
|
||||||
!currentPath.contains('Downloads') &&
|
!currentPath.contains('Downloads') &&
|
||||||
!currentPath.contains('Download');
|
!currentPath.contains('Download');
|
||||||
@@ -770,10 +756,7 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.folder_outlined, color: colors.primary),
|
||||||
Icons.folder_outlined,
|
|
||||||
color: colors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -822,11 +805,7 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isCustom)
|
if (isCustom)
|
||||||
Icon(
|
Icon(Icons.check_circle, color: colors.primary, size: 20),
|
||||||
Icons.check_circle,
|
|
||||||
color: colors.primary,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -848,7 +827,10 @@ class _StorageScreenState extends State<StorageScreen>
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _resetDownloadFolder,
|
onPressed: _resetDownloadFolder,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.refresh),
|
child: const Icon(Icons.refresh),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
final int? chatId;
|
final int? chatId;
|
||||||
final bool isEncryptionPasswordSet;
|
final bool isEncryptionPasswordSet;
|
||||||
final String? decryptedText;
|
final String? decryptedText;
|
||||||
|
// Идёт ли сейчас отправка/удаление реакции для этого сообщения
|
||||||
|
final bool isReactionSending;
|
||||||
|
|
||||||
const ChatMessageBubble({
|
const ChatMessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -414,6 +416,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
this.chatId,
|
this.chatId,
|
||||||
this.isEncryptionPasswordSet = false,
|
this.isEncryptionPasswordSet = false,
|
||||||
this.decryptedText,
|
this.decryptedText,
|
||||||
|
this.isReactionSending = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _formatMessageTime(BuildContext context, int timestamp) {
|
String _formatMessageTime(BuildContext context, int timestamp) {
|
||||||
@@ -1166,7 +1169,10 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
: textColor.withOpacity(0.1),
|
: textColor.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
'$emoji $count',
|
'$emoji $count',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -1178,6 +1184,18 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
: textColor.withOpacity(0.9),
|
: textColor.withOpacity(0.9),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isUserReaction && isReactionSending) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_RotatingIcon(
|
||||||
|
icon: Icons.watch_later_outlined,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? const Color(0xFF9bb5c7)
|
||||||
|
: const Color(0xFF6b7280),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -3340,10 +3358,10 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
String? token,
|
String? token,
|
||||||
int? chatId,
|
int? chatId,
|
||||||
}) async {
|
}) async {
|
||||||
|
try {
|
||||||
// Initialize progress
|
// Initialize progress
|
||||||
FileDownloadProgressService().updateProgress(fileId, 0.0);
|
FileDownloadProgressService().updateProgress(fileId, 0.0);
|
||||||
|
|
||||||
try {
|
|
||||||
// Get Downloads directory using helper
|
// Get Downloads directory using helper
|
||||||
final downloadDir = await DownloadPathHelper.getDownloadDirectory();
|
final downloadDir = await DownloadPathHelper.getDownloadDirectory();
|
||||||
|
|
||||||
@@ -5634,10 +5652,22 @@ class _FullScreenPhotoViewerState extends State<FullScreenPhotoViewer> {
|
|||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
if (widget.attach != null)
|
if (widget.attach != null)
|
||||||
IconButton(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.download, color: Colors.white),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blueAccent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text(
|
||||||
|
'Скачать',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
onPressed: _downloadPhoto,
|
onPressed: _downloadPhoto,
|
||||||
tooltip: 'Скачать фото',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
|||||||
final ScrollController _nameScrollController = ScrollController();
|
final ScrollController _nameScrollController = ScrollController();
|
||||||
String? _localDescription;
|
String? _localDescription;
|
||||||
StreamSubscription? _changesSubscription;
|
StreamSubscription? _changesSubscription;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
bool _isOpeningChat = false;
|
bool _isOpeningChat = false;
|
||||||
|
bool _isInContacts = false;
|
||||||
|
bool _isAddingToContacts = false;
|
||||||
|
|
||||||
String get _displayName {
|
String get _displayName {
|
||||||
final displayName = getContactDisplayName(
|
final displayName = getContactDisplayName(
|
||||||
@@ -64,20 +67,50 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadLocalDescription();
|
_loadLocalDescription();
|
||||||
|
_checkIfInContacts();
|
||||||
|
|
||||||
_changesSubscription = ContactLocalNamesService().changes.listen((
|
_changesSubscription = ContactLocalNamesService().changes.listen((
|
||||||
contactId,
|
contactId,
|
||||||
) {
|
) {
|
||||||
if (contactId == widget.userId && mounted) {
|
if (contactId == widget.userId && mounted) {
|
||||||
_loadLocalDescription();
|
_loadLocalDescription();
|
||||||
|
_checkIfInContacts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_wsSubscription = ApiService.instance.messages.listen((msg) {
|
||||||
|
try {
|
||||||
|
if (msg['opcode'] == 34 &&
|
||||||
|
msg['cmd'] == 1 &&
|
||||||
|
msg['payload'] != null &&
|
||||||
|
msg['payload']['contact'] != null) {
|
||||||
|
final contactJson = msg['payload']['contact'] as Map<String, dynamic>;
|
||||||
|
final id = contactJson['id'] as int?;
|
||||||
|
if (id == widget.userId && mounted) {
|
||||||
|
final contact = Contact.fromJson(contactJson);
|
||||||
|
ApiService.instance.updateContactCache([contact]);
|
||||||
|
setState(() {
|
||||||
|
_isInContacts = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkNameLength();
|
_checkNameLength();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkIfInContacts() async {
|
||||||
|
final cached = ApiService.instance.getCachedContact(widget.userId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isInContacts = cached != null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalDescription() async {
|
Future<void> _loadLocalDescription() async {
|
||||||
final localData = await ContactLocalNamesService().getContactData(
|
final localData = await ContactLocalNamesService().getContactData(
|
||||||
widget.userId,
|
widget.userId,
|
||||||
@@ -92,6 +125,7 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_changesSubscription?.cancel();
|
_changesSubscription?.cancel();
|
||||||
|
_wsSubscription?.cancel();
|
||||||
_nameScrollController.dispose();
|
_nameScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -234,14 +268,24 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
|||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Icons.phone,
|
icon: Icons.phone,
|
||||||
label: 'Позвонить',
|
label: 'Позвонить',
|
||||||
onPressed: null,
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Звонков пока нету'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
colors: colors,
|
colors: colors,
|
||||||
),
|
),
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Icons.person_add,
|
icon: Icons.person_add,
|
||||||
label: 'В контакты',
|
label: _isInContacts ? 'В контактах' : 'В контакты',
|
||||||
onPressed: null,
|
onPressed: _isInContacts || _isAddingToContacts
|
||||||
|
? null
|
||||||
|
: _handleAddToContacts,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
isLoading: _isAddingToContacts,
|
||||||
),
|
),
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Icons.message,
|
icon: Icons.message,
|
||||||
@@ -386,4 +430,46 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAddToContacts() async {
|
||||||
|
if (_isAddingToContacts || _isInContacts) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isAddingToContacts = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Отправляем opcode=34 с action="ADD"
|
||||||
|
await ApiService.instance.addContact(widget.userId);
|
||||||
|
|
||||||
|
// Пытаемся сразу подтянуть обновлённые данные контакта
|
||||||
|
await ApiService.instance.requestContactsByIds([widget.userId]);
|
||||||
|
|
||||||
|
await _checkIfInContacts();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Запрос на добавление в контакты отправлен'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Ошибка при добавлении в контакты: $e'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isAddingToContacts = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ dependencies:
|
|||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
|
||||||
|
lottie: ^3.1.2
|
||||||
|
|
||||||
chewie: ^1.7.5
|
chewie: ^1.7.5
|
||||||
|
|
||||||
just_audio: ^0.9.40
|
just_audio: ^0.9.40
|
||||||
|
|||||||
Reference in New Issue
Block a user