убрал скрол к непрочитанныm GET OUT, статус отправки реакций, более заметная кнопка сохранения фото, возможность выбрать деррикторию для сохранения файлов на пк и телiфоне, совместил отправку файлов и отправку медиав одну кнопку, добавил функционал кнопкам 'добавить в контакты' и написать сообщение который там в этом ну этом ну вы поняли, возможность написать человеку прям с чата(добавил кнопки в то меню где можно редактировать локально контакт), выход не чекал

This commit is contained in:
jganenok
2025-12-04 20:17:22 +07:00
parent 61f0eb349a
commit d344adf035
7 changed files with 533 additions and 253 deletions

View File

@@ -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'];

View File

@@ -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),
),
),
),
),
],
], ],
), ),
); );

View File

@@ -669,23 +669,10 @@ class _StorageScreenState extends State<StorageScreen>
Future<void> _selectDownloadFolder() async { Future<void> _selectDownloadFolder() async {
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),
), ),

View File

@@ -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,17 +1169,32 @@ class ChatMessageBubble extends StatelessWidget {
: textColor.withOpacity(0.1), : textColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Row(
'$emoji $count', mainAxisSize: MainAxisSize.min,
style: TextStyle( children: [
fontSize: 12, Text(
fontWeight: isUserReaction '$emoji $count',
? FontWeight.w600 style: TextStyle(
: FontWeight.w500, fontSize: 12,
color: isUserReaction fontWeight: isUserReaction
? Theme.of(context).colorScheme.primary ? FontWeight.w600
: textColor.withOpacity(0.9), : FontWeight.w500,
), color: isUserReaction
? Theme.of(context).colorScheme.primary
: 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),
),
],
],
), ),
), ),
); );
@@ -3340,10 +3358,10 @@ class ChatMessageBubble extends StatelessWidget {
String? token, String? token,
int? chatId, int? chatId,
}) async { }) async {
// Initialize progress
FileDownloadProgressService().updateProgress(fileId, 0.0);
try { try {
// Initialize progress
FileDownloadProgressService().updateProgress(fileId, 0.0);
// 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: 'Скачать фото',
), ),
], ],
), ),

View File

@@ -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;
});
}
}
}
} }

View File

@@ -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