Chepuhagram/lib/presentation/screens/chat_screen.dart

1149 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
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<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
Uint8List? _pendingImageBytes;
MessageModel? _replyTo;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_loadHistory();
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
Future<void> _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<void> _showMessageActions(MessageModel msg) async {
if (!mounted) return;
await showModalBottomSheet<void>(
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<void> _editMessage(MessageModel msg) async {
final controller = TextEditingController(text: msg.text);
final result = await showDialog<bool>(
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<SocketService>(context, listen: false).sendMessage({
'type': 'edit_message',
'message_id': msg.id,
'content': encryptedContent,
'content50': encryptedContent50,
});
}
}
Future<void> _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<SocketService>(
context,
listen: false,
).sendMessage({'type': 'delete_message', 'message_id': id});
}
}
Future<void> _showForwardContactPicker(MessageModel msg) async {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(myId);
await contactProvider.loadAllContactsForNewChat();
if (!mounted) return;
final selectedContact = await showModalBottomSheet<Contact?>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final provider = context.watch<ContactProvider>();
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<void> _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<SocketService>(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;
});
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: 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<void> _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<void> _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<SocketService>(
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<String, dynamic> 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') {
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<SocketService>(
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']).add(offset),
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<void> _loadHistory() async {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
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<MessageModel> 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;
}
}
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: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: 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 = <int>{};
List<MessageModel> 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;
}
}
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: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null,
),
);
}
try {
await _localDbService.deleteChatHistory(widget.contact.id, myId);
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<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}
}