Chepuhagram/lib/presentation/screens/chat_screen.dart

1841 lines
62 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:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
import 'package:chepuhagram/presentation/widgets/message_bubble.dart';
import 'package:gal/gal.dart';
import 'package:chepuhagram/data/repositories/contact_repository.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/logic/contact_provider.dart';
import '../../domain/services/api_service.dart';
import 'dart:math';
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'contacts_screen.dart';
import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
import 'package:image_picker/image_picker.dart';
import '/core/theme_manager.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
const ChatScreen({super.key, required this.contact});
@override
State<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();
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _messageKeys = {};
bool _showScrollToEnd = false;
Uint8List? _pendingImageBytes;
MessageModel? _replyTo;
bool _isOnline = false;
DateTime? _lastOnline;
Timer? _onlineTimer;
DateTime? _lastTypingSent;
bool _isTyping = false;
Timer? _typingTimer;
late SocketService _socketService;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
_socketService = Provider.of<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);
_scrollController.addListener(_updateScrollButtonVisibility);
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();
_scrollController.removeListener(_updateScrollButtonVisibility);
_scrollController.dispose();
_controller.dispose();
routeObserver.unsubscribe(this);
_inputFocusNode.dispose();
_onlineTimer?.cancel();
_typingTimer?.cancel();
_controller.removeListener(_sendTypingStatus);
_sendStopTypingStatus();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<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(
controller: _scrollController,
reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
final keyId = msg.id ?? msg.tempId ?? index;
final itemKey = _messageKeys.putIfAbsent(
keyId,
() => GlobalKey(),
);
return MessageBubble(
key: itemKey,
message: msg,
onTap: () => _showMessageActions(msg),
onReplyTap: msg.replyToId != null
? () => _scrollToMessage(msg.replyToId)
: null,
onImageTap: msg.messageType == MessageType.image
? () => _openFullScreenImage(msg)
: null,
onImageNeeded: _loadImageBytesForMessage,
);
},
),
),
),
_buildMessageInput(),
],
),
floatingActionButton: _showScrollToEnd
? FloatingActionButton(
onPressed: _scrollToBottom,
child: const Icon(Icons.keyboard_arrow_down),
tooltip: 'Перейти к последнему сообщению',
)
: null,
);
}
String _formatLastOnline(DateTime lastOnline) {
final now = DateTime.now();
final difference = now.difference(lastOnline);
if (difference.inSeconds < 60) {
return 'только что';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
} else if (difference.inHours < 24) {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else {
return 'давно';
}
}
String _pluralize(int count, String form1, String form2, String form5) {
final mod10 = count % 10;
final mod100 = count % 100;
if (mod10 == 1 && mod100 != 11) {
return form1;
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return form2;
} else {
return form5;
}
}
Future<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();
String text = msg.text;
if (msg.text.isEmpty && msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(() => _replyTo = msg.copyWith(text: text));
},
),
if (msg.isMe)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Изменить'),
onTap: () {
Navigator.of(ctx).pop();
_editMessage(msg);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Скопировать'),
onTap: () async {
Navigator.of(ctx).pop();
await Clipboard.setData(ClipboardData(text: msg.text));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Скопировано'),
behavior:
SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom:
80.0 +
10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 2),
),
);
},
),
if (msg.messageType == MessageType.image)
ListTile(
leading: const Icon(Icons.save_alt),
title: const Text('Сохранить в галерею'),
onTap: () {
Navigator.of(ctx).pop();
_saveImageToGallery(msg);
},
),
ListTile(
leading: const Icon(Icons.forward),
title: const Text('Переслать'),
onTap: () {
Navigator.of(ctx).pop();
_showForwardContactPicker(msg);
},
),
if (msg.isMe)
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () async {
Navigator.of(ctx).pop();
await _deleteMessage(msg);
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
Future<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();
final isImage = msg.messageType == MessageType.image;
if (forwardText.isEmpty && !isImage) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Нечего пересылать.'),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 5),
),
);
return;
}
if (targetContact.publicKey == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Публичный ключ контакта ${targetContact.name} не найден.',
),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: const Duration(seconds: 3),
),
);
return;
}
try {
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) {
throw Exception('Не найден приватный ключ.');
}
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
targetContact.publicKey!,
);
String contentToEncrypt = forwardText;
if (contentToEncrypt.isEmpty && isImage) {
contentToEncrypt = "";
}
final encryptedContent = await _cryptoService.encryptMessage(
contentToEncrypt,
sharedSecret,
);
final String previewText = forwardText.isNotEmpty
? (forwardText.length > 50
? forwardText.substring(0, 50)
: forwardText)
: (isImage ? "[Фото]" : "");
final encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
String? fileIdToSend;
String? encryptedFileKeyToSend;
Uint8List? localImageBytes = msg.localFileBytes;
if (isImage) {
if (msg.fileId != null &&
msg.encryptedFileKey != null &&
_currentContact.publicKey != null) {
final currentChatSharedSecret = await _cryptoService
.deriveSharedSecret(myPrivKey, _currentContact.publicKey!);
final originalFileKeyBytes = await _cryptoService.decryptAesKey(
msg.encryptedFileKey!,
currentChatSharedSecret,
);
if (originalFileKeyBytes != null) {
final reencryptedKey = await _cryptoService.encryptAesKey(
originalFileKeyBytes,
sharedSecret,
);
if (reencryptedKey != null) {
fileIdToSend = msg.fileId;
encryptedFileKeyToSend = reencryptedKey;
}
}
}
if (fileIdToSend == null || encryptedFileKeyToSend == null) {
if (msg.localFileBytes != null) {
final imageEncryptResult = await _cryptoService.encryptImage(
msg.localFileBytes!,
sharedSecret,
);
if (imageEncryptResult == null) {
throw Exception('Ошибка шифрования пересылаемой картинки');
}
fileIdToSend = await apiService.uploadMedia(imageEncryptResult.$1);
if (fileIdToSend == null) {
throw Exception(
'Не удалось загрузить пересылаемое изображение на сервер',
);
}
encryptedFileKeyToSend = imageEncryptResult.$2;
}
}
if (fileIdToSend == null || encryptedFileKeyToSend == null) {
throw Exception(
'Невозможно переслать изображение: отсутствует шифрованный ключ или файл.',
);
}
}
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: forwardText.isNotEmpty ? forwardText : (isImage ? "[Фото]" : ""),
isMe: true,
senderId: myId,
receiverId: targetContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: isImage ? localImageBytes : null,
messageType: isImage ? MessageType.image : MessageType.text,
fileId: fileIdToSend,
encryptedFileKey: encryptedFileKeyToSend,
);
if (_currentContact.id == targetContact.id) {
setState(() {
messages.add(localMessage);
_pendingImageBytes = null;
});
}
final payload = {
'type': 'private_message',
'receiver_id': targetContact.id,
'message_type': isImage ? 'image' : 'text',
'content': encryptedContent,
'content50': encryptedContent50,
'temp_id': tempId,
if (isImage) ...{
'file_id': fileIdToSend,
'encrypted_key': encryptedFileKeyToSend,
},
};
final ok = Provider.of<SocketService>(
context,
listen: false,
).sendMessage(payload);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? 'Сообщение переслано контакту ${targetContact.name}.'
: 'Не удалось переслать сообщение.',
),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: const EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: const Duration(seconds: 3),
),
);
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
}
_replyTo = null;
});
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(contact: targetContact),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка пересылки: $e'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(
bottom: 80.0 + 10.0,
left: 10.0,
right: 10.0,
),
duration: const Duration(seconds: 5),
),
);
}
}
Widget _buildMessageInput() {
return SafeArea(
// Добавляем SafeArea здесь
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyTo != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.reply, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_replyTo!.text.isNotEmpty
? _replyTo!.text
: (_replyTo!.messageType == MessageType.image
? "[Фото]"
: ""),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _replyTo = null),
),
],
),
),
if (_pendingImageBytes != null)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
_pendingImageBytes!,
fit: BoxFit.cover,
height: 120,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
setState(() => _pendingImageBytes = null),
),
],
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.photo),
onPressed: _pickImage,
),
Expanded(
child: TextField(
controller: _controller,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: "Напиши сообщение...",
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage();
},
),
],
),
],
),
),
);
}
Future<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;
String? encryptedReplyToText;
// 2. Если есть изображение — сначала загружаем его
if (hasImage) {
final encryptionResult = await _cryptoService.encryptImage(
_pendingImageBytes!,
sharedSecret,
);
if (encryptionResult == null) {
throw Exception("Ошибка шифрования медиа");
}
final encryptedFileData = encryptionResult.$1;
final fileKeyForServer = encryptionResult.$2;
fileId = await apiService.uploadMedia(encryptedFileData);
if (fileId == null) throw Exception("Ошибка загрузки файла на сервер");
encryptedFileKey = fileKeyForServer;
}
// 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к фото)
// Если текста нет, но есть фото, отправим пустую строку или "[Фото]"
final String textToEncrypt = rawText.isNotEmpty
? rawText
: (hasImage ? "" : "");
encryptedContent = await _cryptoService.encryptMessage(
textToEncrypt,
sharedSecret,
);
String previewText = rawText.isNotEmpty ? rawText : "[Фото]";
if (previewText.length > 50) previewText = previewText.substring(0, 50);
encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
if (_replyTo?.id != null && _replyTo!.text.trim().isNotEmpty) {
encryptedReplyToText = await _cryptoService.encryptMessage(
_replyTo!.text,
sharedSecret,
);
}
// 4. Создаем локальную модель для мгновенного отображения
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: rawText,
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: _pendingImageBytes,
messageType: hasImage ? MessageType.image : MessageType.text,
fileId: fileId,
encryptedFileKey: encryptedFileKey,
replyToId: _replyTo?.id,
replyToText: _replyTo?.text,
);
setState(() {
messages.add(localMessage);
_pendingImageBytes = null; // Очищаем черновик
});
// 5. Формируем финальный payload для сокета
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"message_type": hasImage ? "image" : "text",
"content": encryptedContent, // Шифрованный текст (подпись)
"content50": encryptedContent50, // Шифрованное превью
"temp_id": tempId,
if (hasImage) ...{
"file_id": fileId,
"encrypted_key": encryptedFileKey, // Зашифрованный AES-ключ файла
},
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
if (encryptedReplyToText != null)
"reply_to_text": encryptedReplyToText,
},
};
// 6. Отправка через сокет
final ok = Provider.of<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') {
print('DEBUG incoming private_message raw: $data');
setState(() {
_typingTimer?.cancel();
_isTyping = false;
});
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse(
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
);
if (senderId == null || receiverId == null) {
print(
'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}',
);
return;
}
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
final isFromPartnerToMe =
senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try {
final myPrivKey = await _cryptoService.getPrivateKey();
// 2. Вычисляем общий секрет для расшифровки
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
// 3. Расшифровываем контент
final decryptedText = await _cryptoService.decryptMessage(
data['content'],
sharedSecret,
);
// 4. Добавляем в список и обновляем экран
String? encryptedFileKey = data['encrypted_key']?.toString();
Uint8List? decryptedImageBytes;
// Lazy load images later
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null &&
!_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(
context,
listen: false,
).sendReadReceipt(serverMessageId);
_sentReadReceipts.add(serverMessageId);
}
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
);
setState(() {
messages.add(
MessageModel(
id: int.tryParse(data['id']?.toString() ?? ''),
text: decryptedText,
isMe: false,
senderId: senderId,
receiverId: myId,
createdAt: DateTime.parse(data['timestamp']).add(offset),
status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null
? null
: int.tryParse(data['reply_to_id'].toString()),
replyToText: replyToText,
messageType: data['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: data['file_id']?.toString(),
encryptedFileKey: encryptedFileKey,
localFileBytes: decryptedImageBytes,
),
);
});
// Save to local DB with cached image bytes
try {
await _localDbService.saveMessages([messages.last]);
} catch (e) {
print('Error saving incoming message to DB: $e');
}
} catch (e) {
print("Ошибка расшифровки входящего сообщения: $e");
}
} else {
print(
"Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате",
);
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.contact.id) {
setState(() => _isOnline = true);
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.contact.id) {
setState(() {
_isOnline = false;
_lastOnline = DateTime.now();
});
_loadOnlineStatus();
}
}
if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) {
if (mounted) {
setState(() => _isTyping = true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 4), () {
if (mounted) setState(() => _isTyping = false);
});
}
}
if (data['type'] == 'stop_typing' &&
data['sender_id'] == _currentContact.id) {
if (mounted) {
setState(() => _isTyping = false);
_typingTimer?.cancel();
}
}
}
Future<void> _loadHistory() async {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
try {
print('[DEBUG] Начало загрузки истории');
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
print('[DEBUG] Ключи получены');
final cached = await _localDbService.getChatHistory(
widget.contact.id,
myId,
);
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
// Сохраняем кэшированные изображения перед обновлением
Map<int, Uint8List> cachedImages = {};
for (var msg in cached) {
if (msg['id'] != null && msg['local_file_bytes'] != null) {
cachedImages[msg['id'] as int] = msg['local_file_bytes'] as Uint8List;
}
}
try {
List<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;
}
}
Uint8List? decryptedImageBytes;
if (msg['message_type'] == 'image') {
decryptedImageBytes = msg['local_file_bytes'] as Uint8List?;
}
loadedLocalMessages.add(
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']).add(offset),
status: status,
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: await _decryptReplyText(
msg['reply_to_text']?.toString(),
sharedSecret,
),
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null,
messageType: msg['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: msg['file_id']?.toString(),
encryptedFileKey: msg['encrypted_key']?.toString(),
localFileBytes: decryptedImageBytes,
),
);
}
if (cached.isNotEmpty) {
if (!mounted) return;
setState(() {
messages = loadedLocalMessages;
_isKeyLoading = false;
});
}
} catch (e) {
print(e);
}
final history = await apiService.getChatHistory(widget.contact.id);
print('[DEBUG] История с сервера загружена: ${history.length} сообщений');
print(history);
final alreadyReadIncomingMessageIds = <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;
}
}
Uint8List? decryptedImageBytes;
// Lazy load images later to avoid downloading all at once
loadedMessages.insert(
0,
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']).add(offset),
status: status,
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: await _decryptReplyText(
msg['reply_to_text']?.toString(),
sharedSecret,
),
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null,
messageType: msg['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: msg['file_id']?.toString(),
encryptedFileKey: msg['encrypted_key']?.toString(),
localFileBytes: cachedImages[int.tryParse(msg['id']?.toString() ?? '')] ?? decryptedImageBytes,
),
);
}
try {
print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД');
//await _localDbService.deleteChatHistory(widget.contact.id, myId);
await _localDbService.saveMessages(loadedMessages);
print('[DEBUG] История успешно сохранена в локальную БД');
// Восстанавливаем кэшированные изображения
for (var entry in cachedImages.entries) {
await _localDbService.updateMessageLocalFileBytes(entry.key, entry.value);
}
} catch (e) {
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
}
if (!mounted) return;
setState(() {
messages = loadedMessages;
_isKeyLoading = false;
});
// Отправляем read_receipt для сообщений собеседника, которые уже на экране.
for (final m in loadedMessages) {
if (m.isMe) continue;
final id = m.id;
if (id == null) continue;
if (alreadyReadIncomingMessageIds.contains(id)) continue;
if (_sentReadReceipts.contains(id)) continue;
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}
Future<void> _updateScrollButtonVisibility() async {
if (!mounted) return;
final shouldShow =
_scrollController.hasClients && _scrollController.offset > 100;
if (shouldShow != _showScrollToEnd) {
setState(() {
_showScrollToEnd = shouldShow;
});
}
}
Future<void> _scrollToBottom() async {
if (!_scrollController.hasClients) return;
await _scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
Future<void> _scrollToMessage(int? messageId) async {
if (messageId == null) return;
final itemKey = _messageKeys[messageId];
if (itemKey?.currentContext == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Сообщение не найдено для перехода.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
await Scrollable.ensureVisible(
itemKey!.currentContext!,
duration: const Duration(milliseconds: 300),
alignment: 0.1,
curve: Curves.easeInOut,
);
}
Future<Uint8List?> _loadImageBytesForMessage(MessageModel msg) async {
if (msg.localFileBytes != null) return msg.localFileBytes;
if (msg.fileId == null || msg.encryptedFileKey == null) return null;
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) return null;
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
_currentContact.publicKey!,
);
final bytes = await _downloadAndDecryptImage(
msg.fileId!,
msg.encryptedFileKey!,
sharedSecret,
);
// Cache the downloaded bytes
if (bytes != null && msg.id != null) {
try {
await _localDbService.updateMessageLocalFileBytes(msg.id!, bytes);
// Update in-memory message
setState(() {
final idx = messages.indexWhere((m) => m.id == msg.id);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(localFileBytes: bytes);
}
});
} catch (e) {
print('Error caching image bytes: $e');
}
}
return bytes;
}
Future<void> _openFullScreenImage(MessageModel msg) async {
final bytes = await _loadImageBytesForMessage(msg);
if (bytes == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось открыть изображение.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _FullScreenImageScreen(imageBytes: bytes),
),
);
}
Future<void> _saveImageToGallery(MessageModel msg) async {
final bytes = await _loadImageBytesForMessage(msg);
if (bytes == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось получить изображение для сохранения.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
try {
await Gal.putImageBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Изображение сохранено в галерею.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Не удалось сохранить изображение: $e'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
}
}
Future<Uint8List?> _downloadAndDecryptImage(
String fileId,
String encryptedFileKey,
SecretKey sharedSecret,
) async {
try {
print('DEBUG downloadMedia(fileId=$fileId)');
final bytes = await apiService.downloadMedia(fileId);
if (bytes == null) {
print('DEBUG downloadMedia returned null for fileId=$fileId');
return null;
}
print(
'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId',
);
final result = await _cryptoService.decryptImage(
bytes,
encryptedFileKey,
sharedSecret,
);
print(
'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId',
);
return result;
} catch (e) {
print('Ошибка загрузки и дешифровки медиа: $e');
return null;
}
}
Future<String?> _decryptReplyText(
String? encryptedReplyText,
SecretKey sharedSecret,
) async {
if (encryptedReplyText == null) return null;
try {
return await _cryptoService.decryptMessage(
encryptedReplyText,
sharedSecret,
);
} catch (_) {
return encryptedReplyText;
}
}
}
class _FullScreenImageScreen extends StatelessWidget {
final Uint8List imageBytes;
const _FullScreenImageScreen({required this.imageBytes});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(child: InteractiveViewer(child: Image.memory(imageBytes))),
);
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<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)),
),
);
}
}