1149 lines
38 KiB
Dart
1149 lines
38 KiB
Dart
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);
|
||
}
|
||
}
|
||
}
|