Chepuhagram/lib/presentation/screens/chat_screen.dart

1452 lines
48 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 '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 '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';
import 'dart:io';
class ChatScreen extends StatefulWidget {
final Contact contact;
const ChatScreen({super.key, required this.contact});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> 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<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
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<SocketService>(context, listen: false);
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
_loadLocalName();
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_loadHistory();
_loadOnlineStatus();
startOnlineUpdates();
_controller.addListener(_sendTypingStatus);
final socketService = Provider.of<SocketService>(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<void> _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<SocketService>(context, listen: false);
socketService.sendMessage({
'type': 'typing',
'receiver_id': _currentContact.id,
});
}
}
void _sendStopTypingStatus() {
_socketService.sendMessage({
'type': 'stop_typing',
'receiver_id': _currentContact.id,
});
}
Future<void> _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<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, left: 10.0, right: 10.0),
duration: Duration(seconds: 3),
),
);
}
}
@override
void dispose() {
currentActiveChatContactId = null;
_socketSubscription?.cancel();
_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<ThemeProvider>();
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(
reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return MessageBubble(
message: msg,
onTap: () => _showMessageActions(msg),
);
},
),
),
),
_buildMessageInput(),
],
),
);
}
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<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 {
_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;
// 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') {
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. Добавляем в список и обновляем экран
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), игнорируем в этом чате",
);
}
}
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<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);
}
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
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)),
),
);
}
}