Chepuhagram/lib/presentation/screens/chat_screen.dart

4958 lines
177 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:ui';
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import 'package:open_filex/open_filex.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 'package:flutter/rendering.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 '/core/theme_manager.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:file_picker/file_picker.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'camera_screen.dart';
import 'media_viewer_screen.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:path/path.dart' as p;
import 'package:record/record.dart';
import 'package:camera/camera.dart';
import 'package:ffmpeg_kit_flutter_new_min_gpl/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_min_gpl/return_code.dart';
import '../screens/forward_contact_picker_screen.dart';
import 'call_screen.dart';
import 'package:drift/drift.dart' as drift;
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
final void Function(Contact contact)? onOpenProfile;
final VoidCallback? onBack;
final bool showBackButton;
final void Function(int contactId, int? nextUnreadId)? onMessageRead;
const ChatScreen({
super.key,
required this.contact,
this.onOpenProfile,
this.onBack,
this.showBackButton = true,
this.onMessageRead,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> with RouteAware {
static const String _notificationLaunchKey = 'notification_launch_data';
static const int _autoMediaLoadLimitBytes = 10 * 1024 * 1024; // 10MB
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 ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
final Map<int, MessageModel> _messageMap = {};
final ValueNotifier<bool> _showScrollButtonNotifier = ValueNotifier<bool>(
false,
);
MessageModel? _replyTo;
bool _isOnline = false;
DateTime? _lastOnline;
Timer? _onlineTimer;
DateTime? _lastTypingSent;
bool _isTyping = false;
Timer? _typingTimer;
late SocketService _socketService;
MessageType _pendingMessageType = MessageType.text;
String? _pendingFileName;
File? _pendingFile;
Uint8List? _previewBytes;
final ValueNotifier<double> _inputBarHeightNotifier = ValueNotifier<double>(
64.0,
);
SecretKey? _chatSharedSecret;
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
// Состояния для аудио/видео записи
bool _isRecording = false;
bool _isRecordLocked = false; // Режим "замок" (свайп вверх)
bool _isVoiceMode = true; // true - голосовое, false - кружок
double _recordDragX = 0.0; // Для отслеживания свайпа влево (отмена)
double _recordDragY = 0.0; // Для отслеживания свайпа вверх (замок)
// Дополнительно для UI анимаций (опционально, сколько протащили для отмены)
static const double _swipeCancelThreshold =
-80.0; // Порог свайпа влево для отмены
static const double _swipeLockThreshold =
-80.0; // Порог свайпа вверх для лока
final AudioRecorder _audioRecorder = AudioRecorder();
final Stopwatch _stopwatch = Stopwatch();
Timer? _stopwatchTimer;
String _stopwatchDisplay = "0:00";
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
bool _isFlashOverlayVisible = false;
int _limitBefore = 20;
int _limitAfter = 20;
bool _isLoadingOlder = false;
bool _isLoadingNewer = false;
bool _hasMoreOlder = true;
bool _hasMoreNewer = false;
bool _suppressPagination = true;
bool _isFirstSizeMeasurement = true;
int? _initialAnchorId;
bool _isReadyForReading = false;
@override
void initState() {
super.initState();
messages.clear();
_isKeyLoading = true;
_currentContact = widget.contact;
_socketService = Provider.of<SocketService>(context, listen: false);
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
_cancelChatNotifications();
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
_loadLocalName();
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_initialLoad();
_loadOnlineStatus();
startOnlineUpdates();
_controller.addListener(_sendTypingStatus);
_itemPositionsListener.itemPositions.addListener(
_updateScrollButtonVisibility,
);
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
_initCameras();
}
Future<void> _initialLoad() async {
// Вызываем обновленный метод загрузки истории
await _loadHistory();
}
@override
void didUpdateWidget(covariant ChatScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contact.id != oldWidget.contact.id) {
setState(() {
_currentContact = widget.contact;
// Обязательно очищаем старый чат и сбрасываем триггеры пагинации
messages.clear();
_suppressPagination = true;
_hasMoreOlder = true;
_hasMoreNewer = false;
});
currentActiveChatContactId = _currentContact.id;
_cancelChatNotifications();
_loadLocalName();
if (_currentContact.publicKey == null) {
_loadContactKey();
}
// Вызываем правильный метод инициализации, который найдет unreadMessageId
_initialLoad();
_loadOnlineStatus();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
print("Пользователь вернулся на этот экран!");
_loadLocalName();
_cancelChatNotifications();
}
Future<void> _cancelChatNotifications() async {
try {
await flutterLocalNotificationsPlugin.cancel(
id: currentActiveChatContactId!,
);
} catch (e) {
print('Delete notifications for this chat wasnt succesful: $e');
}
}
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;
}
});
}
}
// Инициализация камер устройства для кружочков
Future<void> _initCameras() async {
try {
_cameras = await availableCameras();
} catch (e) {
debugPrint("Ошибка инициализации камер: $e");
}
}
// Секундомер
void _startStopwatch() {
_stopwatch.reset();
_stopwatch.start();
_stopwatchTimer = Timer.periodic(const Duration(milliseconds: 500), (
timer,
) {
if (_stopwatch.isRunning) {
setState(() {
final elapsed = _stopwatch.elapsed;
String minutes = (elapsed.inMinutes % 60).toString();
String seconds = (elapsed.inSeconds % 60).toString().padLeft(2, '0');
_stopwatchDisplay = "$minutes:$seconds";
});
}
});
}
void _stopStopwatch() {
_stopwatch.stop();
_stopwatchTimer?.cancel();
setState(() {
_stopwatchDisplay = "0:00";
});
}
// СТАРТ ЗАПИСИ
Future<void> _startRecording() async {
HapticFeedback.lightImpact();
_startStopwatch();
setState(() {
_isRecording = true;
_isRecordLocked = false;
_recordDragX = 0.0;
_recordDragY = 0.0;
_isFlashOverlayVisible = false;
});
try {
if (_isVoiceMode) {
if (await _audioRecorder.hasPermission()) {
final directory = await getTemporaryDirectory();
final path =
'${directory.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _audioRecorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: path,
);
}
} else {
// Режим кружочка (видео) — Единый нативный плагин для мобилок и Windows
if (Platform.isAndroid || Platform.isIOS) {
final cameraStatus = await Permission.camera.request();
final micStatus = await Permission.microphone.request();
if (!cameraStatus.isGranted || !micStatus.isGranted) {
await _cancelRecording();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Необходимы разрешения на камеру и микрофон"),
),
);
return;
}
}
if (_cameras == null || _cameras!.isEmpty) {
_cameras = await availableCameras();
}
if (_cameras != null && _cameras!.isNotEmpty) {
if (_cameraController != null) {
print("Обнаружен зависший контроллер. Принудительно очищаем...");
await _cameraController!.dispose();
_cameraController = null;
// 💡 ПРЕДОХРАНИТЕЛЬ ДЛЯ WINDOWS: даем ОС 250 мс, чтобы закрыть handle железки
await Future.delayed(const Duration(milliseconds: 250));
}
// Находим фронталку, а если её нет (или мы на ПК) — берем первую доступную камеру
final defaultCamera = _cameras!.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.front,
orElse: () => _cameras!.first,
);
_cameraController = CameraController(
defaultCamera,
ResolutionPreset.medium,
enableAudio: true,
);
await _cameraController!.initialize();
if (mounted) {
setState(() {
_isCameraInitialized = true;
});
// Даем камере стабилизировать сессию перед стартом записи
await Future.delayed(const Duration(milliseconds: 100));
if (_cameraController != null &&
_cameraController!.value.isInitialized) {
await _cameraController!.startVideoRecording();
}
}
} else {
throw Exception("Камеры не обнаружены в системе.");
}
}
} catch (e) {
debugPrint("Ошибка старта записи: $e");
}
}
// ФИКСАЦИЯ НА ЗАМОК
void _lockRecording() {
HapticFeedback.mediumImpact();
setState(() {
_isRecordLocked = true;
});
}
// ОТМЕНА ЗАПИСИ
Future<void> _cancelRecording() async {
HapticFeedback.heavyImpact();
_stopStopwatch();
setState(() {
_isRecording = false;
_isRecordLocked = false;
_isCameraInitialized = false;
_isFlashOverlayVisible = false;
});
try {
if (_isVoiceMode) {
await _audioRecorder.stop();
} else {
if (_cameraController != null) {
if (_cameraController!.value.isRecordingVideo) {
await _cameraController!.stopVideoRecording();
}
await _cameraController!.dispose();
_cameraController = null; // Освобождаем девайс
}
}
} catch (e) {
debugPrint("Ошибка при отмене записи: $e");
}
}
Future<void> _stopAndSendRecording() async {
if (!_isRecording) return;
_stopStopwatch();
String? filePath;
try {
if (_isVoiceMode) {
filePath = await _audioRecorder.stop();
} else {
if (_cameraController != null &&
_cameraController!.value.isRecordingVideo) {
print("Останавливаем видео через stopVideoRecording...");
// Используем timeout, чтобы не зависнуть вечно
final XFile videoFile = await _cameraController!
.stopVideoRecording()
.timeout(
const Duration(seconds: 3),
onTimeout: () =>
throw Exception("Timeout при остановке камеры"),
);
filePath = videoFile.path;
print("Видео сохранено по пути: $filePath");
}
}
} catch (e) {
debugPrint("Критическая ошибка при остановке записи: $e");
} finally {
// В любом случае освобождаем ресурсы камеры
if (_cameraController != null) {
await _cameraController!.dispose();
_cameraController = null;
}
// Обновляем состояние UI один раз
if (mounted) {
setState(() {
_isFlashOverlayVisible = false;
_isRecording = false;
_isRecordLocked = false;
_isCameraInitialized = false;
});
}
}
// Отправка файла, если он был успешно получен
if (filePath != null && await File(filePath).exists()) {
setState(() {
_pendingFile = File(filePath!);
_pendingFileName = _isVoiceMode ? "Голосовое.m4a" : "Видео.mp4";
_pendingMessageType = _isVoiceMode
? MessageType.voiceNote
: MessageType.videoNote;
});
_sendMessage();
}
}
void _toggleRecordMode() {
if (_isRecording) return;
setState(() {
_isVoiceMode = !_isVoiceMode;
});
}
void _updateMessageInList(
int messageId,
MessageModel Function(MessageModel) updater,
) {
if (!_messageMap.containsKey(messageId)) return;
final oldMessage = _messageMap[messageId]!;
final newMessage = updater(oldMessage);
setState(() {
_messageMap[messageId] = newMessage;
final idx = messages.indexWhere((m) => m.id == messageId);
if (idx != -1) messages[idx] = newMessage;
});
}
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 {
if (currentActiveChatContactId == null) return;
try {
await flutterLocalNotificationsPlugin.cancel(
id: currentActiveChatContactId!,
);
} catch (e) {
print('Delete notifications for this chat wasnt succesful: $e');
}
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) {
final parsed = DateTime.tryParse(data['last_online']);
_lastOnline = parsed != null ? parsed.add(offset) : null;
} 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),
),
);
}
}
String _getMediaPreview(MessageType type) {
switch (type) {
case MessageType.videoNote:
return '[Кружок]';
case MessageType.voiceNote:
return '[Голосовое]';
case MessageType.image:
return '[Фото]';
case MessageType.video:
return '[Видео]';
case MessageType.file:
return '[Файл]';
case MessageType.text:
return '';
}
}
MessageType _parseMessageTypeString(String? typeStr) {
switch (typeStr?.toLowerCase()) {
case 'voicenote':
return MessageType.voiceNote;
case 'videonote':
return MessageType.videoNote;
case 'image':
return MessageType.image;
case 'video':
return MessageType.video;
case 'file':
return MessageType.file;
case 'text':
default:
return MessageType.text;
}
}
@override
void dispose() {
currentActiveChatContactId = null;
_socketSubscription?.cancel();
_controller.dispose();
for (final n in _messageProgressNotifiers.values) {
n.dispose();
}
routeObserver.unsubscribe(this);
_inputFocusNode.dispose();
_onlineTimer?.cancel();
_typingTimer?.cancel();
_controller.removeListener(_sendTypingStatus);
_sendStopTypingStatus();
_audioRecorder.dispose();
_cameraController?.dispose();
_stopwatchTimer?.cancel();
_stopwatch.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: colorScheme.surface,
appBar: AppBar(
backgroundColor: colorScheme.surfaceContainer,
elevation: 0,
scrolledUnderElevation: 0,
leadingWidth: widget.showBackButton ? 64 : 16,
leading: widget.showBackButton
? Center(
child: Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ClipOval(
child: Material(
color: Colors.transparent,
child: IconButton(
icon: const Icon(Icons.arrow_back_rounded, size: 20),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
),
),
),
)
: const SizedBox.shrink(),
title: Consumer<ContactProvider>(
builder: (context, contactProvider, child) {
final freshContact = contactProvider.contacts.firstWhere(
(c) => c.id == widget.contact.id,
orElse: () => widget.contact,
);
final bool currentOnline = freshContact.isOnline || _isOnline;
final String subtitleText = _currentContact.id == 0 ? 'Системные уведомления' : currentOnline
? 'в сети'
: (_lastOnline != null
? 'был(а) ${_formatLastOnline(_lastOnline!)}'
: 'был(а) недавно');
String fName = _currentContact.name;
if (fName.toLowerCase() == 'unknown' ||
fName.toLowerCase() == 'uncnown' ||
fName == 'null') {
fName = 'Без имени';
}
String lName = _currentContact.surname;
if (lName.toLowerCase() == 'unknown' ||
lName.toLowerCase() == 'uncnown' ||
lName == 'null') {
lName = '';
}
final String cleanFullName = '$fName $lName'.trim();
final contactInitials = cleanFullName.isNotEmpty
? cleanFullName
.trim()
.split(RegExp(r'\s+'))
.take(2)
.map((e) => e[0].toUpperCase())
.join()
: '?';
return InkWell(
onTap: () {
if (widget.onOpenProfile != null) {
widget.onOpenProfile!(freshContact);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => UserProfileScreen(
userId: freshContact.id,
username: freshContact.username,
name: cleanFullName,
),
),
).then(
(_) => _loadLocalName(),
); // Обновляем имя при возвращении с экрана профиля
}
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer,
),
child: ClipOval(
child: Stack(
alignment: Alignment.center,
children: [
Text(
contactInitials,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
if (freshContact.avatarUrl != null)
CachedNetworkImage(
imageUrl: freshContact.avatarUrl!,
fit: BoxFit.cover,
width: 40,
height: 40,
placeholder: (context, url) =>
const SizedBox.shrink(),
errorWidget: (context, url, error) =>
const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// ФИКС ИМЕНИ: Читаем из стабильной переменной _currentContact
Text(
cleanFullName,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// ФИКС ВРЕМЕНИ ОНЛАЙНА: Выводим точное рассчитанное время
Text(
subtitleText,
style: TextStyle(
fontSize: 12,
fontWeight: currentOnline
? FontWeight.bold
: FontWeight.normal,
color: currentOnline
? Colors.green.shade400
: Theme.of(context).colorScheme.outline,
),
),
],
),
),
],
),
),
);
},
),
),
body: Stack(
children: [
if (themeProv.wallpaperPath != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: MediaQuery.of(context).size.height,
child: RepaintBoundary(
child: Image.file(
File(themeProv.wallpaperPath!),
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
),
Positioned.fill(
child: ValueListenableBuilder<double>(
valueListenable: _inputBarHeightNotifier,
builder: (context, inputBarHeight, child) {
final double bottomPadding = MediaQuery.of(
context,
).padding.bottom;
final double effectiveInputHeight = _currentContact.id == 0 ? 0.0 : inputBarHeight;
return ScrollablePositionedList.builder(
padding: EdgeInsets.only(
bottom: bottomPadding + effectiveInputHeight + 20.0,
left: 8,
right: 8,
top: 8,
),
itemScrollController: _itemScrollController,
itemPositionsListener: _itemPositionsListener,
reverse: true,
// Вычисляем общее количество элементов (сообщения + лоадеры)
itemCount:
messages.length +
(_hasMoreOlder ? 1 : 0) +
(_hasMoreNewer ? 1 : 0),
itemBuilder: (context, index) {
// 1. НИЖНИЙ ЛОАДЕР (если есть куда листать вниз)
if (_hasMoreNewer && index == 0) {
return _buildLoader(); // Вынесите лоадер в отдельный метод
}
final int messageIndexInSliver = _hasMoreNewer
? index - 1
: index;
// 2. ВЕРХНИЙ ЛОАДЕР (если есть куда листать вверх)
if (_hasMoreOlder &&
messageIndexInSliver == messages.length) {
return _buildLoader();
}
// 3. ОТРИСОВКА СООБЩЕНИЯ
final msg =
messages[messages.length - 1 - messageIndexInSliver];
final isMedia =
msg.messageType == MessageType.image ||
msg.messageType == MessageType.video ||
msg.messageType == MessageType.file;
final showDateDivider = _isNewDay(messageIndexInSliver);
Widget itemChild = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (showDateDivider) _buildDateDivider(msg.createdAt),
Dismissible(
direction: DismissDirection.endToStart,
key: ValueKey<String>(
'dismiss_${msg.id ?? msg.tempId}',
),
confirmDismiss: (direction) async {
setState(() => _replyTo = msg);
return false;
},
child: MessageBubble(
key: ValueKey(
'${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}',
),
message: msg,
onReplyTap: msg.replyToId != null
? () => _scrollToMessage(msg.replyToId)
: null,
onForwardTap: () => _showForwardContactPicker(msg),
onReplyToTap: () {
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(
() => _replyTo = msg.copyWith(text: text),
);
},
onImageTap: () => _openFullScreenMedia(msg),
onEditTap: () => _editMessage(msg),
onDeleteTap: () => _deleteMessage(msg),
onDownloadRequested: (m) async {
await _ensureFileDecrypted(m, dontLoad: false);
},
onDownloadRequestedWithoutLoad: (m) async {
await _ensureFileDecrypted(m, dontLoad: true);
},
autoLoadMedia: msg.messageType != MessageType.image
? true
: (msg.fileSize == null ||
msg.fileSize! <=
_autoMediaLoadLimitBytes),
downloadProgress:
_messageProgressNotifiers['${msg.fileId}'],
onDownloadStoped: (m) async {
await _stopFileLoading(msg);
},
onShowInExplorer: (m) async =>
await _showInExplorer(m),
onSaveToGallery: (m) async =>
await _saveMediaToGallery(m),
onSaveToDownloads: (m) async =>
await _saveFileToDownloads(m),
),
),
],
);
// VisibilityDetector для чтения сообщений
return VisibilityDetector(
key: ValueKey('visible_${msg.id ?? msg.tempId}'),
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.35) {
if (!msg.isMe && msg.status != MessageStatus.read) {
_markAsRead(msg);
}
}
},
child: itemChild,
);
},
);
},
),
),
// 4. Плавающее поле ввода
Positioned(
left: 0,
right: 0,
bottom: 16,
child: SafeArea(
// SafeArea теперь СНАРУЖИ, его изменения не триггерят измерение
top: false,
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 2),
child: _currentContact.id != 0
? ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
color: Theme.of(
context,
).colorScheme.surfaceVariant.withOpacity(0.5),
child: _MeasureSize(
// ИЗМЕРЯЕМ СТРОГО ВНУТРЕННОСТЬ поля ввода
onChange: (size) {
// Теперь этот колбэк сработает только если текст перепрыгнет на новую строку!
if (_inputBarHeightNotifier.value !=
size.height) {
_inputBarHeightNotifier.value = size.height;
}
},
child: _buildMessageInput(),
),
),
),
)
: const SizedBox.shrink(),
),
),
// 3. Кнопка скролла с плавным размытием
ValueListenableBuilder<double>(
valueListenable: _currentContact.id == 0
? ValueNotifier<double>(0)
: _inputBarHeightNotifier,
builder: (context, inputBarHeight, child) {
final systemBottomPadding = MediaQuery.of(context).padding.bottom;
final safeAreaBottom = systemBottomPadding > 2.0
? systemBottomPadding
: 2.0;
final totalPanelHeight = 16.0 + safeAreaBottom + inputBarHeight;
return Positioned(
right: 16.0,
// Кнопка встанет строго над верхней границей панели ввода с красивым отступом
bottom: totalPanelHeight + 12.0,
child: child!,
);
},
child: ValueListenableBuilder<bool>(
valueListenable: _showScrollButtonNotifier,
builder: (context, showButton, _) {
return AnimatedOpacity(
opacity: showButton ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: IgnorePointer(
ignoring: !showButton,
child: Container(
// Ограничиваем размеры контейнера для тени
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: ClipOval(
// СТРОГО ОБЯЗАТЕЛЬНО: обрезаем область размытия
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
color: Theme.of(
context,
).colorScheme.surfaceVariant.withOpacity(0.75),
child: IconButton(
onPressed: _scrollToBottom,
icon: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
),
),
);
},
),
),
if (_isRecording &&
!_isVoiceMode &&
_isCameraInitialized &&
(_cameraController != null || Platform.isWindows))
Positioned.fill(
child: Stack(
children: [
if (_isFlashOverlayVisible &&
_cameraController!.description.lensDirection ==
CameraLensDirection.front)
IgnorePointer(
child: Container(color: Colors.white.withOpacity(0.75)),
),
// 1. Слой с кружком камеры
Center(
child: Builder(
builder: (context) {
final double shortestSide = math.min(
MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height,
);
final double circleSize = (shortestSide * 0.75).clamp(
240.0,
360.0,
);
return SizedBox(
width: circleSize,
height: circleSize,
child: ClipOval(
child:
_cameraController != null &&
_cameraController!.value.isInitialized
? FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: circleSize,
height: circleSize,
child: CameraPreview(_cameraController!),
),
)
: Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(),
),
),
),
);
},
),
),
// 2. Панель управления (слева, над полем ввода)
Positioned(
bottom: 120,
left: 16,
child: _buildGlassPanel(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: Icon(
Icons.cameraswitch_rounded,
color: colorScheme.primary,
size: 20,
),
onPressed: _switchCamera,
),
IconButton(
color: colorScheme.primary,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: Icon(
_isFlashOverlayVisible
? Icons.flash_on_rounded
: Icons.flash_off_rounded,
size: 20,
),
onPressed: _toggleFlash,
),
],
),
),
),
Positioned(
bottom: 120,
left: 0,
right: 0,
child: Center(
child: _buildGlassPanel(
child: TextButton(
onPressed: _cancelRecording,
child: const Text(
'Отмена',
style: TextStyle(fontSize: 15),
),
),
),
),
),
// 3. Замок (справа, над кнопкой записи)
if (!_isRecordLocked)
Positioned(
right: 26,
bottom: 120,
child: _buildGlassPanel(
child: Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.lock_open_rounded,
size: 20,
color: colorScheme.primary,
),
),
),
),
],
),
),
],
),
);
}
Widget _buildLoader() => Container(
padding: const EdgeInsets.symmetric(vertical: 20),
alignment: Alignment.center,
child: const CircularProgressIndicator(strokeWidth: 2),
);
Widget _buildDateDivider(DateTime date) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withOpacity(0.75),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDividerDate(date),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _buildGlassPanel({required Widget child}) {
return ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceVariant.withOpacity(0.5),
),
child: child,
),
),
);
}
Future<void> _toggleFlash() async {
if (_cameraController == null) return;
// 2. Переключаем аппаратную вспышку (фонарик)
try {
final newMode = _isFlashOverlayVisible ? FlashMode.off : FlashMode.torch;
// 1. Переключаем программную заливку экрана
setState(() {
_isFlashOverlayVisible = !_isFlashOverlayVisible;
});
await _cameraController!.setFlashMode(newMode);
} catch (e) {
debugPrint("Ошибка при переключении аппаратной вспышки: $e");
}
// Обновляем UI, чтобы иконка кнопки тоже изменилась
if (mounted) setState(() {});
}
Future<void> _switchCamera() async {
if (_cameraController == null || _cameras == null || _cameras!.length < 2) {
return;
}
setState(() {
_isFlashOverlayVisible = false;
});
final currentDirection = _cameraController!.description.lensDirection;
final newCamera = _cameras!.firstWhere(
(c) => c.lensDirection != currentDirection,
);
await _cameraController!.setDescription(newCamera);
await _cameraController!.setFlashMode(FlashMode.off);
if (mounted) setState(() {});
}
void _initiateCall(BuildContext context) {
// Отправляем сигнал на сервер
SocketService().sendMessage({
"type": "call_init",
"receiver_id": widget.contact.id,
});
}
bool _isNewDay(int currentIndex) {
final int realIndex = messages.length - 1 - currentIndex;
if (realIndex == 0) return true;
final currentMsgTime = messages[realIndex].createdAt;
final previousMsgTime = messages[realIndex - 1].createdAt;
return currentMsgTime.year != previousMsgTime.year ||
currentMsgTime.month != previousMsgTime.month ||
currentMsgTime.day != previousMsgTime.day;
}
// Форматирование даты для плашки
String _formatDividerDate(DateTime date) {
final now = DateTime.now();
if (date.year == now.year &&
date.month == now.month &&
date.day == now.day) {
return "Сегодня";
}
final yesterday = now.subtract(const Duration(days: 1));
if (date.year == yesterday.year &&
date.month == yesterday.month &&
date.day == yesterday.day) {
return "Вчера";
}
const months = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
];
return "${date.day} ${months[date.month - 1]} ${date.year != now.year ? date.year : ''}"
.trim();
}
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 if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
} 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: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop();
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(() => _replyTo = msg.copyWith(text: text));
});
},
),
ListTile(
leading: const Icon(Icons.forward),
title: const Text('Переслать'),
onTap: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop();
_showForwardContactPicker(msg);
});
},
),
if (msg.isMe)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Изменить'),
onTap: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop();
_editMessage(msg);
});
},
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Скопировать'),
onTap: () async {
WidgetsBinding.instance.addPostFrameCallback((_) 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 ||
msg.messageType == MessageType.video ||
msg.messageType == MessageType.videoNote)
ListTile(
leading: const Icon(Icons.save_alt),
title: const Text('Сохранить в галерею'),
onTap: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop();
_saveMediaToGallery(msg);
});
},
),
/*if (msg.messageType == MessageType.image ||
msg.messageType == MessageType.video ||
msg.messageType == MessageType.file ||
msg.messageType == MessageType.videoNote ||
msg.messageType == MessageType.voiceNote)
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить локальный файл'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () {
Navigator.of(ctx).pop();
_deleteLocalFile(msg);
},
),*/
if (msg.isMe)
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () async {
WidgetsBinding.instance.addPostFrameCallback((_) 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: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop(false);
});
},
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
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 > 500 ? newText.substring(0, 500) : 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});
} else if (msg.tempId != null) {
try {
await _localDbService.deleteMessage(msg.tempId!);
} catch (_) {}
}
}
Future<void> _showForwardContactPicker(MessageModel msg) async {
// Открываем новый красивый экран выбора вместо bottomSheet
final selectedContact = await Navigator.of(context).push<Contact?>(
MaterialPageRoute(
builder: (context) => ForwardContactPickerScreen(message: msg),
),
);
// Если контакт был выбран и нажата кнопка «Продолжить»
if (selectedContact != null && mounted) {
// Запускаем твою готовую и исправленную функцию пересылки медиа/текста
await _forwardMessage(msg, selectedContact);
}
}
Future<void> _forwardMessage(
MessageModel originalMsg,
Contact targetContact,
) async {
try {
final isSameChat = _currentContact.id == targetContact.id;
String? newFileId;
String? newEncryptedKey;
File? newLocalFile;
final tempId = DateTime.now().millisecondsSinceEpoch;
// 1. E2EE Защита: Если публичного ключа нет в объекте, пробуем запросить его у сервера
String? targetPublicKey = targetContact.publicKey;
if (originalMsg.fileId != null &&
(targetPublicKey == null || targetPublicKey.isEmpty)) {
debugPrint(
"==> [Forward] У контакта нет публичного ключа в кэше. Запрашиваем с сервера...",
);
try {
// Вызываем метод твоего API для получения свежих данных пользователя
final freshContact = await apiService.getUserByUsername(
targetContact.username,
);
if (freshContact != null && freshContact.publicKey != null) {
targetPublicKey = freshContact.publicKey;
targetContact.publicKey =
freshContact.publicKey; // Обновляем инстанс в памяти
}
} catch (e) {
debugPrint(
"==> [Forward] Не удалось запросить ключ получателя с сервера: $e",
);
}
}
// 2. Если есть медиа — обрабатываем на сервере и перешифровываем ключи
if (originalMsg.fileId != null) {
debugPrint(
"==> [Forward] Старт копирования медиа. fileId: ${originalMsg.fileId}",
);
final copiedFileId = await apiService.copyMediaOnServer(
originalMsg.fileId!,
targetContact.id,
);
if (copiedFileId == null) {
throw Exception("Сервер отказал в копировании файла");
}
newFileId = copiedFileId;
// Копируем локальный файл асинхронно, дожидаясь (await) завершения
if (originalMsg.localFile != null) {
final directory = await getApplicationDocumentsDirectory();
// Сохраняем строго под префиксом file_, который ожидает MessageBubble
final decFile = p.join(directory.path, 'dec_$copiedFileId');
try {
if (await originalMsg.localFile!.exists()) {
newLocalFile = await originalMsg.localFile!.copy(decFile);
print(
"Локальный файл для пересылки создан по пути: ${newLocalFile.path}",
);
} else {
debugPrint(
"Исходный файл для пересылки отсутствует, пропускаю локальное копирование: ${originalMsg.localFile!.path}",
);
}
} catch (e) {
debugPrint("Ошибка копирования файла для пересылки: $e");
}
} else if (originalMsg.fileId != null) {
final directory = await getApplicationDocumentsDirectory();
// Сохраняем строго под префиксом file_, который ожидает MessageBubble
final decFile = p.join(directory.path, 'dec_$copiedFileId');
final File oldFile = File(
p.join(directory.path, 'dec_${originalMsg.fileId!}'),
);
newLocalFile = await oldFile.copy(decFile);
print(
"Локальный файл для пересылки создан через id по пути: ${newLocalFile.path}",
);
} else {
print(
"Невозможно создать локальную копию файла для пересылки: отсутствует и локальный файл, и fileId.",
);
}
final sharedPrefs = await SharedPreferences.getInstance();
final String sizeKey = 'valid_dec_size_$copiedFileId';
final finalFileSize = await newLocalFile?.length();
if (finalFileSize != null && finalFileSize > 0) {
// Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл
await sharedPrefs.setInt(sizeKey, finalFileSize);
debugPrint(
"Файл успешно загружен. Размер сохранен: $finalFileSize байт.",
);
}
// Проверяем условия для криптографии
final myPrivKey = await _cryptoService.getPrivateKey();
if (originalMsg.encryptedFileKey == null) {
throw Exception(
"У оригинального сообщения отсутствует ключ шифрования файла.",
);
}
if (targetPublicKey == null || targetPublicKey.isEmpty) {
throw Exception(
"Невозможно переслать медиа: у получателя отсутствует публичный ключ шифрования E2EE.",
);
}
final oldSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
_currentContact.publicKey!,
);
final newSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
targetPublicKey,
);
final decryptedKey = await _cryptoService.decryptAesKey(
originalMsg.encryptedFileKey!,
oldSecret,
);
if (decryptedKey == null) {
throw Exception("Не удалось расшифровать ключ файла для пересылки");
}
newEncryptedKey = await _cryptoService.encryptAesKey(
decryptedKey,
newSecret,
);
}
// 3. Отрисовываем сообщение в текущем чате (если пересылаем сами себе)
// Теперь localFile передается сразу, предотвращая ложные индикаторы загрузки
print(
"Перед добавлением пересланного сообщения в UI: newFileId=$newFileId, newEncryptedKey=${newEncryptedKey != null ? 'есть' : 'нет'}, newLocalFile=${newLocalFile != null ? 'есть' : 'нет'}",
);
if (isSameChat) {
final localMsg = MessageModel(
tempId: tempId,
text: originalMsg.text,
isMe: true,
senderId: myId,
receiverId: targetContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
messageType: originalMsg.messageType,
fileId: newFileId ?? originalMsg.fileId,
fileName: originalMsg.fileName,
localFile: newLocalFile,
fileSize: originalMsg.fileSize,
encryptedFileKey: newEncryptedKey ?? originalMsg.encryptedFileKey,
);
setState(() {
messages.add(localMsg);
});
_scrollToBottom();
}
// 4. Шифруем текстовую часть контента
if (targetPublicKey == null || targetPublicKey.isEmpty) {
throw Exception(
"У получателя отсутствует публичный ключ для шифрования текста.",
);
}
final textSecret = await _cryptoService.deriveSharedSecret(
(await _cryptoService.getPrivateKey())!,
targetPublicKey,
);
final encryptedContent = await _cryptoService.encryptMessage(
originalMsg.text,
textSecret,
);
// 5. Формируем Payload
final payload = {
"type": "private_message",
"receiver_id": targetContact.id,
"message_type": originalMsg.messageType.name,
"content": encryptedContent,
"temp_id": tempId,
if (newFileId != null) ...{
"file_id": newFileId,
"encrypted_key": newEncryptedKey,
},
};
// 6. Отправка и навигация
final socket = Provider.of<SocketService>(context, listen: false);
final isSent = socket.sendMessage(payload);
if (!isSent) throw Exception("Ошибка отправки через сокет");
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!isSameChat) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(contact: targetContact),
),
);
} else {
// Если переслали в текущий чат — обновляем статус
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: MessageStatus.sent,
fileId: newFileId ?? originalMsg.fileId,
localFile: newLocalFile,
);
}
});
}
});
} catch (e) {
debugPrint("[Error] Ошибка пересылки: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceAll("Exception: ", "")),
backgroundColor: Colors.redAccent,
),
);
}
}
Widget _buildMessageInput() {
final bool hasTextOrFile =
_controller.text.trim().isNotEmpty || _pendingFile != null;
final bool showSendButton = hasTextOrFile || _isRecordLocked;
final colorScheme = Theme.of(context).colorScheme;
return Stack(
clipBehavior: Clip.none,
children: [
Container(
constraints: const BoxConstraints(maxHeight: 250),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 24,
offset: const Offset(0, -4),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.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: colorScheme.surface.withOpacity(0.7),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.reply_rounded,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_replyTo!.text.isNotEmpty
? _replyTo!.text
: _getMediaPreview(_replyTo!.messageType),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => setState(() => _replyTo = null),
),
],
),
),
if (_pendingFile != null)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.8),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.2),
),
),
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: _buildPreviewIcon(),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_pendingFileName ?? "Файл",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
_pendingMessageType.name.toUpperCase(),
style: TextStyle(
fontSize: 11,
color: colorScheme.outline,
fontWeight: FontWeight.bold,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.cancel_rounded, size: 20),
onPressed: () => setState(() {
_pendingFile = null;
_pendingFileName = null;
_previewBytes = null;
_pendingMessageType = MessageType.text;
}),
),
],
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!_isRecording)
GestureDetector(
onTapDown: (details) =>
_showPopup(context, details.globalPosition),
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
Icons.attach_file_rounded,
size: 20,
color: colorScheme.primary,
),
),
)
else
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Icon(
Icons.fiber_manual_record_rounded,
color: Colors.red,
size: 18,
),
),
const SizedBox(width: 8),
Expanded(
child: _isRecording
? Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
child: Row(
children: [
Text(
_stopwatchDisplay,
style: const TextStyle(
color: Colors.red,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_isRecordLocked
? "Запись..."
: (_recordDragX <
_swipeCancelThreshold / 2
? "Отпустите для удаления"
: "Смахните влево для отмены"),
style: TextStyle(
color: colorScheme.outline,
fontSize: 13,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
)
: Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
// 1. Просто Enter — отправка сообщения
const SingleActivator(
LogicalKeyboardKey.enter,
): () {
if (showSendButton) {
_sendMessage();
}
},
const SingleActivator(
LogicalKeyboardKey.enter,
control: true,
): () {
_insertNewLine();
},
const SingleActivator(
LogicalKeyboardKey.enter,
meta: true,
): () {
_insertNewLine();
},
},
child: TextField(
controller: _controller,
minLines: 1,
maxLines: 5,
readOnly: _isRecordLocked,
textCapitalization:
TextCapitalization.sentences,
style: const TextStyle(fontSize: 15),
decoration: InputDecoration(
hintText: "Сообщение...",
hintStyle: TextStyle(
color: colorScheme.outline.withOpacity(0.6),
),
isDense: true,
border: InputBorder.none,
),
onChanged: (text) => setState(() {}),
),
),
),
),
_buildContextButton(showSendButton),
],
),
],
),
),
],
);
}
void _insertNewLine() {
final text = _controller.text;
final selection = _controller.selection;
// Проверяем, что курсор корректно установлен
if (!selection.isValid) return;
// Вставляем перенос строки в позицию курсора (или заменяем выделенный текст)
final newText = text.replaceRange(selection.start, selection.end, '\n');
final newOffset = selection.start + 1;
_controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newOffset),
);
}
Widget _buildContextButton(bool showSendButton) {
final colorScheme = Theme.of(context).colorScheme;
if (showSendButton) {
return GestureDetector(
onTap: () => _isRecordLocked ? _stopAndSendRecording() : _sendMessage(),
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
),
);
}
return GestureDetector(
onTap: _toggleRecordMode,
onLongPressStart: (_) => _startRecording(),
onLongPressMoveUpdate: (details) {
if (!_isRecording || _isRecordLocked) return;
setState(() {
_recordDragX = details.localOffsetFromOrigin.dx;
_recordDragY = details.localOffsetFromOrigin.dy;
});
if (_recordDragX < _swipeCancelThreshold) {
_cancelRecording();
} else if (_recordDragY < _swipeLockThreshold) {
_lockRecording();
}
},
onLongPressEnd: (_) {
if (_isRecording && !_isRecordLocked) _stopAndSendRecording();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _isRecording
? Colors.red.withOpacity(0.12)
: colorScheme.primary.withOpacity(0.08),
shape: BoxShape.circle,
),
child: Icon(
_isVoiceMode ? Icons.mic_rounded : Icons.videocam_rounded,
size: 20,
color: _isRecording ? Colors.red : colorScheme.primary,
),
),
);
}
void _showPopup(BuildContext context, Offset position) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: [
if (!Platform.isWindows)
PopupMenuItem(
value: 'camera',
child: Row(
children: const [
Icon(Icons.camera_alt),
SizedBox(width: 8),
Text("Камера"),
],
),
),
PopupMenuItem(
value: 'gallery',
child: Row(
children: const [
Icon(Icons.photo_library),
SizedBox(width: 8),
Text("Галерея"),
],
),
),
PopupMenuItem(
value: 'file',
child: Row(
children: const [
Icon(Icons.insert_drive_file),
SizedBox(width: 8),
Text("Файлы"),
],
),
),
],
);
// обработка выбора
switch (selected) {
case 'camera':
_pickCamera();
break;
case 'gallery':
_pickGallery();
break;
case 'file':
_pickFile();
break;
}
}
Future<void> _pickCamera() async {
if (Platform.isWindows) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Съемка фото/видео недоступна на Windows.'),
),
);
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = await Navigator.push<(XFile, String)>(
context,
MaterialPageRoute(builder: (_) => const CameraScreen()),
);
if (result == null) return;
final file = result.$1;
final type = result.$2;
final bytes = type == 'image' ? await file.readAsBytes() : null;
setState(() {
if (type == 'image') {
_previewBytes = bytes;
}
_pendingFile = File(file.path);
_pendingFileName = 'media_${DateTime.now().millisecondsSinceEpoch}';
_pendingMessageType = type == 'video'
? MessageType.video
: MessageType.image;
});
});
}
Future<void> _pickGallery() async {
if (Platform.isWindows) {
final FilePickerResult? result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: [
'jpg',
'jpeg',
'png',
'gif',
'bmp',
'webp',
'heic',
'heif',
'mp4',
'mov',
'avi',
'mkv',
'webm',
'flv',
'wmv',
'm4v',
],
);
if (result == null || result.files.isEmpty) return;
try {
final picked = result.files.single;
if (picked.path == null) return;
final file = File(picked.path!);
if (!mounted) return;
final lowerPath = picked.path!.toLowerCase();
final isVideo =
lowerPath.endsWith('.mp4') ||
lowerPath.endsWith('.mov') ||
lowerPath.endsWith('.avi') ||
lowerPath.endsWith('.mkv') ||
lowerPath.endsWith('.webm') ||
lowerPath.endsWith('.flv') ||
lowerPath.endsWith('.wmv') ||
lowerPath.endsWith('.m4v');
final isImage =
lowerPath.endsWith('.jpg') ||
lowerPath.endsWith('.jpeg') ||
lowerPath.endsWith('.png') ||
lowerPath.endsWith('.gif') ||
lowerPath.endsWith('.bmp') ||
lowerPath.endsWith('.webp') ||
lowerPath.endsWith('.heic') ||
lowerPath.endsWith('.heif');
Uint8List? bytes;
if (isImage) {
bytes = await file.readAsBytes();
}
setState(() {
if (isImage && bytes != null) {
_previewBytes = bytes;
}
_pendingFile = file;
_pendingFileName = picked.name;
_pendingMessageType = isVideo
? MessageType.video
: isImage
? MessageType.image
: MessageType.file;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе медиа: $e'),
duration: const Duration(seconds: 3),
),
);
}
return;
}
final photosGranted = await Permission.photos.request();
final videosGranted = await Permission.videos.request();
if (!photosGranted.isGranted || !videosGranted.isGranted) {
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),
duration: Duration(seconds: 3),
),
);
return;
}
final List<AssetEntity>? result = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
maxAssets: 1,
pageSize: 33,
gridCount: 3,
pickerTheme: ThemeData(
brightness: Theme.of(context).brightness,
primaryColor: Theme.of(context).primaryColor,
colorScheme: Theme.of(context).colorScheme,
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBarTheme: AppBarTheme(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
),
specialItemBuilder: null,
),
);
if (result != null && result.isNotEmpty) {
final asset = result.first;
try {
Uint8List? bytes;
if (asset.type == AssetType.image) {
bytes = await asset.originBytes;
}
final File? file = await asset.file;
if (file == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось получить доступ к файлу медиа.'),
duration: Duration(seconds: 2),
),
);
return;
}
if (!mounted) return;
setState(() {
if (asset.type == AssetType.image && bytes != null) {
_previewBytes = bytes;
}
_pendingFile = file;
_pendingFileName =
asset.title ?? 'media_${DateTime.now().millisecondsSinceEpoch}';
_pendingMessageType = asset.type == AssetType.video
? MessageType.video
: MessageType.image;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе медиа: $e'),
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _pickFile() async {
FilePickerResult? result = await FilePicker.pickFiles(type: FileType.any);
if (result != null && result.files.isNotEmpty) {
try {
final file = File(result.files.single.path!);
if (!mounted) return;
setState(() {
_pendingFile = file;
_pendingFileName = result.files.single.name;
_pendingMessageType = MessageType.file;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе файла: $e'),
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<File?> _compressAndCropVideoNoteSafe(File originalVideoFile) async {
try {
if (!await originalVideoFile.exists()) {
debugPrint('==> FFmpeg: Исходный файл не найден на диске.');
return null;
}
final String targetOriginalPath = originalVideoFile.path;
debugPrint(
'==> Исходный файл: $targetOriginalPath, размер: ${await originalVideoFile.length()} байт',
);
// 1. Мгновенно переименовываем оригинальный файл во временный входной файл
final String tempInputPath = '${targetOriginalPath}_temp_input.mp4';
final File movedOriginalFile = await originalVideoFile.rename(
tempInputPath,
);
// 2. Строим команду в виде списка аргументов (List<String>).
// Используем двойной слэш \\, чтобы экранирование запятой дошло до парсера FFmpeg
final List<String> ffmpegArgs = [
'-i',
tempInputPath,
'-vf',
'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512',
'-vcodec',
'libx264',
'-crf',
'28',
'-preset',
'fast',
'-y',
targetOriginalPath,
];
debugPrint(
'==> FFmpeg: Запуск потоковой обработки через массив аргументов...',
);
// 3. КРОССПЛАТФОРМЕННЫЙ ЗАПУСК
if (Platform.isWindows) {
// НА WINDOWS: Работаем напрямую через нативный процесс операционной системы
String executable = 'ffmpeg';
// Проверяем, лежит ли портативный ffmpeg.exe в одной папке с запущенным приложением
final localFfmpeg = File(
p.join(p.dirname(Platform.resolvedExecutable), 'ffmpeg.exe'),
);
if (await localFfmpeg.exists()) {
executable = localFfmpeg.path;
}
try {
final result = await Process.run(executable, ffmpegArgs);
if (result.exitCode == 0) {
debugPrint('==> FFmpeg Windows: Успешное сжатие!');
final outputFile = File(targetOriginalPath);
if (await outputFile.exists()) {
debugPrint(
'==> FFmpeg Windows: Новый размер файла: ${await outputFile.length()} байт',
);
}
// Чистим временный исходник
if (await movedOriginalFile.exists()) {
await movedOriginalFile.delete();
}
return outputFile;
} else {
debugPrint('==> FFmpeg Windows Ошибка: ${result.stderr}');
// ВОССТАНОВЛЕНИЕ: Возвращаем оригинальный файл на место
if (await movedOriginalFile.exists()) {
await movedOriginalFile.rename(targetOriginalPath);
}
return null;
}
} catch (e) {
debugPrint(
'==> Критическая ошибка запуска FFmpeg на Windows (проверьте PATH или наличие ffmpeg.exe): $e',
);
if (await movedOriginalFile.exists()) {
await movedOriginalFile.rename(targetOriginalPath);
}
return null;
}
} else {
// НА МОБИЛКАХ (Android / iOS): Оставляем выполнение через мобильный плагин
final session = await FFmpegKit.executeWithArguments(ffmpegArgs);
final returnCode = await session.getReturnCode();
final output = await session.getOutput();
if (output != null && output.isNotEmpty) {
debugPrint('==> FFmpeg Консоль:\n$output');
}
if (ReturnCode.isSuccess(returnCode)) {
final outputFile = File(targetOriginalPath);
if (await outputFile.exists()) {
debugPrint(
'==> FFmpeg: Успех! Новый размер файла: ${await outputFile.length()} байт',
);
}
if (await movedOriginalFile.exists()) {
await movedOriginalFile.delete();
}
return outputFile;
} else {
final failStackTrace = await session.getFailStackTrace();
debugPrint(
'==> FFmpeg: Ошибка кодирования. Код возврата: $returnCode. Стек: $failStackTrace',
);
// ВОССТАНОВЛЕНИЕ: Возвращаем оригинальный файл на место
if (await movedOriginalFile.exists()) {
await movedOriginalFile.rename(targetOriginalPath);
}
return null;
}
}
} catch (e) {
debugPrint(
'==> Критическая ошибка во время выполнения _compressAndCropVideoNoteSafe: $e',
);
return null;
}
}
Future<void> _sendMessage() async {
_sendStopTypingStatus();
String rawText = _controller.text.trim();
File? file = _pendingFile;
final MessageType messageType = _pendingMessageType;
final hasMedia = _pendingFile != null;
final replyTo = _replyTo;
if (messageType == MessageType.videoNote ||
messageType == MessageType.voiceNote) {
rawText =
""; // Для видеозаметок и голосовых сообщений текст не обязателен, игнорируем его
}
// Если и текст пустой, и медиа нет — выходим
if (rawText.isEmpty && !hasMedia) return;
_scrollToBottom();
// Блокируем UI на время загрузки
_controller.clear();
_pendingFile = null;
_pendingMessageType = MessageType.text; // Сбрасываем тип медиа
_previewBytes = null; // Очищаем превью
_pendingFileName = null;
_replyTo = null;
final tempId = DateTime.now().millisecondsSinceEpoch;
try {
print(
"Исходный файл: ${file?.path}, размер: ${await file?.length()} байт",
);
if (messageType == MessageType.videoNote && file != null) {
file = await _compressAndCropVideoNoteSafe(file);
print(
"После обработки видеозаметки: ${file?.path}, размер: ${await file?.length()} байт",
);
}
int? fileSize = await file?.length();
// создаем первичную модель отобрадения
MessageModel tempMsg = MessageModel(
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
isMe: true,
text: rawText,
tempId: tempId,
messageType: messageType,
localFile: file,
status: MessageStatus.encrypting,
fileSize: fileSize,
replyToId: replyTo?.id,
replyToText: replyTo?.text,
fileId: tempId.toString(),
fileName: file != null ? p.basename(file.path) : "file",
);
setState(() => messages.add(tempMsg));
// 1. Подготовка ключей
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
_currentContact.publicKey!,
);
String? fileId;
String? encryptedFileKey;
String encryptedContent;
String encryptedContent50;
String? encryptedReplyToText;
_messageProgressNotifiers['${tempMsg.fileId}'] ??= ValueNotifier<double?>(
0.0,
);
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
// 2. Если есть медиа — сначала загружаем его
if (hasMedia && file != null && fileSize != null) {
final fileStream = file.openRead();
final encryptedStream = await _cryptoService.encryptFileStream(
fileStream,
sharedSecret,
totalSize: fileSize,
onProgress: (received, total) {
print(received);
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
}
},
);
final fileKeyForServer = encryptedStream.$2;
final tempDir = await getTemporaryDirectory();
final encFile = File('${tempDir.path}/enc_${tempId}.tmp');
final ios = encFile.openWrite();
await ios.addStream(encryptedStream.$1);
await ios.close();
final int exactEncryptedSize = await encFile.length();
setState(() {
tempMsg = tempMsg.copyWith(status: MessageStatus.sending);
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = tempMsg;
}
fileSize = exactEncryptedSize;
});
_messageProgressNotifiers['${tempMsg.fileId}'] ??=
ValueNotifier<double?>(0.0);
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
fileId = await apiService.uploadFileStream(
encFile.openRead(),
exactEncryptedSize,
purpose: messageType.name,
fileName: p.basename(file.path),
onProgress: (received, total) {
print(received);
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
}
},
);
if (await encFile.exists()) {
await encFile.delete();
}
if (fileId == null) {
throw Exception("Ошибка загрузки файла на сервер");
}
// Сохраняем полный путь к исходному файлу, чтобы при повторном открытии чата
// можно было использовать оригинальную локальную копию, если она ещё есть
try {
await _localDbService.saveOriginalFileNameForFileId(
fileId,
file.path, // сохраняем полный путь вместо только имени
);
} catch (e) {
debugPrint('Не удалось сохранить путь оригинального файла: $e');
}
encryptedFileKey = fileKeyForServer;
}
// 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к медиа)
// Если текста нет, но есть медиа, отправим пустую строку
final String textToEncrypt = rawText.isNotEmpty
? rawText
: (hasMedia ? "" : "");
encryptedContent = await _cryptoService.encryptMessage(
textToEncrypt,
sharedSecret,
);
String previewText = rawText.isNotEmpty ? rawText : "[Фото]";
if (previewText.length > 500) previewText = previewText.substring(0, 500);
encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
if (replyTo?.id != null && replyTo!.text.trim().isNotEmpty) {
encryptedReplyToText = await _cryptoService.encryptMessage(
replyTo.text,
sharedSecret,
);
}
// 4. Создаем локальную модель для мгновенного отображения
final localMessage = MessageModel(
tempId: tempId,
text: rawText,
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFile: file,
messageType: messageType,
fileId: fileId,
encryptedFileKey: encryptedFileKey,
replyToId: replyTo?.id,
replyToText: replyTo?.text,
fileSize: await file?.length(),
fileName: file != null ? p.basename(file.path) : "file",
);
final directory = await getApplicationDocumentsDirectory();
if (file != null) {
final localCopyPath = (fileId != null)
? p.normalize(p.join(directory.path, 'dec_$fileId'))
: null;
if (localCopyPath != null) {
try {
// Принудительно приводим путь исходного файла к Windows-стандарту слэшей
final File normalizedFile = File(p.normalize(file.path));
if (await normalizedFile.exists()) {
await normalizedFile.copy(localCopyPath);
print(
"DEBUG: Сохраняю файл: ${normalizedFile.path}, существует: ${await normalizedFile.exists()}, размер: ${await normalizedFile.length()}",
);
} else {
debugPrint(
"Локальный файл отсутствует, пропускаю копирование в кеш: ${normalizedFile.path}",
);
}
} catch (e) {
debugPrint("Не удалось скопировать локальный файл в Documents: $e");
}
}
final sharedPrefs = await SharedPreferences.getInstance();
final String sizeKey = 'valid_dec_size_$fileId';
if (await file.exists()) {
final finalFileSize = await file.length();
if (finalFileSize > 0) {
// Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл
await sharedPrefs.setInt(sizeKey, finalFileSize);
debugPrint(
"Файл успешно загружен. Размер сохранен: $finalFileSize байт.",
);
}
}
} else {
print(
"==> [Warning] Локальный файл отсутствует для сообщения с tempId: $tempId",
);
}
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = localMessage;
}
file = null; // Очищаем черновик
});
if (hasMedia && (fileId == null || encryptedFileKey == null)) {
throw Exception(
'Не удалось загрузить медиа перед отправкой сообщения.',
);
}
// 5. Формируем финальный payload для сокета
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"message_type": messageType.name,
"content": encryptedContent,
"content50": encryptedContent50,
"temp_id": tempId,
if (hasMedia) ...{
"file_id": fileId,
"encrypted_key": encryptedFileKey,
if (messageType == MessageType.file)
"file_name": file != null ? p.basename(file!.path) : "file",
},
if (replyTo?.id != null) ...{
"reply_to_id": replyTo!.id,
if (encryptedReplyToText != null)
"reply_to_text": encryptedReplyToText,
},
};
// Логирование для отладки
print('[DEBUG] _sendMessage payload:');
print('[DEBUG] - type: ${payload['type']}');
print('[DEBUG] - receiver_id: ${payload['receiver_id']}');
print('[DEBUG] - message_type: ${payload['message_type']}');
print(
'[DEBUG] - content length: ${(payload['content'] as String?)?.length ?? 0}',
);
print('[DEBUG] - temp_id: ${payload['temp_id']}');
if (hasMedia) {
print('[DEBUG] - file_id: ${payload['file_id']}');
print(
'[DEBUG] - encrypted_key: ${(payload['encrypted_key'] as String?)?.length ?? 0}',
);
if (payload.containsKey('file_name')) {
print('[DEBUG] - file_name: ${payload['file_name']}');
}
}
// 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;
});
try {
await _localDbService.insertMessage(localMessage);
} catch (e) {
print('Ошибка при сохранении сообщения в локальную базу: $e');
}
} catch (e) {
try {
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages.removeAt(idx);
}
_replyTo = null;
});
} catch (e) {
print(e);
}
print(e);
// В случае ошибки возвращаем текст и медиа в контроллер
_controller.text = rawText;
_pendingFile = file;
_pendingMessageType = messageType;
_replyTo = replyTo;
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 {
print('Meesage from websocket: $data');
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (data['type'] == 'call_created') {
final String serverCallId = data['call_id'];
final String targetName = data['receiver_name'] ?? "Пользователь";
// Переходим на экран звонка, используя ID от сервера
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: serverCallId,
isIncoming: false, // Мы инициатор
callerName: targetName,
onAccept: () async {},
onHangup: () {
// Отправляем сигнал отмены на сервер
SocketService().sendMessage({
"type": "hangup",
"call_id": serverCallId,
});
},
),
),
);
}
if (data['type'] == 'all_chat_read') {
final readerId = int.tryParse(data['reader_id']?.toString() ?? '');
if (readerId == widget.contact.id) {
setState(() {
for (int i = 0; i < messages.length; i++) {
if (messages[i].isMe && messages[i].status != MessageStatus.read) {
messages[i] = messages[i].copyWith(status: MessageStatus.read);
}
}
});
}
}
if (data['type'] == 'message_sent') {
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
final receiverId = int.tryParse(data['receiver_id']?.toString() ?? '');
var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (receiverId != widget.contact.id) return; // Это не наше сообщение
if (tempId == null) return;
if (!mounted) return;
if (await _localDbService.messageExists(tempId)) {
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
// 1. Создаем обновленный объект сообщения с серверным ID
final updatedMsg = messages[idx].copyWith(
id: serverId ?? messages[idx].id,
createdAt: ts ?? messages[idx].createdAt,
status: MessageStatus.sent,
);
// 2. Обновляем его в основном списке
messages[idx] = updatedMsg;
// 3. Обязательно регистрируем сообщение в мапе по его серверному ID!
if (serverId != null) {
_messageMap[serverId] = updatedMsg;
}
});
} else {
// Если сообщения у нас нет, значит оно было отправлено с другого устройсва
// Мы должны расшифровать его и добавить в список
// Также необходимо расшифровать ключ файла (если есть) и сохранить его для последующего скачивания медиа
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,
);
final ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
final String? fileId = data['file_id'];
final String? encryptedKey = data['encrypted_key'];
String? decryptedReplyToText;
if (data['reply_to_id'] != null && data['reply_to_text'] != null) {
decryptedReplyToText = await _cryptoService.decryptMessage(
data['reply_to_text'],
sharedSecret,
);
print('Decrypted reply_to_text: $decryptedReplyToText');
}
setState(() {
messages.add(
MessageModel(
id: serverId,
text: decryptedText,
isMe: true,
senderId: myId,
receiverId: widget.contact.id,
createdAt: ts ?? DateTime.now(),
status: MessageStatus.sent,
fileId: fileId,
encryptedFileKey: encryptedKey,
messageType: _parseMessageTypeString(data['message_type']),
replyToId: data['reply_to_id'] != null
? int.tryParse(data['reply_to_id'].toString())
: null,
replyToText: decryptedReplyToText,
),
);
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
});
}
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(() {
_updateMessageInList(
messageId,
(m) => m.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 (_) {}
// Обновить последнее сообщение в списке контактов
final contactProvider = context.read<ContactProvider>();
if (messages.isNotEmpty && messages.last.id == messageId) {
await contactProvider.updateContactLastMessage(
widget.contact.id,
lastMessage: decryptedText,
lastMessageTime: ts,
isLastMsgDecrypted: true,
lastMessageId: messageId,
isEdited: true,
);
}
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 (_) {}
// Обновить последнее сообщение в списке контактов
final contactProvider = context.read<ContactProvider>();
if (messages.isEmpty) {
// Если не осталось сообщений, очистить последнее сообщение
await contactProvider.updateContactLastMessage(
widget.contact.id,
lastMessage: null,
lastMessageTime: null,
lastMessageId: null,
);
} else {
// Обновить на предпоследнее сообщение
await contactProvider.refreshContactLastMessage(widget.contact.id);
}
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(() {
_updateMessageInList(
messageId,
(m) => m.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();
// Lazy load images later
if (!mounted) return;
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
);
final incomingMsg = 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: _parseMessageTypeString(data['message_type']),
fileId: data['file_id']?.toString(),
encryptedFileKey: encryptedFileKey,
);
// Сохраняем в локальную БД в любом случае, чтобы сообщение не потерялось
try {
await _localDbService.saveMessages([incomingMsg]);
} catch (e) {
print('Error saving incoming message to DB: $e');
}
// ПРЕДОХРАНИТЕЛЬ: Если снизу есть не подгруженная история (_hasMoreNewer == true),
// не пушим сообщение в UI-список, иначе нарушится хронология.
if (_hasMoreNewer) {
print(
'[WebSocket] Чат в режиме истории. Сообщение сохранено в БД, но не добавлено в текущий UI.',
);
// Здесь при желании можно показать UI-нотификацию вроде "Новое сообщение снизу"
return;
}
setState(() {
messages.add(incomingMsg);
});
} 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!,
);
// Извлекаем чистые байты ключа, который сгенерировал Flutter
final keyBytes = await sharedSecret.extractBytes();
print(
"ФЛАТТЕР AES КЛЮЧ: ${RegExp(r'\[(.*)\]').firstMatch(keyBytes.toString())?.group(1)}",
);
// Или в формате HEX:
print(
"ФЛАТТЕР AES HEX: ${keyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}",
);
_chatSharedSecret = sharedSecret;
// БЕРЕМ АНКЕР НАПРЯМУЮ ИЗ МОДЕЛИ КОНТАКТА
int? passedAnchorId = widget.contact.firstUnreadMessageId;
if (passedAnchorId == 0) {
passedAnchorId = null;
}
// 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД
final cached = await _localDbService.getMessages(
widget.contact.id,
myId,
anchorMessageId: passedAnchorId,
limitBefore: _limitBefore,
limitAfter: _limitAfter,
);
Map<int, MessageModel> localMessagesMap = {};
for (var msg in cached) {
final parsed = await _parseAndDecryptMessage(
msg,
sharedSecret,
Duration(),
{},
);
if (parsed != null && parsed.id != null) {
localMessagesMap[parsed.id!] = parsed;
}
}
if (localMessagesMap.isNotEmpty) {
if (!mounted) return;
setState(() {
messages = localMessagesMap.values.toList();
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
_isKeyLoading = false;
});
}
// 2. ФОНОВАЯ ЗАГРУЗКА ИЗ API (Фронтенд НЕ передает anchorId, если его нет)
final responseBody = await apiService.getChatHistory(
widget.contact.id,
anchorId: passedAnchorId, // Передаем тот же анкер на сервер
limitBefore: _limitBefore,
limitAfter: _limitAfter,
);
// Читаем анкер и список сообщений, присланные сервером
int? serverAnchorId = passedAnchorId;
if (responseBody.containsKey('anchor_id')) {
serverAnchorId = responseBody['anchor_id'] as int?;
}
if (serverAnchorId == 0) {
serverAnchorId = null;
}
final List<dynamic> history = responseBody['messages'] ?? [];
print(
'[DEBUG] Сервер вернул анкер: $serverAnchorId, сообщений: ${history.length}',
);
final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages = [];
List<MessageModel> encryptedMessagesForStorage = [];
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 String rawCiphertext = msg['content'].toString();
final String? rawEncryptedReplyText = msg['reply_to_text']?.toString();
final parsed = await _parseAndDecryptMessage(
msg,
sharedSecret,
offset,
localMessagesMap,
);
if (parsed != null) {
loadedMessages.add(parsed);
encryptedMessagesForStorage.add(
MessageModel(
id: parsed.id,
text: rawCiphertext,
isMe: parsed.isMe,
senderId: parsed.senderId,
receiverId: parsed.receiverId,
createdAt: parsed.createdAt,
status: parsed.status,
replyToId: parsed.replyToId,
replyToText: rawEncryptedReplyText,
editedAt: parsed.editedAt,
messageType: parsed.messageType,
fileId: parsed.fileId,
encryptedFileKey: parsed.encryptedFileKey,
fileName: parsed.fileName,
fileSize: parsed.fileSize,
localFile: parsed.localFile,
),
);
}
}
try {
if (encryptedMessagesForStorage.isNotEmpty) {
await _localDbService.saveMessages(encryptedMessagesForStorage);
}
} catch (e) {
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
}
if (!mounted) return;
setState(() {
loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// Умное слияние, чтобы не ломать структуру списка
final apiIds = loadedMessages.map((m) => m.id).toSet();
final localPreserved = messages
.where((m) => m.id == null || !apiIds.contains(m.id))
.toList();
final combined = [...localPreserved, ...loadedMessages];
combined.sort((a, b) => a.createdAt.compareTo(b.createdAt));
messages = combined;
_isKeyLoading = false;
// ВЫСТАВЛЯЕМ ФЛАГИ НА ОСНОВЕ ДАННЫХ СЕРВЕРА
if (serverAnchorId != null) {
final olderCount = loadedMessages
.where((m) => m.id != null && m.id! < serverAnchorId!)
.length;
final newerCount = loadedMessages
.where((m) => m.id != null && m.id! > serverAnchorId!)
.length;
_hasMoreOlder = olderCount >= _limitBefore;
_hasMoreNewer = newerCount >= _limitAfter;
} else {
_hasMoreNewer = false;
_hasMoreOlder = history.length >= _limitBefore;
}
});
// ВЫПОЛНЯЕМ ПРЫЖОК СТРОГО ЕСЛИ СЕРВЕР ПОДТВЕРДИЛ НАЛИЧИЕ АНКЕРА
// --- 2. ВОССТАНАВЛИВАЕМ СКРОЛЛ ИЛИ ПРЫГАЕМ К АНКЕРУ ---
if (serverAnchorId != null) {
// Если есть непрочитанные — делаем осознанный прыжок к первому непрочитанному
_suppressPagination = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 150), () async {
await _scrollToMessage(serverAnchorId);
if (mounted) {
setState(() {
_suppressPagination = false;
_isReadyForReading = true;
});
}
});
});
} else {
// ЕСЛИ ВСЁ ПРОЧИТАНО:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (messages.isNotEmpty && _itemScrollController.isAttached) {
_itemScrollController.jumpTo(index: 0);
}
if (mounted) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContact(
widget.contact.id,
unreadCount: 0,
firstUnreadMessageId: 0,
);
setState(() {
_suppressPagination = false;
_isReadyForReading = true;
});
}
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 500), () async {
if (mounted) {
// Если чат пуст или нет непрочитанных — сразу отправляем прочтение
final hasUnread = messages.any(
(m) => !m.isMe && m.status != MessageStatus.read,
);
final positions = _itemPositionsListener.itemPositions.value;
print(
'Отправка прочтения: ${(!hasUnread || (positions.isNotEmpty && positions.last.index > 5))}',
);
// Если пользователь уже внизу (сразу после загрузки)
if (!hasUnread ||
(positions.isNotEmpty && positions.last.index > 5)) {
_socketService.sendReadAllChat(widget.contact.id);
}
}
});
});
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}
Future<void> _markAsRead(MessageModel msg) async {
if (msg.id == null || msg.isMe || msg.status == MessageStatus.read) return;
if (_sentReadReceipts.contains(msg.id)) return;
// 1. Моментально блокируем повторную обработку
_sentReadReceipts.add(msg.id!);
print('[DEBUG] Плавное прочтение сообщения ID: ${msg.id}');
// 2. СИНХРОННО обновляем локальный статус в массиве (без setState)
// Это гарантирует, что следующий вызов _markAsRead в эту же миллисекунду
// уже увидит, что это сообщение прочитано!
final index = messages.indexWhere((m) => m.id == msg.id);
if (index != -1) {
messages[index] = messages[index].copyWith(status: MessageStatus.read);
}
// 3. СИНХРОННО ищем следующий анкер по обновленному массиву
final nextUnreadMsg = messages.firstWhere(
(m) => !m.isMe && m.status != MessageStatus.read,
orElse: () => MessageModel(
createdAt: DateTime.now(),
text: '',
isMe: true,
senderId: 0,
receiverId: 0,
),
);
final int? nextAnchorId = nextUnreadMsg.id;
// 4. СИНХРОННО отнимаем -1 в списке контактов
// (Вызов уйдет до того, как база данных заморозит выполнение)
widget.onMessageRead?.call(_currentContact.id, nextAnchorId);
// 5. Перерисовываем галочки в интерфейсе чата
if (mounted) setState(() {});
// 6. И только теперь уходим в медленные асинхронные задачи
_socketService.sendReadReceipt(msg.id!);
try {
await _localDbService.updateReadAt(msg.id!, DateTime.now());
} catch (e) {
print('Ошибка при сохранении статуса прочтения в БД: $e');
}
}
// 1. ПОДГРУЗКА СТАРЫХ СООБЩЕНИЙ (ВВЕРХ ХРОНОЛОГИИ)
Future<void> _loadOlderMessages() async {
if (_isLoadingOlder ||
!_hasMoreOlder ||
messages.isEmpty ||
_chatSharedSecret == null)
return;
setState(() => _isLoadingOlder = true);
try {
final int? anchorId = messages.first.id;
if (anchorId == null) return;
print('[DEBUG] Загрузка старой истории. Анкер: $anchorId');
final history = await apiService.getChatHistory(
_currentContact.id,
anchorId: anchorId,
limitBefore: _limitBefore,
limitAfter: 0,
);
final List<dynamic> historyList = history['messages'] ?? [];
if (historyList.isEmpty) {
setState(() {
_hasMoreOlder = false;
_isLoadingOlder = false;
});
return;
}
List<MessageModel> loadedOlder = [];
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
for (var msg in historyList) {
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
offset,
{},
);
if (parsed != null) loadedOlder.add(parsed);
}
if (!mounted) return;
setState(() {
loadedOlder.sort((a, b) => a.createdAt.compareTo(b.createdAt));
// --- ЗАЩИТА ОТ ДУБЛИКАТОВ ---
// Собираем ID всех сообщений, которые УЖЕ отрисованы на экране
final existingIds = messages.map((m) => m.id).toSet();
// Фильтруем новую пачку, убирая то, что уже есть (включая сообщение-анкер)
final uniqueOlder = loadedOlder
.where((m) => !existingIds.contains(m.id))
.toList();
// Вставляем в начало только уникальные сообщения
messages.insertAll(0, uniqueOlder);
_isLoadingOlder = false;
if (historyList.length < _limitBefore) _hasMoreOlder = false;
});
} catch (e) {
print("Ошибка загрузки старых сообщений: $e");
setState(() => _isLoadingOlder = false);
}
}
Future<void> _loadNewerMessages() async {
if (_isLoadingNewer ||
!_hasMoreNewer ||
messages.isEmpty ||
_chatSharedSecret == null)
return;
setState(() => _isLoadingNewer = true);
try {
final int? anchorId = messages.last.id;
if (anchorId == null) return;
final history = await apiService.getChatHistory(
_currentContact.id,
anchorId: anchorId,
limitBefore: 0,
limitAfter: _limitAfter,
);
final List<dynamic> historyList = history['messages'] ?? [];
if (historyList.isEmpty) {
setState(() {
_hasMoreNewer = false;
_isLoadingNewer = false;
});
return;
}
List<MessageModel> loadedNewer = [];
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
for (var msg in historyList) {
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
offset,
{},
);
if (parsed != null) loadedNewer.add(parsed);
}
if (!mounted) return;
// --- ЛОГИКА СТАБИЛИЗАЦИИ ---
// Нам нужно знать, какой индекс сейчас первый видимый, чтобы не "прыгнуть"
final positions = _itemPositionsListener.itemPositions.value;
final int? firstVisibleIndex = positions.isNotEmpty
? positions.first.index
: null;
setState(() {
loadedNewer.sort((a, b) => a.createdAt.compareTo(b.createdAt));
final existingIds = messages.map((m) => m.id).toSet();
final uniqueNewer = loadedNewer
.where((m) => !existingIds.contains(m.id))
.toList();
messages.addAll(uniqueNewer);
_isLoadingNewer = false;
if (historyList.length < _limitAfter) _hasMoreNewer = false;
});
// Если пользователь не был в самом низу, удерживаем его текущий видимый индекс
if (firstVisibleIndex != null && firstVisibleIndex > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemScrollController.isAttached) {
_itemScrollController.jumpTo(index: firstVisibleIndex, alignment: 0);
}
});
}
} catch (e) {
print("Ошибка загрузки новых сообщений: $e");
setState(() => _isLoadingNewer = false);
}
}
Future<MessageModel?> _parseAndDecryptMessage(
Map<String, dynamic> msg,
SecretKey sharedSecret,
Duration offset,
Map<int, MessageModel> localCache,
) async {
final msgId = int.tryParse(msg['id']?.toString() ?? '');
if (msgId == null) return null;
try {
// 1. УМНОЕ ИЗВЛЕЧЕНИЕ ID (понимает и 'sender_id' от API, и 'senderId' от БД Drift)
final int senderId =
int.tryParse(
msg['sender_id']?.toString() ?? msg['senderId']?.toString() ?? '',
) ??
0;
final int receiverId =
int.tryParse(
msg['receiver_id']?.toString() ??
msg['receiverId']?.toString() ??
'',
) ??
0;
final cachedMsg = localCache[msgId];
final String decryptedText;
final String? decryptedReplyText;
// Аналогично проверяем даты
final rawEditedAt = msg['edited_at'] ?? msg['editedAt'];
final currentEditedAt = rawEditedAt != null
? DateTime.tryParse(rawEditedAt.toString())?.add(offset)
: null;
if (cachedMsg != null && cachedMsg.editedAt == currentEditedAt) {
decryptedText = cachedMsg.text;
decryptedReplyText = cachedMsg.replyToText;
} else {
// Контент тоже может прийти как 'content' (БД) или 'text'
final rawContent =
msg['content']?.toString() ?? msg['text']?.toString() ?? '';
decryptedText = await _cryptoService.decryptMessage(
rawContent,
sharedSecret,
);
final rawReplyText =
msg['reply_to_text']?.toString() ?? msg['replyToText']?.toString();
decryptedReplyText = await _decryptReplyText(
rawReplyText,
sharedSecret,
);
}
final rawDeliveredAt = msg['delivered_at'] ?? msg['deliveredAt'];
final deliveredAt = rawDeliveredAt != null
? DateTime.tryParse(rawDeliveredAt.toString())?.add(offset)
: null;
final rawReadAt = msg['read_at'] ?? msg['readAt'];
final readAt = rawReadAt != null
? DateTime.tryParse(rawReadAt.toString())?.add(offset)
: null;
MessageStatus status = (senderId == myId)
? MessageStatus.sent
: MessageStatus.delivered;
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString();
final rawFileName =
msg['file_name']?.toString() ?? msg['fileName']?.toString();
File? existingLocalFile = await _findExistingCachedDecryptedFile(
rawFileId,
rawFileName,
);
if (cachedMsg != null) {
existingLocalFile = cachedMsg.localFile ?? existingLocalFile;
}
final String effectiveFileName = existingLocalFile != null
? p.basename(existingLocalFile.path)
: (rawFileName ?? 'file_$rawFileId');
final rawTimestamp = msg['timestamp'] ?? msg['createdAt'];
final timestampStr =
rawTimestamp?.toString() ?? DateTime.now().toIso8601String();
return MessageModel(
id: msgId,
text: decryptedText,
isMe: senderId == myId,
senderId: senderId,
receiverId: receiverId,
createdAt: DateTime.parse(timestampStr).add(offset),
status: status,
replyToId: int.tryParse(
msg['reply_to_id']?.toString() ?? msg['replyToId']?.toString() ?? '',
),
replyToText: decryptedReplyText,
editedAt: currentEditedAt,
messageType: _parseMessageTypeString(
(msg['message_type'] ?? msg['messageType'])?.toString() ?? 'text',
),
fileId: rawFileId,
encryptedFileKey:
msg['encrypted_key']?.toString() ??
msg['encryptedKey']?.toString() ??
msg['encryptedFileKey']?.toString(),
fileName: effectiveFileName,
fileSize: int.tryParse(
msg['file_size']?.toString() ?? msg['fileSize']?.toString() ?? '',
),
localFile: existingLocalFile,
);
} catch (e) {
print('Ошибка парсинга/дешифровки сообщения $msgId: $e');
return null;
}
}
final Map<String, StreamSubscription<List<int>>> _activeDownloads = {};
Future<void> _stopFileLoading(MessageModel message) async {
if (message.fileId == null) return;
final subscription = _activeDownloads.remove(message.fileId!);
if (subscription != null) {
await subscription.cancel();
debugPrint("Загрузка файла ${message.fileId} отменена пользователем.");
if (message.localFile != null && message.localFile!.existsSync()) {
try {
await message.localFile!.delete();
debugPrint(
"Локальный файл успешно удален с диска: ${message.fileId}",
);
} catch (e) {
debugPrint("Ошибка при физическом удалении файла с диска: $e");
}
}
}
if (mounted) {
setState(() {
_messageProgressNotifiers[message.fileId!]?.value = null;
});
}
}
int _findMessageIndex(MessageModel message) {
return messages.indexWhere((m) {
if (message.id != null && m.id == message.id) return true;
if (message.tempId != null && m.tempId == message.tempId) return true;
if (message.fileId != null && m.fileId == message.fileId) return true;
return false;
});
}
Future<void> _fetchFileSizeIfNeeded(MessageModel message) async {
if (message.fileId == null) return;
try {
debugPrint("Фоновый запрос размера для файла: ${message.fileId}");
final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName(
message.fileId!,
);
if (remoteSize != null && remoteSize > 0 && mounted) {
if (message.fileSize != null && message.fileSize! > 0) {
if (message.fileSize != remoteSize) {
debugPrint(
"Размер файла на сервере (${remoteSize} байт) отличается от локального (${message.fileSize} байт). Локальный файл признан недействительным.",
);
if (message.localFile != null) {
try {
await message.localFile!.delete();
} catch (e) {
debugPrint(
"Ошибка удаления недействительного локального файла: $e",
);
}
}
}
}
print(
"Получен размер файла из сети: $remoteSize байт. Обновляем модель сообщения.",
);
setState(() {
final index = _findMessageIndex(message);
if (index != -1) {
final String? finalFileName = messages[index].localFile != null
? p.basename(messages[index].localFile!.path)
: (filename ?? messages[index].fileName);
messages[index] = messages[index].copyWith(
fileSize: remoteSize,
fileName: finalFileName ?? filename,
);
}
});
}
} catch (e) {
debugPrint("Ошибка фонового получения размера файла: $e");
}
}
Future<void> _ensureFileDecrypted(
MessageModel message, {
bool dontLoad = false,
}) async {
MessageModel msg = message;
if (msg.fileId == null || _chatSharedSecret == null) return;
// КРИТИЧЕСКИЙ ФИКС: Мгновенно выходим, если файл уже скачивается этой системой!
if (_activeDownloads.containsKey(msg.fileId)) return;
final decFile = await _getCachedDecryptedFile(msg.fileId!, msg.fileName);
final sharedPrefs = await SharedPreferences.getInstance();
final String sizeKey = 'valid_dec_size_${msg.fileId}';
if (await decFile.exists()) {
final localLength = await decFile.length();
final int? expectedDecryptedSize = sharedPrefs.getInt(sizeKey);
if (expectedDecryptedSize != null) {
if (localLength != expectedDecryptedSize) {
debugPrint(
"Размер локального файла ($localLength байт) не совпадает с сохраненным эталоном ($expectedDecryptedSize байт). Файл поврежден. Удаляем.",
);
try {
await decFile.delete();
} catch (e) {
debugPrint("Ошибка удаления: $e");
}
await sharedPrefs.remove(sizeKey);
} else {
debugPrint(
"Локальный файл успешно прошел валидацию по сохраненному размеру.",
);
if (mounted) {
setState(() {
final index = _findMessageIndex(msg);
if (index != -1) {
messages[index] = messages[index].copyWith(localFile: decFile);
}
});
}
return;
}
} else {
// Если файл есть, а эталона размера нет (например, прервали прошлый раз) — удаляем от греха подальше
try {
await decFile.delete();
} catch (e) {
debugPrint("Удаление неопознанного файла: $e");
}
}
}
if (dontLoad) return;
debugPrint("=== ОТЛАДКА СКАЧИВАНИЯ ===");
debugPrint("ID файла: ${msg.fileId}");
debugPrint("Ключ файла (Base64): ${msg.encryptedFileKey}");
debugPrint("Общий ключ чата готов?: ${_chatSharedSecret != null}");
final bool createdNewNotifier =
_messageProgressNotifiers[msg.fileId!] == null;
_messageProgressNotifiers[msg.fileId!] ??= ValueNotifier<double?>(0.0);
if (createdNewNotifier && mounted) {
setState(() {});
}
_messageProgressNotifiers[msg.fileId!]!.value = 0.0;
if (msg.fileSize == null || msg.fileSize == 0) {
debugPrint("Размер файла в модели пуст. Делаем HEAD запрос...");
final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName(
msg.fileId!,
);
if (remoteSize != null && remoteSize > 0) {
if (mounted) {
setState(() {
final index = _findMessageIndex(msg);
if (index != -1) {
messages[index] = messages[index].copyWith(
fileSize: remoteSize,
fileName: filename,
);
msg = messages[index];
} else {
msg = msg.copyWith(fileSize: remoteSize, fileName: filename);
}
});
}
}
}
// 1. Запрашиваем поток и метаданные с сервера
final response = await apiService.downloadFileAsStream(msg.fileId!);
final networkStream = response.$1;
final serverFileName = response.$2; // Настоящее имя из заголовков сервера
// 2. КРИТИЧЕСКИЙ ФИКС: Корректируем целевой файл на основе данных от сервера
File targetFile = decFile;
if (msg.fileId != null &&
serverFileName != null &&
serverFileName.isNotEmpty) {
await _localDbService.saveOriginalFileNameForFileId(
msg.fileId!,
serverFileName,
);
// Если до этого имя файла было заглушкой 'file_ID', перенаправляем поток в нормальное имя
if (p.basename(decFile.path) == 'file_${msg.fileId}') {
final prefs = await SharedPreferences.getInstance();
final cachedPathKey = 'cached_dec_file_path_${msg.fileId}';
// Удаляем пустую заготовку, если она успела создаться на диске
if (await decFile.exists() && (await decFile.length()) == 0) {
try {
await decFile.delete();
} catch (_) {}
}
// Генерируем новый красивый путь (например, Chepuhagram/документ.pdf)
targetFile = await _resolveUniqueFilePath(serverFileName);
await prefs.setString(cachedPathKey, targetFile.path);
debugPrint("Поток перенаправлен в правильный файл: ${targetFile.path}");
}
}
Stream<List<int>> decryptedStream = _cryptoService.decryptFileStream(
networkStream,
_chatSharedSecret!,
msg.encryptedFileKey!,
totalBytes: msg.fileSize,
onProgress: (received, total) {
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers[msg.fileId!]?.value = progress;
}
},
);
// Открываем Write-стрим уже к гарантированно ПРАВИЛЬНОМУ файлу
final iosink = targetFile.openWrite();
final Completer<void> downloadCompleter = Completer<void>();
final subscription = decryptedStream.listen(
(chunk) {
iosink.add(chunk);
},
onError: (error) async {
debugPrint("Ошибка внутри криптострима: $error");
await iosink.close();
if (!downloadCompleter.isCompleted) downloadCompleter.complete();
try {
await targetFile.delete();
} catch (_) {}
},
onDone: () async {
await iosink.close();
final finalLocalLength = await targetFile.length();
if (finalLocalLength > 0) {
await sharedPrefs.setInt(sizeKey, finalLocalLength);
debugPrint(
"Файл успешно сохранен под своим именем. Размер: $finalLocalLength байт.",
);
}
if (mounted) {
setState(() {
final updatedMsg = msg.copyWith(
localFile: targetFile, // Передаем корректный файл в модель
fileName: p.basename(targetFile.path),
);
final index = _findMessageIndex(msg);
if (index != -1) {
messages[index] = updatedMsg;
}
});
}
try {
_activeDownloads.remove(msg.fileId)?.cancel();
if (mounted) {
setState(() {
_messageProgressNotifiers[msg.fileId!]?.value = null;
});
}
} catch (e) {
debugPrint("Ошибка при очистке ресурсов загрузки: $e");
}
if (!downloadCompleter.isCompleted) downloadCompleter.complete();
},
cancelOnError: true,
);
_activeDownloads[msg.fileId!] = subscription;
try {
await downloadCompleter.future;
} catch (e) {
debugPrint("Загрузка прервана или завершилась с ошибкой: $e");
if (await targetFile.exists()) {
try {
await targetFile.delete();
} catch (_) {}
}
await sharedPrefs.remove(sizeKey);
}
}
Future<Directory> _getDownloadsDirectory() async {
try {
final downloads = await getDownloadsDirectory();
if (downloads != null) {
final dir = Directory(p.join(downloads.path, 'Chepuhagram'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (e) {
debugPrint(
'Downloads directory unavailable, falling back to app documents: $e',
);
}
return await getApplicationDocumentsDirectory();
}
Future<String?> _getOriginalFileName(String fileId, String? fallback) async {
if (fallback != null && fallback.isNotEmpty) {
return fallback;
}
return await _localDbService.getOriginalFileNameForFileId(fileId);
}
Future<File> _resolveUniqueFilePath(String fileName) async {
final safeName = p.basename(fileName);
final directory = await _getDownloadsDirectory();
var candidatePath = p.join(directory.path, safeName);
if (!await File(candidatePath).exists()) {
return File(candidatePath);
}
final nameWithoutExtension = p.basenameWithoutExtension(safeName);
final extension = p.extension(safeName);
var counter = 1;
while (true) {
final candidateName = '$nameWithoutExtension ($counter)$extension';
candidatePath = p.join(directory.path, candidateName);
if (!await File(candidatePath).exists()) {
return File(candidatePath);
}
counter++;
}
}
Future<File> _getCachedDecryptedFile(String fileId, String? fileName) async {
final prefs = await SharedPreferences.getInstance();
final cachedPathKey = 'cached_dec_file_path_$fileId';
// Если путь уже был выделен ранее — возвращаем его без оглядки на физическое существование.
// Это предотвратит создание копий с суффиксами (1), (2) во время параллельных проверок.
final existingPath = prefs.getString(cachedPathKey);
if (existingPath != null) {
return File(existingPath);
}
final effectiveName =
await _getOriginalFileName(fileId, fileName) ?? 'file_$fileId';
final safeName = p.basename(effectiveName);
final decFile = await _resolveUniqueFilePath(safeName);
await prefs.setString(cachedPathKey, decFile.path);
return decFile;
}
Future<File?> _findExistingCachedDecryptedFile(
String? fileId,
String? fileName,
) async {
if (fileId == null) return null;
// 1. Проверяем оригинальный путь (актуально для отправленных нами файлов)
try {
final originalFromDb = await _localDbService.getOriginalFileNameForFileId(
fileId,
);
if (originalFromDb != null && originalFromDb.isNotEmpty) {
final candidate = File(originalFromDb);
if (await candidate.exists()) return candidate;
}
} catch (e) {
debugPrint('Ошибка проверки оригинального пути из БД: $e');
}
// 2. Проверяем закешированный путь из SharedPreferences
final prefs = await SharedPreferences.getInstance();
final cachedPathKey = 'cached_dec_file_path_$fileId';
final existingPath = prefs.getString(cachedPathKey);
if (existingPath != null) {
final existingFile = File(existingPath);
if (await existingFile.exists()) {
return existingFile;
}
}
return null;
}
Future<File> _getDownloadCopyFile(String fileId, String? fileName) async {
final effectiveName =
await _getOriginalFileName(fileId, fileName) ?? 'file_$fileId';
return _resolveUniqueFilePath(effectiveName);
}
void _updateScrollButtonVisibility() {
if (!mounted) return;
final positions = _itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
// Так как позиции в itemPositions приходят не отсортированными,
// находим фактический первый и последний видимый элемент
int firstVisible = positions.first.index;
int lastVisible = positions.first.index;
for (final pos in positions) {
if (pos.index < firstVisible) {
firstVisible = pos.index;
}
if (pos.index > lastVisible) {
lastVisible = pos.index;
}
}
// Если прокрутили вверх к старым сообщениям
if (lastVisible >= messages.length - 5) {
_loadOlderMessages();
}
// Если прокрутили вниз к новым сообщениям
if (firstVisible <= 2) {
_loadNewerMessages();
}
// 2. Логика кнопки скролла вниз:
// Показываем кнопку сразу, как только первый элемент (индекс 0) полностью ушел с экрана,
// либо если он виден, но его нижний край опустился ниже границы видимости (leadingEdge < -0.001)
bool showScrollButton = false;
ItemPosition? pos0;
for (final p in positions) {
if (p.index == 0) {
pos0 = p;
break;
}
}
if (pos0 == null) {
// Нижний элемент (индекс 0) больше не виден вообще
showScrollButton = true;
} else {
// Нижний элемент виден, но прокручен вниз за нижнюю границу (leadingEdge отрицательный)
if (pos0.itemLeadingEdge < -0.005) {
showScrollButton = true;
}
}
_showScrollButtonNotifier.value = showScrollButton;
}
Future<void> _scrollToBottom() async {
if (messages.isEmpty || !_itemScrollController.isAttached) return;
_itemScrollController.jumpTo(index: 0, alignment: 0);
}
Future<void> _scrollToMessage(int? messageId) async {
if (messageId == null || !_itemScrollController.isAttached) return;
// 1. Ищем индекс в массиве
final int msgIndex = messages.indexWhere((m) => m.id == messageId);
if (msgIndex == -1) return;
// 2. Учитываем сдвиг из-за лоадера (если он есть в начале списка)
// В reverse: true списке индекс 0 — это низ.
// Лоадер новых (если есть) занимает индекс 0.
final int scrollIndex = _hasMoreNewer
? (messages.length - 1 - msgIndex) + 1
: (messages.length - 1 - msgIndex);
// 3. Плавный скролл в центр
_itemScrollController.scrollTo(
index: scrollIndex,
alignment: 0.5, // Центр
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
Future<void> _loadHistoryAroundAnchor(int anchorId) async {
if (_chatSharedSecret == null) return;
print('Запущен прыжок к сообщению $anchorId через анкер.');
setState(() {
_isKeyLoading = true;
_suppressPagination =
true; // Выключаем триггеры автоматической пагинации во время прыжка
_isReadyForReading = false;
});
try {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
// 1. Быстро вытягиваем локальный кэш вокруг этого анкера
final cached = await _localDbService.getMessages(
widget.contact.id,
myId,
anchorMessageId: anchorId,
limitBefore: _limitBefore,
limitAfter: _limitAfter,
);
print('Получено ${cached.length} сообщений из локального кэша.');
Map<int, MessageModel> localMessagesMap = {};
for (var msg in cached) {
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
const Duration(),
{},
);
if (parsed != null && parsed.id != null) {
localMessagesMap[parsed.id!] = parsed;
}
}
if (localMessagesMap.isNotEmpty) {
setState(() {
messages = localMessagesMap.values.toList();
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
});
}
List<int> ids = messages.map((msg) => msg.id ?? -1).toList();
print('Список сообщений: $ids');
// 2. Фоново запрашиваем у сервера историю вокруг нужного сообщения
final responseBody = await apiService.getChatHistory(
widget.contact.id,
anchorId: anchorId,
limitBefore: _limitBefore,
limitAfter: _limitAfter,
);
final List<dynamic> history = responseBody['messages'] ?? [];
List<MessageModel> loadedMessages = [];
List<MessageModel> encryptedMessagesForStorage = [];
print('Получено ${history.length} сообщений из сети.');
for (var msg in history) {
final String rawCiphertext = msg['content'].toString();
final String? rawEncryptedReplyText = msg['reply_to_text']?.toString();
final parsed = await _parseAndDecryptMessage(
msg,
_chatSharedSecret!,
offset,
localMessagesMap,
);
if (parsed != null) {
loadedMessages.add(parsed);
encryptedMessagesForStorage.add(
MessageModel(
id: parsed.id,
text: rawCiphertext,
isMe: parsed.isMe,
senderId: parsed.senderId,
receiverId: parsed.receiverId,
createdAt: parsed.createdAt,
status: parsed.status,
replyToId: parsed.replyToId,
replyToText: rawEncryptedReplyText,
editedAt: parsed.editedAt,
messageType: parsed.messageType,
fileId: parsed.fileId,
encryptedFileKey: parsed.encryptedFileKey,
fileName: parsed.fileName,
fileSize: parsed.fileSize,
localFile: parsed.localFile,
),
);
}
}
ids = loadedMessages.map((msg) => msg.id ?? -1).toList();
print('Сообщения из сети загружены. Список: $ids');
// Сохраняем пачку в базу данных
if (encryptedMessagesForStorage.isNotEmpty) {
await _localDbService.saveMessages(encryptedMessagesForStorage);
}
print('Сообщения сохранены в БД.');
setState(() {
loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
messages = loadedMessages;
_isKeyLoading = false;
ids = messages.map((msg) => msg.id ?? -1).toList();
print('Сообщения отсортированы. Список: $ids');
// Корректно выставляем флаги пагинации для новой точки обзора
_hasMoreOlder =
loadedMessages
.where((m) => m.id != null && m.id! < anchorId)
.length >=
_limitBefore;
_hasMoreNewer =
loadedMessages
.where((m) => m.id != null && m.id! > anchorId)
.length >=
_limitAfter;
print('Имеются сообщения до: $_hasMoreOlder, после: $_hasMoreNewer');
});
// 3. Дожидаемся рендера списка
WidgetsBinding.instance.addPostFrameCallback((_) async {
// Даем чуть больше времени на инициализацию элементов ListView
await Future.delayed(const Duration(milliseconds: 2000));
final index = messages.indexWhere((m) => m.id == anchorId);
if (index != -1 && _itemScrollController.isAttached) {
_itemScrollController.jumpTo(index: index, alignment: 0.5);
}
setState(() {
_suppressPagination = false;
_isReadyForReading = true;
});
});
} catch (e) {
print("Ошибка при прыжке к сообщению через анкер: $e");
setState(() {
_isKeyLoading = false;
_suppressPagination = false;
_isReadyForReading = true;
});
}
}
void _openFullScreenMedia(MessageModel msg) async {
print('Открытие медиа');
if (msg.fileId == null) return;
// Показываем индикатор загрузки (диалог или крутилку)
_showLoadingDialog();
final decFile = await _getCachedDecryptedFile(msg.fileId!, msg.fileName);
final decPath = decFile.path;
// 1. ПРОВЕРКА КЭША: если расшифрованный файл уже есть, открываем мгновенно
if (await decFile.exists() && await decFile.length() > 0) {
_closeLoadingDialog();
_navigateToViewer(decPath, msg);
return;
}
// Проверяем наличие ключей перед началом долгого процесса
if (_chatSharedSecret == null || msg.encryptedFileKey == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Ошибка: Ключи шифрования недоступны")),
);
return;
}
try {
await _ensureFileDecrypted(msg);
final decFile = File(decPath);
if (await decFile.exists() && await decFile.length() > 0) {
_closeLoadingDialog();
_navigateToViewer(decPath, msg);
return;
}
} catch (e) {
debugPrint('Ошибка при обработке и дешифрации файла: $e');
}
// Если что-то пошло не так (ошибка сети, ошибка дешифрации)
if (mounted) {
_closeLoadingDialog();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Не удалось загрузить или расшифровать файл"),
),
);
}
}
void _closeLoadingDialog() {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
}
void _showLoadingDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
),
);
}
void _navigateToViewer(String path, MessageModel msg) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaViewer(
items: [
MediaItem(
path: path,
isVideo:
msg.messageType == MessageType.video ||
msg.messageType == 'video',
),
],
),
),
);
});
}
Future<void> _saveMediaToGallery(MessageModel msg) async {
final originalFile = msg.localFile;
if (originalFile == null || !originalFile.existsSync()) return;
File? tempFile;
try {
// 1. Проверка доступности файла
// Пытаемся открыть файл на чтение. Если файл занят, это выбросит ошибку,
// и мы подождем перед тем, как копировать.
bool isFileReady = false;
int attempts = 0;
while (!isFileReady && attempts < 5) {
try {
final raf = await originalFile.open(mode: FileMode.read);
await raf.close();
isFileReady = true;
} catch (e) {
await Future.delayed(const Duration(milliseconds: 500));
attempts++;
}
}
// 2. Копирование с правильным именем
String ext = p.extension(msg.fileName ?? '');
if (ext.isEmpty)
ext = (msg.messageType == MessageType.video) ? '.mp4' : '.jpg';
final String tempName =
'save_${DateTime.now().millisecondsSinceEpoch}$ext';
final Directory tempDir = await getTemporaryDirectory();
tempFile = await originalFile.copy(p.join(tempDir.path, tempName));
// 3. Сохранение
if (msg.messageType == MessageType.video ||
msg.messageType == MessageType.videoNote) {
await Gal.putVideo(tempFile.path);
} else {
await Gal.putImage(tempFile.path);
}
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Сохранено в галерею')));
} catch (e) {
debugPrint("Ошибка: $e");
// Если ошибка "битый файл", возможно, Gal еще держит временный файл
} finally {
// ВАЖНО: Удаляем только если файл существует
if (tempFile != null && await tempFile.exists()) {
try {
await tempFile.delete();
} catch (_) {}
}
}
}
Future<void> _showInExplorer(MessageModel msg) async {
try {
File? file = msg.localFile;
if (file == null || !await file.exists()) {
file = await _findExistingCachedDecryptedFile(msg.fileId, msg.fileName);
}
if (file == null) return;
final path = file.path;
if (Platform.isWindows) {
try {
await Process.run('explorer.exe', ['/select,${path}']);
} catch (e) {
debugPrint('Не удалось открыть проводник: $e');
}
} else {
// На других платформах просто открываем файл
await OpenFilex.open(path);
}
} catch (e) {
debugPrint('Ошибка _showInExplorer: $e');
}
}
Future<void> _saveFileToDownloads(MessageModel msg) async {
try {
File? original = msg.localFile;
if (original == null || !await original.exists()) {
original = await _findExistingCachedDecryptedFile(
msg.fileId,
msg.fileName,
);
}
if (original == null) return;
final File target = await _getDownloadCopyFile(
msg.fileId ?? '',
msg.fileName,
);
// Ensure parent exists
final parent = Directory(p.dirname(target.path));
if (!await parent.exists()) await parent.create(recursive: true);
await original.copy(target.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Файл сохранён в: ${target.path}')),
);
}
} catch (e) {
debugPrint('Ошибка сохранения в загрузки: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Не удалось сохранить файл: $e')),
);
}
}
}
Future<Uint8List?> _downloadAndDecryptImage(
String fileId,
String encryptedFileKey,
SecretKey sharedSecret, {
void Function(int received, int total)? onProgress,
}) async {
try {
print('DEBUG downloadMedia(fileId=$fileId)');
final bytes = await apiService.downloadMedia(
fileId,
onProgress: onProgress,
);
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.decryptMedia(
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;
}
}
Widget _buildPreviewIcon() {
switch (_pendingMessageType) {
case MessageType.image:
return _previewBytes != null
? Image.memory(_previewBytes!, fit: BoxFit.cover)
: Container(
color: Colors.grey.withOpacity(0.2),
child: const Icon(Icons.image, color: Colors.grey),
);
case MessageType.video:
return Container(
color: Colors.black,
child: const Icon(Icons.videocam, color: Colors.white),
);
case MessageType.file:
default:
return Container(
color: Colors.blue.withOpacity(0.1),
child: const Icon(Icons.insert_drive_file, color: Colors.blue),
);
}
}
// 1. Метод конвертации: из БД в UI-модель
MessageModel _fromDbMessage(Message dbMsg, int currentUserId) {
return MessageModel(
id: dbMsg.id,
tempId:
null, // Из базы мы поднимаем уже подтвержденные сервером сообщения
senderId: dbMsg.senderId,
receiverId: dbMsg.receiverId,
text: dbMsg.content, // ФИКС: маппинг content -> text
createdAt: DateTime.parse(
dbMsg.timestamp,
), // ФИКС: маппинг timestamp -> createdAt
isMe: dbMsg.senderId == currentUserId,
// ФИКС: Высчитываем статус на основе дат из БД
status: dbMsg.readAt != null
? MessageStatus.read
: (dbMsg.deliveredAt != null
? MessageStatus.delivered
: MessageStatus.sent),
replyToId: dbMsg.replyToId,
replyToText: dbMsg.replyToText,
editedAt: dbMsg.editedAt != null ? DateTime.parse(dbMsg.editedAt!) : null,
messageType: MessageModel.parseMessageType(dbMsg.messageType),
fileId: dbMsg.fileId,
encryptedFileKey: dbMsg.encryptedKey,
fileName: dbMsg.fileName,
);
}
// 2. Метод конвертации: из UI-модели в сущность для БД
MessagesCompanion _toDbCompanion(MessageModel msg) {
return MessagesCompanion(
id: msg.id != null ? drift.Value(msg.id!) : const drift.Value.absent(),
senderId: drift.Value(msg.senderId),
receiverId: drift.Value(msg.receiverId),
content: drift.Value(msg.text), // ФИКС: text -> content
timestamp: drift.Value(msg.createdAt.toIso8601String()),
deliveredAt:
msg.status == MessageStatus.delivered ||
msg.status == MessageStatus.read
? drift.Value(DateTime.now().toIso8601String())
: const drift.Value.absent(),
readAt: msg.status == MessageStatus.read
? drift.Value(DateTime.now().toIso8601String())
: const drift.Value.absent(),
replyToId: drift.Value(msg.replyToId),
replyToText: drift.Value(msg.replyToText),
editedAt: drift.Value(msg.editedAt?.toIso8601String()),
messageType: drift.Value(msg.messageType.name),
fileId: drift.Value(msg.fileId),
encryptedKey: drift.Value(msg.encryptedFileKey),
fileName: drift.Value(msg.fileName),
);
}
}
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)),
),
);
}
}
typedef _OnWidgetSizeChange = void Function(Size size);
class _MeasureSize extends SingleChildRenderObjectWidget {
final _OnWidgetSizeChange onChange;
const _MeasureSize({super.key, required super.child, required this.onChange});
@override
RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange: onChange);
}
@override
void updateRenderObject(
BuildContext context,
_MeasureSizeRenderObject renderObject,
) {
renderObject.onChange = onChange;
}
}
class _MeasureSizeRenderObject extends RenderProxyBox {
_OnWidgetSizeChange onChange;
Size? _oldSize;
_MeasureSizeRenderObject({required this.onChange, RenderBox? child})
: super(child);
@override
void performLayout() {
super.performLayout();
final newSize = size;
if (_oldSize != newSize) {
_oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
}