import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:cryptography/cryptography.dart'; import '/data/models/message_model.dart'; import '/data/models/contact_model.dart'; import 'package:chepuhagram/presentation/widgets/message_bubble.dart'; import 'package:gal/gal.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 'dart:math'; 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'; import '/core/theme_manager.dart'; class ChatScreen extends StatefulWidget { final Contact contact; const ChatScreen({super.key, required this.contact}); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State with RouteAware { static const String _notificationLaunchKey = 'notification_launch_data'; int myId = 0; late Contact _currentContact; bool _isKeyLoading = true; 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(); final ScrollController _scrollController = ScrollController(); final Map _messageKeys = {}; bool _showScrollToEnd = false; Uint8List? _pendingImageBytes; MessageModel? _replyTo; bool _isOnline = false; DateTime? _lastOnline; Timer? _onlineTimer; DateTime? _lastTypingSent; bool _isTyping = false; Timer? _typingTimer; late SocketService _socketService; @override void initState() { super.initState(); _currentContact = widget.contact; _socketService = Provider.of(context, listen: false); currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе _loadLocalName(); if (_currentContact.publicKey == null) { _loadContactKey(); } _loadHistory(); _loadOnlineStatus(); startOnlineUpdates(); _controller.addListener(_sendTypingStatus); _scrollController.addListener(_updateScrollButtonVisibility); final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); } @override void didChangeDependencies() { super.didChangeDependencies(); routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); } @override void didPopNext() { print("Пользователь вернулся на этот экран!"); _loadLocalName(); flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); } Future _loadLocalName() async { final prefs = await SharedPreferences.getInstance(); final String? savedName = prefs.getString( 'firstname_${_currentContact.id}', ); final String? savedSurname = prefs.getString( 'lastname_${_currentContact.id}', ); print('Загружены имя $savedName, $savedSurname'); if (mounted) { setState(() { if (savedName != null) { _currentContact.name = savedName; } if (savedSurname != null) { _currentContact.surname = savedSurname; } }); } } void _sendTypingStatus() { final now = DateTime.now(); if (_lastTypingSent == null || now.difference(_lastTypingSent!) > const Duration(seconds: 3)) { _lastTypingSent = now; final socketService = Provider.of(context, listen: false); socketService.sendMessage({ 'type': 'typing', 'receiver_id': _currentContact.id, }); } } void _sendStopTypingStatus() { _socketService.sendMessage({ 'type': 'stop_typing', 'receiver_id': _currentContact.id, }); } Future _loadOnlineStatus() async { flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); try { print( "🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})", ); final data = await apiService.getUserById(_currentContact.id); if (!mounted) return; DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; print( "✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}", ); setState(() { _isOnline = data['online'] ?? false; if (data['last_online'] != null) _lastOnline = DateTime.parse(data['last_online']).add(offset); else _lastOnline = null; }); } catch (e) { print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ СТАТУСА ОНЛАЙН: $e"); // Игнорируем ошибки при загрузке статуса } } void startOnlineUpdates() { _onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) { _loadOnlineStatus(); }); } 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, left: 10.0, right: 10.0), duration: Duration(seconds: 3), ), ); } } @override void dispose() { currentActiveChatContactId = null; _socketSubscription?.cancel(); _scrollController.removeListener(_updateScrollButtonVisibility); _scrollController.dispose(); _controller.dispose(); routeObserver.unsubscribe(this); _inputFocusNode.dispose(); _onlineTimer?.cancel(); _typingTimer?.cancel(); _controller.removeListener(_sendTypingStatus); _sendStopTypingStatus(); super.dispose(); } @override Widget build(BuildContext context) { final themeProv = context.watch(); 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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}', ), if (_isKeyLoading == true) const Text( 'загрузка...', style: const TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ) else if (_isTyping) Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const Text( 'печатает', style: TextStyle(fontSize: 12, color: Colors.greenAccent), ), const SizedBox(width: 4), TypingIndicator(), ], ) else if (_isOnline) const Text( 'онлайн', style: TextStyle(fontSize: 12, color: Colors.greenAccent), ) else if (_lastOnline != null) Text( 'был(а) в сети ${_formatLastOnline(_lastOnline!)}', style: const TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ) else const Text( 'был(а) недавно', style: TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ), ], ), ), ), body: Column( children: [ Expanded( child: Container( decoration: themeProv.wallpaperPath != null ? BoxDecoration( image: DecorationImage( image: FileImage(File(themeProv.wallpaperPath!)), fit: BoxFit.cover, ), ) : null, child: ListView.builder( controller: _scrollController, reverse: true, // Сообщения растут снизу вверх itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; final keyId = msg.id ?? msg.tempId ?? index; final itemKey = _messageKeys.putIfAbsent( keyId, () => GlobalKey(), ); return MessageBubble( key: itemKey, message: msg, onTap: () => _showMessageActions(msg), onReplyTap: msg.replyToId != null ? () => _scrollToMessage(msg.replyToId) : null, onImageTap: msg.messageType == MessageType.image ? () => _openFullScreenImage(msg) : null, onImageNeeded: _loadImageBytesForMessage, ); }, ), ), ), _buildMessageInput(), ], ), floatingActionButton: _showScrollToEnd ? FloatingActionButton( onPressed: _scrollToBottom, child: const Icon(Icons.keyboard_arrow_down), tooltip: 'Перейти к последнему сообщению', ) : null, ); } String _formatLastOnline(DateTime lastOnline) { final now = DateTime.now(); final difference = now.difference(lastOnline); if (difference.inSeconds < 60) { return 'только что'; } else if (difference.inMinutes < 60) { return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад'; } else if (difference.inHours < 24) { return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад'; } else if (difference.inDays < 7) { return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад'; } else { return 'давно'; } } String _pluralize(int count, String form1, String form2, String form5) { final mod10 = count % 10; final mod100 = count % 100; if (mod10 == 1 && mod100 != 11) { return form1; } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { return form2; } else { return form5; } } 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(); String text = msg.text; if (msg.text.isEmpty && msg.messageType == MessageType.image) { text = "[Фото]"; } setState(() => _replyTo = msg.copyWith(text: text)); }, ), 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), ), ); }, ), if (msg.messageType == MessageType.image) ListTile( leading: const Icon(Icons.save_alt), title: const Text('Сохранить в галерею'), onTap: () { Navigator.of(ctx).pop(); _saveImageToGallery(msg); }, ), 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(); final isImage = msg.messageType == MessageType.image; if (forwardText.isEmpty && !isImage) { 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: const Duration(seconds: 3), ), ); return; } try { final myPrivKey = await _cryptoService.getPrivateKey(); if (myPrivKey == null) { throw Exception('Не найден приватный ключ.'); } final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey, targetContact.publicKey!, ); String contentToEncrypt = forwardText; if (contentToEncrypt.isEmpty && isImage) { contentToEncrypt = ""; } final encryptedContent = await _cryptoService.encryptMessage( contentToEncrypt, sharedSecret, ); final String previewText = forwardText.isNotEmpty ? (forwardText.length > 50 ? forwardText.substring(0, 50) : forwardText) : (isImage ? "[Фото]" : ""); final encryptedContent50 = await _cryptoService.encryptMessage( previewText, sharedSecret, ); String? fileIdToSend; String? encryptedFileKeyToSend; Uint8List? localImageBytes = msg.localFileBytes; if (isImage) { if (msg.fileId != null && msg.encryptedFileKey != null && _currentContact.publicKey != null) { final currentChatSharedSecret = await _cryptoService .deriveSharedSecret(myPrivKey, _currentContact.publicKey!); final originalFileKeyBytes = await _cryptoService.decryptAesKey( msg.encryptedFileKey!, currentChatSharedSecret, ); if (originalFileKeyBytes != null) { final reencryptedKey = await _cryptoService.encryptAesKey( originalFileKeyBytes, sharedSecret, ); if (reencryptedKey != null) { fileIdToSend = msg.fileId; encryptedFileKeyToSend = reencryptedKey; } } } if (fileIdToSend == null || encryptedFileKeyToSend == null) { if (msg.localFileBytes != null) { final imageEncryptResult = await _cryptoService.encryptImage( msg.localFileBytes!, sharedSecret, ); if (imageEncryptResult == null) { throw Exception('Ошибка шифрования пересылаемой картинки'); } fileIdToSend = await apiService.uploadMedia(imageEncryptResult.$1); if (fileIdToSend == null) { throw Exception( 'Не удалось загрузить пересылаемое изображение на сервер', ); } encryptedFileKeyToSend = imageEncryptResult.$2; } } if (fileIdToSend == null || encryptedFileKeyToSend == null) { throw Exception( 'Невозможно переслать изображение: отсутствует шифрованный ключ или файл.', ); } } final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, text: forwardText.isNotEmpty ? forwardText : (isImage ? "[Фото]" : ""), isMe: true, senderId: myId, receiverId: targetContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, localFileBytes: isImage ? localImageBytes : null, messageType: isImage ? MessageType.image : MessageType.text, fileId: fileIdToSend, encryptedFileKey: encryptedFileKeyToSend, ); if (_currentContact.id == targetContact.id) { setState(() { messages.add(localMessage); _pendingImageBytes = null; }); } final payload = { 'type': 'private_message', 'receiver_id': targetContact.id, 'message_type': isImage ? 'image' : 'text', 'content': encryptedContent, 'content50': encryptedContent50, 'temp_id': tempId, if (isImage) ...{ 'file_id': fileIdToSend, 'encrypted_key': encryptedFileKeyToSend, }, }; final ok = Provider.of( context, listen: false, ).sendMessage(payload); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( ok ? 'Сообщение переслано контакту ${targetContact.name}.' : 'Не удалось переслать сообщение.', ), behavior: SnackBarBehavior.floating, // Обязательно для margin margin: const EdgeInsets.only( bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) left: 10.0, right: 10.0, ), duration: const 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; }); Navigator.push( context, MaterialPageRoute( builder: (context) => ChatScreen(contact: targetContact), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка пересылки: $e'), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only( bottom: 80.0 + 10.0, left: 10.0, right: 10.0, ), duration: const 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.isNotEmpty ? _replyTo!.text : (_replyTo!.messageType == MessageType.image ? "[Фото]" : ""), 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, 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 { _sendStopTypingStatus(); 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; String? encryptedReplyToText; // 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, ); if (_replyTo?.id != null && _replyTo!.text.trim().isNotEmpty) { encryptedReplyToText = await _cryptoService.encryptMessage( _replyTo!.text, sharedSecret, ); } // 4. Создаем локальную модель для мгновенного отображения final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, text: rawText, isMe: true, senderId: myId, receiverId: _currentContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, localFileBytes: _pendingImageBytes, messageType: hasImage ? MessageType.image : MessageType.text, fileId: fileId, encryptedFileKey: encryptedFileKey, 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, if (encryptedReplyToText != null) "reply_to_text": encryptedReplyToText, }, }; // 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 { DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; // ACK от сервера: сообщение сохранено и получило server_id if (data['type'] == 'message_sent') { final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); var ts = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); 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() ?? ''); var ts = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); 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() ?? ''); var ts = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); 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() ?? ''); var ts = DateTime.tryParse( data['edited_at']?.toString() ?? '', )?.add(offset); 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; var ts = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); 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') { print('DEBUG incoming private_message raw: $data'); setState(() { _typingTimer?.cancel(); _isTyping = false; }); 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. Добавляем в список и обновляем экран String? encryptedFileKey = data['encrypted_key']?.toString(); Uint8List? decryptedImageBytes; // Lazy load images later 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); } final replyToText = await _decryptReplyText( data['reply_to_text']?.toString(), sharedSecret, ); setState(() { messages.add( MessageModel( id: int.tryParse(data['id']?.toString() ?? ''), text: decryptedText, isMe: false, senderId: senderId, receiverId: myId, createdAt: DateTime.parse(data['timestamp']).add(offset), status: MessageStatus.delivered, replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()), replyToText: replyToText, messageType: data['message_type'] == 'image' ? MessageType.image : MessageType.text, fileId: data['file_id']?.toString(), encryptedFileKey: encryptedFileKey, localFileBytes: decryptedImageBytes, ), ); }); // Save to local DB with cached image bytes try { await _localDbService.saveMessages([messages.last]); } catch (e) { print('Error saving incoming message to DB: $e'); } } catch (e) { print("Ошибка расшифровки входящего сообщения: $e"); } } else { print( "Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате", ); } } if (data['type'] == 'user_online') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId == widget.contact.id) { setState(() => _isOnline = true); } } if (data['type'] == 'user_offline') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId == widget.contact.id) { setState(() { _isOnline = false; _lastOnline = DateTime.now(); }); _loadOnlineStatus(); } } if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) { if (mounted) { setState(() => _isTyping = true); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 4), () { if (mounted) setState(() => _isTyping = false); }); } } if (data['type'] == 'stop_typing' && data['sender_id'] == _currentContact.id) { if (mounted) { setState(() => _isTyping = false); _typingTimer?.cancel(); } } } Future _loadHistory() async { DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); try { print('[DEBUG] Начало загрузки истории'); final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); print('[DEBUG] Ключи получены'); final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); print('[DEBUG] Локальная история загружена: ${cached.length} сообщений'); // Сохраняем кэшированные изображения перед обновлением Map cachedImages = {}; for (var msg in cached) { if (msg['id'] != null && msg['local_file_bytes'] != null) { cachedImages[msg['id'] as int] = msg['local_file_bytes'] as Uint8List; } } 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())?.add(offset); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString())?.add(offset); 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; } } Uint8List? decryptedImageBytes; if (msg['message_type'] == 'image') { decryptedImageBytes = msg['local_file_bytes'] as Uint8List?; } 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']).add(offset), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: await _decryptReplyText( msg['reply_to_text']?.toString(), sharedSecret, ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, messageType: msg['message_type'] == 'image' ? MessageType.image : MessageType.text, fileId: msg['file_id']?.toString(), encryptedFileKey: msg['encrypted_key']?.toString(), localFileBytes: decryptedImageBytes, ), ); } if (cached.isNotEmpty) { if (!mounted) return; setState(() { messages = loadedLocalMessages; _isKeyLoading = false; }); } } catch (e) { print(e); } final history = await apiService.getChatHistory(widget.contact.id); print('[DEBUG] История с сервера загружена: ${history.length} сообщений'); 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())?.add(offset); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString())?.add(offset); 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; } } Uint8List? decryptedImageBytes; // Lazy load images later to avoid downloading all at once 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']).add(offset), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: await _decryptReplyText( msg['reply_to_text']?.toString(), sharedSecret, ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, messageType: msg['message_type'] == 'image' ? MessageType.image : MessageType.text, fileId: msg['file_id']?.toString(), encryptedFileKey: msg['encrypted_key']?.toString(), localFileBytes: cachedImages[int.tryParse(msg['id']?.toString() ?? '')] ?? decryptedImageBytes, ), ); } try { print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД'); //await _localDbService.deleteChatHistory(widget.contact.id, myId); await _localDbService.saveMessages(loadedMessages); print('[DEBUG] История успешно сохранена в локальную БД'); // Восстанавливаем кэшированные изображения for (var entry in cachedImages.entries) { await _localDbService.updateMessageLocalFileBytes(entry.key, entry.value); } } catch (e) { print("[ERROR] Ошибка сохранения истории в локальную базу: $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); } } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; setState(() => _isKeyLoading = false); } } Future _updateScrollButtonVisibility() async { if (!mounted) return; final shouldShow = _scrollController.hasClients && _scrollController.offset > 100; if (shouldShow != _showScrollToEnd) { setState(() { _showScrollToEnd = shouldShow; }); } } Future _scrollToBottom() async { if (!_scrollController.hasClients) return; await _scrollController.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } Future _scrollToMessage(int? messageId) async { if (messageId == null) return; final itemKey = _messageKeys[messageId]; if (itemKey?.currentContext == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Сообщение не найдено для перехода.'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); return; } await Scrollable.ensureVisible( itemKey!.currentContext!, duration: const Duration(milliseconds: 300), alignment: 0.1, curve: Curves.easeInOut, ); } Future _loadImageBytesForMessage(MessageModel msg) async { if (msg.localFileBytes != null) return msg.localFileBytes; if (msg.fileId == null || msg.encryptedFileKey == null) return null; final myPrivKey = await _cryptoService.getPrivateKey(); if (myPrivKey == null) return null; final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey, _currentContact.publicKey!, ); final bytes = await _downloadAndDecryptImage( msg.fileId!, msg.encryptedFileKey!, sharedSecret, ); // Cache the downloaded bytes if (bytes != null && msg.id != null) { try { await _localDbService.updateMessageLocalFileBytes(msg.id!, bytes); // Update in-memory message setState(() { final idx = messages.indexWhere((m) => m.id == msg.id); if (idx != -1) { messages[idx] = messages[idx].copyWith(localFileBytes: bytes); } }); } catch (e) { print('Error caching image bytes: $e'); } } return bytes; } Future _openFullScreenImage(MessageModel msg) async { final bytes = await _loadImageBytesForMessage(msg); if (bytes == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось открыть изображение.'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); return; } if (!mounted) return; Navigator.of(context).push( MaterialPageRoute( builder: (_) => _FullScreenImageScreen(imageBytes: bytes), ), ); } Future _saveImageToGallery(MessageModel msg) async { final bytes = await _loadImageBytesForMessage(msg); if (bytes == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось получить изображение для сохранения.'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); return; } try { await Gal.putImageBytes(bytes); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Изображение сохранено в галерею.'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось сохранить изображение: $e'), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); } } Future _downloadAndDecryptImage( String fileId, String encryptedFileKey, SecretKey sharedSecret, ) async { try { print('DEBUG downloadMedia(fileId=$fileId)'); final bytes = await apiService.downloadMedia(fileId); if (bytes == null) { print('DEBUG downloadMedia returned null for fileId=$fileId'); return null; } print( 'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId', ); final result = await _cryptoService.decryptImage( bytes, encryptedFileKey, sharedSecret, ); print( 'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId', ); return result; } catch (e) { print('Ошибка загрузки и дешифровки медиа: $e'); return null; } } Future _decryptReplyText( String? encryptedReplyText, SecretKey sharedSecret, ) async { if (encryptedReplyText == null) return null; try { return await _cryptoService.decryptMessage( encryptedReplyText, sharedSecret, ); } catch (_) { return encryptedReplyText; } } } class _FullScreenImageScreen extends StatelessWidget { final Uint8List imageBytes; const _FullScreenImageScreen({required this.imageBytes}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, iconTheme: const IconThemeData(color: Colors.white), ), body: Center(child: InteractiveViewer(child: Image.memory(imageBytes))), ); } } class TypingIndicator extends StatefulWidget { const TypingIndicator({super.key}); @override State createState() => _TypingIndicatorState(); } class _TypingIndicatorState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), )..repeat(reverse: true); // Анимация идет туда-сюда } @override void dispose() { _controller.dispose(); super.dispose(); } Widget _buildDot(int index) { return AnimatedBuilder( animation: _controller, builder: (context, child) { // Рассчитываем смещение: только отрицательные значения (вверх) double delay = index * 0.5; // Увеличили задержку для плавности double shift = sin((_controller.value * 2 * pi) + delay); // Используем clamp или abs, чтобы точка не уходила ниже базовой линии double yOffset = (shift < 0 ? shift : 0) * 4; return SizedBox( width: 4, // Фиксированная зона для одной точки height: 5, // Фиксированная высота зоны анимации child: Align( alignment: Alignment.bottomCenter, // Точка всегда прижата к низу child: Container( width: 2, height: 2, decoration: const BoxDecoration( color: Colors.greenAccent, shape: BoxShape.circle, ), transform: Matrix4.translationValues(0, yOffset, 0), ), ), ); }, ); } @override Widget build(BuildContext context) { return SizedBox( height: 12, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(3, (index) => _buildDot(index)), ), ); } }