import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '/data/models/message_model.dart'; import '/data/models/contact_model.dart'; import 'package:chepuhagram/presentation/widgets/message_bubble.dart'; import 'package:chepuhagram/data/repositories/contact_repository.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:provider/provider.dart'; import '/logic/contact_provider.dart'; import '../../domain/services/api_service.dart'; import 'package:chepuhagram/data/datasources/local_db_service.dart'; import 'package:chepuhagram/main.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'contacts_screen.dart'; import 'package:flutter/services.dart'; import 'user_profile_screen.dart'; import 'package:image_picker/image_picker.dart'; class ChatScreen extends StatefulWidget { final Contact contact; const ChatScreen({super.key, required this.contact}); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { static const String _notificationLaunchKey = 'notification_launch_data'; int myId = 0; late Contact _currentContact; bool _isKeyLoading = false; final TextEditingController _controller = TextEditingController(); final FocusNode _inputFocusNode = FocusNode(); final ContactRepository _contactRepository = ContactRepository(); final apiService = ApiService(); final CryptoService _cryptoService = CryptoService(); List messages = []; StreamSubscription? _socketSubscription; final Set _sentReadReceipts = {}; final LocalDbService _localDbService = LocalDbService(); Uint8List? _pendingImageBytes; MessageModel? _replyTo; @override void initState() { super.initState(); _currentContact = widget.contact; currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе if (_currentContact.publicKey == null) { _loadContactKey(); } _loadHistory(); final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); } Future _loadContactKey() async { if (!mounted) return; setState(() => _isKeyLoading = true); try { final updatedContact = await _contactRepository.fetchContactById( _currentContact.id, ); if (!mounted) return; setState(() { _currentContact = updatedContact; _isKeyLoading = false; }); print(updatedContact.publicKey); } catch (e) { if (!mounted) return; setState(() => _isKeyLoading = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Не удалось получить ключ шифрования собеседника"), behavior: SnackBarBehavior.floating, // Обязательно для margin margin: EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), duration: Duration(seconds: 3), ), ); } } @override void dispose() { currentActiveChatContactId = null; // Сбрасываем активный чат _socketSubscription?.cancel(); _controller.dispose(); _inputFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { if (Navigator.canPop(context)) { Navigator.pop(context); } else { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const ContactsScreen()), ); } }, ), title: GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => UserProfileScreen( userId: _currentContact.id, username: _currentContact.username, name: _currentContact.name, ), ), ); }, child: Text(_currentContact.name), ), ), body: Column( children: [ Expanded( child: ListView.builder( reverse: true, // Сообщения растут снизу вверх itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; return MessageBubble( message: msg, onTap: () => _showMessageActions(msg), ); }, ), ), _buildMessageInput(), ], ), ); } Future _showMessageActions(MessageModel msg) async { if (!mounted) return; await showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.reply), title: const Text('Ответить'), onTap: () { Navigator.of(ctx).pop(); setState(() => _replyTo = msg); _inputFocusNode.requestFocus(); }, ), if (msg.isMe) ListTile( leading: const Icon(Icons.edit), title: const Text('Изменить'), onTap: () { Navigator.of(ctx).pop(); _editMessage(msg); }, ), ListTile( leading: const Icon(Icons.copy), title: const Text('Скопировать'), onTap: () async { Navigator.of(ctx).pop(); await Clipboard.setData(ClipboardData(text: msg.text)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Скопировано'), behavior: SnackBarBehavior.floating, // Обязательно для margin margin: EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), duration: Duration(seconds: 2), ), ); }, ), ListTile( leading: const Icon(Icons.forward), title: const Text('Переслать'), onTap: () { Navigator.of(ctx).pop(); _showForwardContactPicker(msg); }, ), if (msg.isMe) ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Удалить'), textColor: Colors.red, iconColor: Colors.red, onTap: () async { Navigator.of(ctx).pop(); await _deleteMessage(msg); }, ), const SizedBox(height: 8), ], ), ); }, ); } Future _editMessage(MessageModel msg) async { final controller = TextEditingController(text: msg.text); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Изменить сообщение'), content: TextField( controller: controller, minLines: 1, maxLines: 5, autofocus: true, decoration: const InputDecoration(hintText: 'Новый текст сообщения'), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена'), ), ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Сохранить'), ), ], ), ); if (result != true || controller.text.trim().isEmpty) return; final newText = controller.text.trim(); final myPrivKey = await _cryptoService.getPrivateKey(); if (myPrivKey == null) return; final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey, _currentContact.publicKey!, ); final encryptedContent = await _cryptoService.encryptMessage( newText, sharedSecret, ); final content50 = newText.length > 50 ? newText.substring(0, 50) : newText; final encryptedContent50 = await _cryptoService.encryptMessage( content50, sharedSecret, ); setState(() { messages = messages.map((m) { if (m.id != null && m.id == msg.id) { return m.copyWith(text: newText, editedAt: DateTime.now()); } return m; }).toList(); }); if (msg.id != null) { try { await _localDbService.updateMessageContent( msg.id!, encryptedContent, DateTime.now(), ); } catch (_) {} Provider.of(context, listen: false).sendMessage({ 'type': 'edit_message', 'message_id': msg.id, 'content': encryptedContent, 'content50': encryptedContent50, }); } } Future _deleteMessage(MessageModel msg) async { setState(() { messages.removeWhere( (m) => (m.id != null && m.id == msg.id) || (m.tempId != null && m.tempId == msg.tempId), ); }); final id = msg.id; if (id != null) { try { await _localDbService.deleteMessage(id); } catch (_) {} Provider.of( context, listen: false, ).sendMessage({'type': 'delete_message', 'message_id': id}); } } Future _showForwardContactPicker(MessageModel msg) async { final contactProvider = context.read(); contactProvider.setCurrentUserId(myId); await contactProvider.loadAllContactsForNewChat(); if (!mounted) return; final selectedContact = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) { final provider = context.watch(); if (provider.isLoading) { return const SizedBox( height: 150, child: Center(child: CircularProgressIndicator()), ); } if (provider.error != null) { return Padding( padding: const EdgeInsets.all(16.0), child: Text('Ошибка загрузки контактов: ${provider.error}'), ); } if (provider.allContacts.isEmpty) { return const Padding( padding: EdgeInsets.all(16.0), child: Text('Нет доступных контактов для пересылки.'), ); } return SafeArea( child: ListView.builder( shrinkWrap: true, itemCount: provider.allContacts.length, itemBuilder: (ctx2, index) { final contact = provider.allContacts[index]; return ListTile( leading: CircleAvatar( child: Text(contact.name.isNotEmpty ? contact.name[0] : '?'), ), title: Text(contact.name), subtitle: Text(contact.username), onTap: () => Navigator.of(ctx).pop(contact), ); }, ), ); }, ); if (selectedContact != null) { await _forwardMessage(msg, selectedContact); } } Future _forwardMessage(MessageModel msg, Contact targetContact) async { final forwardText = msg.text.trim(); if (forwardText.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Нечего пересылать.'), behavior: SnackBarBehavior.floating, // Обязательно для margin margin: EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), duration: Duration(seconds: 5), ), ); return; } if (targetContact.publicKey == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Публичный ключ контакта ${targetContact.name} не найден.', ), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), duration: Duration(seconds: 3), ), ); return; } try { final myPrivKey = await _cryptoService.getPrivateKey(); if (myPrivKey == null) { throw Exception('Не найден приватный ключ.'); } final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey, targetContact.publicKey!, ); final encryptedContent = await _cryptoService.encryptMessage( forwardText, sharedSecret, ); final previewText = forwardText.length > 50 ? forwardText.substring(0, 50) : forwardText; final encryptedContent50 = await _cryptoService.encryptMessage( previewText, sharedSecret, ); final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, text: forwardText.isNotEmpty ? forwardText : "[Фото]", isMe: true, senderId: myId, receiverId: targetContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, localFileBytes: _pendingImageBytes, ); if (_currentContact.id == targetContact.id) { setState(() { messages.add(localMessage); _pendingImageBytes = null; }); } final ok = Provider.of(context, listen: false) .sendMessage({ 'type': 'private_message', 'receiver_id': targetContact.id, 'message_type': 'text', 'content': encryptedContent, 'content50': encryptedContent50, 'temp_id': tempId, }); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( ok ? 'Сообщение переслано контакту ${targetContact.name}.' : 'Не удалось переслать сообщение.', ), behavior: SnackBarBehavior.floating, // Обязательно для margin margin: EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), duration: Duration(seconds: 3), ), ); setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); if (idx != -1) { messages[idx] = messages[idx].copyWith( status: ok ? MessageStatus.sent : MessageStatus.failed, ); } _replyTo = null; }); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка пересылки: $e'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), duration: Duration(seconds: 5), ), ); } } Widget _buildMessageInput() { return SafeArea( // Добавляем SafeArea здесь child: Container( color: Theme.of(context).colorScheme.surfaceVariant, padding: const EdgeInsets.all(8.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_replyTo != null) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ const Icon(Icons.reply, size: 18), const SizedBox(width: 8), Expanded( child: Text( _replyTo!.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.close, size: 18), onPressed: () => setState(() => _replyTo = null), ), ], ), ), if (_pendingImageBytes != null) Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.memory( _pendingImageBytes!, fit: BoxFit.cover, height: 120, ), ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close), onPressed: () => setState(() => _pendingImageBytes = null), ), ], ), ), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( icon: const Icon(Icons.photo), onPressed: _pickImage, ), Expanded( child: TextField( controller: _controller, focusNode: _inputFocusNode, minLines: 1, maxLines: 5, textInputAction: TextInputAction.newline, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( hintText: "Напиши сообщение...", ), ), ), IconButton( icon: const Icon(Icons.send), onPressed: () { _sendMessage(); }, ), ], ), ], ), ), ); } Future _pickImage() async { final ImagePicker _picker = ImagePicker(); final XFile? image = await _picker.pickImage( source: ImageSource.gallery, maxWidth: 1280, maxHeight: 1280, imageQuality: 80, ); if (image != null) { final Uint8List fileBytes = await image.readAsBytes(); if (!mounted) return; setState(() { _pendingImageBytes = fileBytes; }); } } Future _sendMessage() async { final rawText = _controller.text.trim(); final hasImage = _pendingImageBytes != null; // Если и текст пустой, и картинки нет — выходим if (rawText.isEmpty && !hasImage) return; // Блокируем UI на время загрузки _controller.clear(); try { // 1. Подготовка ключей final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, _currentContact.publicKey!, ); String? fileId; String? encryptedFileKey; String encryptedContent; String encryptedContent50; // 2. Если есть изображение — сначала загружаем его if (hasImage) { final encryptionResult = await _cryptoService.encryptImage( _pendingImageBytes!, sharedSecret, ); if (encryptionResult == null) { throw Exception("Ошибка шифрования медиа"); } final encryptedFileData = encryptionResult.$1; final fileKeyForServer = encryptionResult.$2; fileId = await apiService.uploadMedia(encryptedFileData); if (fileId == null) throw Exception("Ошибка загрузки файла на сервер"); encryptedFileKey = fileKeyForServer; } // 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к фото) // Если текста нет, но есть фото, отправим пустую строку или "[Фото]" final String textToEncrypt = rawText.isNotEmpty ? rawText : (hasImage ? "" : ""); encryptedContent = await _cryptoService.encryptMessage( textToEncrypt, sharedSecret, ); String previewText = rawText.isNotEmpty ? rawText : "[Фото]"; if (previewText.length > 50) previewText = previewText.substring(0, 50); encryptedContent50 = await _cryptoService.encryptMessage( previewText, sharedSecret, ); // 4. Создаем локальную модель для мгновенного отображения final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, text: rawText.isNotEmpty ? rawText : "[Фото]", isMe: true, senderId: myId, receiverId: _currentContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, localFileBytes: _pendingImageBytes, replyToId: _replyTo?.id, replyToText: _replyTo?.text, ); setState(() { messages.add(localMessage); _pendingImageBytes = null; // Очищаем черновик }); // 5. Формируем финальный payload для сокета final payload = { "type": "private_message", "receiver_id": _currentContact.id, "message_type": hasImage ? "image" : "text", "content": encryptedContent, // Шифрованный текст (подпись) "content50": encryptedContent50, // Шифрованное превью "temp_id": tempId, if (hasImage) ...{ "file_id": fileId, "encrypted_key": encryptedFileKey, // Зашифрованный AES-ключ файла }, if (_replyTo?.id != null) ...{ "reply_to_id": _replyTo!.id, "reply_to_text": _replyTo!.text, }, }; // 6. Отправка через сокет final ok = Provider.of( context, listen: false, ).sendMessage(payload); // Обновляем статус setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); if (idx != -1) { messages[idx] = messages[idx].copyWith( status: ok ? MessageStatus.sent : MessageStatus.failed, ); } _replyTo = null; }); } catch (e) { // В случае ошибки возвращаем текст в контроллер _controller.text = rawText; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Ошибка отправки: $e"), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), duration: Duration(seconds: 5), ), ); } } void _handleIncomingMessage(Map data) async { // ACK от сервера: сообщение сохранено и получило server_id if (data['type'] == 'message_sent') { final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); if (tempId == null) return; if (!mounted) return; setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); if (idx == -1) return; messages[idx] = messages[idx].copyWith( id: serverId ?? messages[idx].id, createdAt: ts ?? messages[idx].createdAt, status: MessageStatus.sent, ); }); return; } // Backward compatibility: старый ack мог приходить как message_delivered с temp_id/server_id if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) { final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); if (tempId == null) return; if (!mounted) return; setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); if (idx == -1) return; messages[idx] = messages[idx].copyWith( id: serverId ?? messages[idx].id, createdAt: ts ?? messages[idx].createdAt, status: MessageStatus.sent, ); }); return; } // Доставка онлайн (получатель был в сети) if (data['type'] == 'message_delivered') { final messageId = int.tryParse(data['message_id']?.toString() ?? ''); final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); if (messageId == null) return; if (!mounted) return; setState(() { for (int i = 0; i < messages.length; i++) { if (messages[i].id == messageId) { messages[i] = messages[i].copyWith(status: MessageStatus.delivered); } } }); if (ts != null) { try { await _localDbService.updateDeliveredAt(messageId, ts); } catch (_) {} } return; } if (data['type'] == 'message_edited') { final messageId = int.tryParse(data['message_id']?.toString() ?? ''); final ts = DateTime.tryParse(data['edited_at']?.toString() ?? ''); if (messageId == null) return; final myPrivKey = await _cryptoService.getPrivateKey(); if (myPrivKey == null) return; final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey, _currentContact.publicKey!, ); final decryptedText = await _cryptoService.decryptMessage( data['content'], sharedSecret, ); if (!mounted) return; setState(() { messages = messages.map((m) { if (m.id != null && m.id == messageId) { return m.copyWith(text: decryptedText, editedAt: ts); } return m; }).toList(); }); try { await _localDbService.updateMessageContent( messageId, data['content'].toString(), ts, ); } catch (_) {} return; } if (data['type'] == 'message_deleted') { final messageId = int.tryParse(data['message_id']?.toString() ?? ''); if (messageId == null) return; if (!mounted) return; setState(() { messages.removeWhere((m) => m.id != null && m.id == messageId); }); try { await _localDbService.deleteMessage(messageId); } catch (_) {} return; } if (data['type'] == 'message_read') { final messageId = int.tryParse(data['message_id'].toString()); if (messageId == null) return; final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); if (!mounted) return; setState(() { for (int i = 0; i < messages.length; i++) { if (messages[i].id == messageId) { messages[i] = messages[i].copyWith(status: MessageStatus.read); } } }); if (ts != null) { try { await _localDbService.updateReadAt(messageId, ts); } catch (_) {} } return; } if (data['type'] == 'private_message') { final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); final receiverId = int.tryParse( (data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '', ); if (senderId == null || receiverId == null) { print( 'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}', ); return; } // 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId; if (isFromPartnerToMe) { try { final myPrivKey = await _cryptoService.getPrivateKey(); // 2. Вычисляем общий секрет для расшифровки final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); // 3. Расшифровываем контент final decryptedText = await _cryptoService.decryptMessage( data['content'], sharedSecret, ); // 4. Добавляем в список и обновляем экран await LocalDbService().saveMessages([data]); if (!mounted) return; final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) { Provider.of( context, listen: false, ).sendReadReceipt(serverMessageId); _sentReadReceipts.add(serverMessageId); } setState(() { messages.add( MessageModel( id: int.tryParse(data['id']?.toString() ?? ''), text: decryptedText, isMe: false, senderId: senderId, receiverId: myId, createdAt: DateTime.parse(data['timestamp']), status: MessageStatus.delivered, replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()), replyToText: data['reply_to_text'] != null ? data['reply_to_text'].toString() : null, ), ); }); } catch (e) { print("Ошибка расшифровки входящего сообщения: $e"); } } else { print( "Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате", ); // Тут можно добавить логику уведомления для списка чатов } } } Future _loadHistory() async { initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); try { final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); try { List loadedLocalMessages = []; for (var msg in cached) { final decrypted = await _cryptoService.decryptMessage( msg['content'], sharedSecret, ); final deliveredAt = msg['delivered_at'] == null ? null : DateTime.tryParse(msg['delivered_at'].toString()); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString()); MessageStatus status = (msg['sender_id'] == myId) ? MessageStatus.sent : MessageStatus.delivered; if (msg['sender_id'] == myId) { if (readAt != null) { status = MessageStatus.read; } else if (deliveredAt != null) { status = MessageStatus.delivered; } } loadedLocalMessages.add( MessageModel( id: int.tryParse(msg['id']?.toString() ?? ''), text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: msg['reply_to_text'] != null ? msg['reply_to_text'].toString() : null, editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString()) : null, ), ); } if (cached.isNotEmpty) { if (!mounted) return; setState(() { messages = loadedLocalMessages; _isKeyLoading = false; }); } } catch (e) { print(e); } final history = await apiService.getChatHistory(widget.contact.id); print(history); final alreadyReadIncomingMessageIds = {}; List loadedMessages = []; for (var msg in history) { final msgId = int.tryParse(msg['id']?.toString() ?? ''); if (msgId != null && msg['sender_id'] != myId && msg['read_at'] != null) { alreadyReadIncomingMessageIds.add(msgId); } final decrypted = await _cryptoService.decryptMessage( msg['content'], sharedSecret, ); final deliveredAt = msg['delivered_at'] == null ? null : DateTime.tryParse(msg['delivered_at'].toString()); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString()); MessageStatus status = (msg['sender_id'] == myId) ? MessageStatus.sent : MessageStatus.delivered; if (msg['sender_id'] == myId) { if (readAt != null) { status = MessageStatus.read; } else if (deliveredAt != null) { status = MessageStatus.delivered; } } loadedMessages.insert( 0, MessageModel( id: int.tryParse(msg['id']?.toString() ?? ''), text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: msg['reply_to_text'] != null ? msg['reply_to_text'].toString() : null, editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString()) : null, ), ); } try { await _localDbService.saveMessages(history); } catch (e) { print("Ошибка сохранения истории в локальную базу: $e"); } if (!mounted) return; setState(() { messages = loadedMessages; _isKeyLoading = false; }); // Отправляем read_receipt для сообщений собеседника, которые уже на экране. for (final m in loadedMessages) { if (m.isMe) continue; final id = m.id; if (id == null) continue; if (alreadyReadIncomingMessageIds.contains(id)) continue; if (_sentReadReceipts.contains(id)) continue; Provider.of(context, listen: false).sendReadReceipt(id); _sentReadReceipts.add(id); } await _localDbService.deleteChatHistory(widget.contact.id, myId); } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; setState(() => _isKeyLoading = false); } } }