Chepuhagram/lib/presentation/screens/chat_screen.dart

655 lines
22 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 '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';
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();
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("Не удалось получить ключ шифрования собеседника"),
),
);
}
}
@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: () {
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();
},
),
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('Скопировано')),
);
},
),
ListTile(
leading: const Icon(Icons.forward),
title: const Text('Переслать'),
onTap: () {
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Пересылка пока не реализована')),
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () async {
Navigator.of(ctx).pop();
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 (_) {}
}
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
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),
),
],
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _inputFocusNode,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: "Напиши сообщение...",
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage();
},
),
],
),
],
),
),
);
}
Future<void> _sendMessage() async {
final rawText = _controller.text.trim();
if (rawText.isEmpty) return;
_controller.clear();
if (_currentContact.publicKey == null) {
await _loadContactKey();
if (_currentContact.publicKey == null) return;
}
try {
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
_currentContact.publicKey!,
);
final encryptedText = await _cryptoService.encryptMessage(
rawText,
sharedSecret,
);
final encryptedText50 = await _cryptoService.encryptMessage(
rawText.length > 50 ? rawText.substring(0, 50) : rawText,
sharedSecret,
);
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,
replyToId: _replyTo?.id,
replyToText: _replyTo?.text,
);
setState(() {
messages.add(localMessage);
});
// Формируем payload для сервера
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"content": encryptedText,
"content50": encryptedText50,
"temp_id": tempId,
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
"reply_to_text": _replyTo!.text,
},
};
// Отправляем
print("ОТПРАВКА: $payload");
final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
if (!mounted) return;
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
_replyTo = null;
});
_controller.clear();
} catch (e) {
_controller.text = rawText;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Ошибка шифрования: $e")));
}
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
// ACK от сервера: сообщение сохранено и получило server_id
if (data['type'] == 'message_sent') {
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
if (tempId == null) return;
if (!mounted) return;
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
messages[idx] = messages[idx].copyWith(
id: serverId ?? messages[idx].id,
createdAt: ts ?? messages[idx].createdAt,
status: MessageStatus.sent,
);
});
return;
}
// Backward compatibility: старый ack мог приходить как message_delivered с temp_id/server_id
if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) {
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
if (tempId == null) return;
if (!mounted) return;
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
messages[idx] = messages[idx].copyWith(
id: serverId ?? messages[idx].id,
createdAt: ts ?? messages[idx].createdAt,
status: MessageStatus.sent,
);
});
return;
}
// Доставка онлайн (получатель был в сети)
if (data['type'] == 'message_delivered') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
if (messageId == null) return;
if (!mounted) return;
setState(() {
for (int i = 0; i < messages.length; i++) {
if (messages[i].id == messageId) {
messages[i] = messages[i].copyWith(status: MessageStatus.delivered);
}
}
});
if (ts != null) {
try {
await _localDbService.updateDeliveredAt(messageId, ts);
} catch (_) {}
}
return;
}
if (data['type'] == 'message_read') {
final messageId = int.tryParse(data['message_id'].toString());
if (messageId == null) return;
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
if (!mounted) return;
setState(() {
for (int i = 0; i < messages.length; i++) {
if (messages[i].id == messageId) {
messages[i] = messages[i].copyWith(status: MessageStatus.read);
}
}
});
if (ts != null) {
try {
await _localDbService.updateReadAt(messageId, ts);
} catch (_) {}
}
return;
}
if (data['type'] == 'private_message') {
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse((data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '');
if (senderId == null || receiverId == null) {
print('Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}');
return;
}
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try {
final myPrivKey = await _cryptoService.getPrivateKey();
// 2. Вычисляем общий секрет для расшифровки
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
// 3. Расшифровываем контент
final decryptedText = await _cryptoService.decryptMessage(
data['content'],
sharedSecret,
);
// 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]);
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) {
Provider.of<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']),
status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()),
replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(),
),
);
});
} catch (e) {
print("Ошибка расшифровки входящего сообщения: $e");
}
} else {
print(
"Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате",
);
// Тут можно добавить логику уведомления для списка чатов
}
}
}
Future<void> _loadHistory() async {
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
try {
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
try {
List<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());
final readAt = msg['read_at'] == null
? null
: DateTime.tryParse(msg['read_at'].toString());
MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent
: MessageStatus.delivered;
if (msg['sender_id'] == myId) {
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
}
loadedLocalMessages.add(
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
),
);
}
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());
final readAt = msg['read_at'] == null
? null
: DateTime.tryParse(msg['read_at'].toString());
MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent
: MessageStatus.delivered;
if (msg['sender_id'] == myId) {
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
}
loadedMessages.insert(
0,
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
),
);
}
try {
await _localDbService.saveMessages(history);
} catch (e) {
print("Ошибка сохранения истории в локальную базу: $e");
}
if (!mounted) return;
setState(() {
messages = loadedMessages;
_isKeyLoading = false;
});
// Отправляем read_receipt для сообщений собеседника, которые уже на экране.
for (final m in loadedMessages) {
if (m.isMe) continue;
final id = m.id;
if (id == null) continue;
if (alreadyReadIncomingMessageIds.contains(id)) continue;
if (_sentReadReceipts.contains(id)) continue;
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}
}