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'; import 'package:flutter_contacts/flutter_contacts.dart' as fc; 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 createState() => _ChatScreenState(); } class _ChatScreenState extends State 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 messages = []; StreamSubscription? _socketSubscription; final Set _sentReadReceipts = {}; final LocalDbService _localDbService = LocalDbService(); final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); final Map _messageMap = {}; final ValueNotifier _showScrollButtonNotifier = ValueNotifier( 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 _inputBarHeightNotifier = ValueNotifier( 64.0, ); SecretKey? _chatSharedSecret; final Map> _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? _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(context, listen: false); currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат _cancelChatNotifications(); final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе _loadLocalName(); if (_currentContact.publicKey == null) { _loadContactKey(); } _initialLoad(); _loadOnlineStatus(); startOnlineUpdates(); _controller.addListener(_sendTypingStatus); _itemPositionsListener.itemPositions.addListener( _updateScrollButtonVisibility, ); final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); _initCameras(); } Future _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 _cancelChatNotifications() async { try { await flutterLocalNotificationsPlugin.cancel( id: currentActiveChatContactId!, ); } catch (e) { print('Delete notifications for this chat wasnt succesful: $e'); } } Future _loadLocalName() async { final prefs = await SharedPreferences.getInstance(); final String? savedName = prefs.getString( 'firstname_${_currentContact.id}', ); final String? savedSurname = prefs.getString( 'lastname_${_currentContact.id}', ); String? phoneBookName; if ((savedName == null && savedSurname == null) && _currentContact.phone != null && (Platform.isAndroid || Platform.isIOS)) { try { final hasPermission = await fc.FlutterContacts.requestPermission(readonly: true); if (hasPermission) { final deviceContacts = await fc.FlutterContacts.getContacts(withProperties: true); String normalizePhone(String p) { final digits = p.replaceAll(RegExp(r'\D'), ''); if (digits.length >= 10) { return digits.substring(digits.length - 10); } return digits; } final normTarget = normalizePhone(_currentContact.phone!); if (normTarget.isNotEmpty) { for (var dc in deviceContacts) { for (var phoneObj in dc.phones) { if (normalizePhone(phoneObj.number) == normTarget) { phoneBookName = dc.displayName; break; } } if (phoneBookName != null) break; } } } } catch (e) { print("Ошибка получения имени контакта из телефонной книги в чате: $e"); } } if (mounted) { setState(() { if (savedName != null || savedSurname != null) { _currentContact.name = savedName ?? ''; _currentContact.surname = savedSurname ?? ''; } else if (phoneBookName != null && phoneBookName!.isNotEmpty) { _currentContact.name = phoneBookName!; _currentContact.surname = ''; } }); } } // Инициализация камер устройства для кружочков Future _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 _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 _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 _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(context, listen: false); socketService.sendMessage({ 'type': 'typing', 'receiver_id': _currentContact.id, }); } } void _sendStopTypingStatus() { _socketService.sendMessage({ 'type': 'stop_typing', 'receiver_id': _currentContact.id, }); } Future _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 _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(); 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( 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( 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( '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( valueListenable: _currentContact.id == 0 ? ValueNotifier(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( 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 _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 _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 _showMessageActions(MessageModel msg) async { if (!mounted) return; await showModalBottomSheet( 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 _editMessage(MessageModel msg) async { final controller = TextEditingController(text: msg.text); final result = await showDialog( 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(context, listen: false).sendMessage({ 'type': 'edit_message', 'message_id': msg.id, 'content': encryptedContent, 'content50': encryptedContent50, }); } } Future _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( context, listen: false, ).sendMessage({'type': 'delete_message', 'message_id': id}); } else if (msg.tempId != null) { try { await _localDbService.deleteMessage(msg.tempId!); } catch (_) {} } } Future _showForwardContactPicker(MessageModel msg) async { // Открываем новый красивый экран выбора вместо bottomSheet final selectedContact = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ForwardContactPickerScreen(message: msg), ), ); // Если контакт был выбран и нажата кнопка «Продолжить» if (selectedContact != null && mounted) { // Запускаем твою готовую и исправленную функцию пересылки медиа/текста await _forwardMessage(msg, selectedContact); } } Future _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(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: { // 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( 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 _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 _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? 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 _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 _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). // Используем двойной слэш \\, чтобы экранирование запятой дошло до парсера FFmpeg final List 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 _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( 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(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( 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 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'] == 'chat_deleted') { final contactId = int.tryParse(data['contact_id']?.toString() ?? ''); if (contactId == widget.contact.id) { if (mounted) { setState(() { messages.clear(); }); widget.onBack?.call(); } } return; } 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(); 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(); 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 _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 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 history = responseBody['messages'] ?? []; print( '[DEBUG] Сервер вернул анкер: $serverAnchorId, сообщений: ${history.length}', ); final alreadyReadIncomingMessageIds = {}; List loadedMessages = []; List 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.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; final isAtBottom = positions.isNotEmpty && positions.any((p) => p.index == 0); if (!hasUnread || isAtBottom) { _socketService.sendReadAllChat(widget.contact.id); _markAllLocalMessagesAsRead(); } } }); }); } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; setState(() => _isKeyLoading = false); } } Future _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 _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 historyList = history['messages'] ?? []; if (historyList.isEmpty) { setState(() { _hasMoreOlder = false; _isLoadingOlder = false; }); return; } List 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 _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 historyList = history['messages'] ?? []; if (historyList.isEmpty) { setState(() { _hasMoreNewer = false; _isLoadingNewer = false; }); return; } List 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 _parseAndDecryptMessage( Map msg, SecretKey sharedSecret, Duration offset, Map 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>> _activeDownloads = {}; Future _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 _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 _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(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> 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 downloadCompleter = Completer(); 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 _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 _getOriginalFileName(String fileId, String? fallback) async { if (fallback != null && fallback.isNotEmpty) { return fallback; } return await _localDbService.getOriginalFileNameForFileId(fileId); } Future _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 _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 _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 _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; // Если пользователь внизу и есть непрочитанные сообщения от собеседника, отправляем прочтение всех if (!showScrollButton) { final hasUnread = messages.any( (m) => !m.isMe && m.status != MessageStatus.read, ); if (hasUnread) { print("DEBUG: Пользователь внизу чата, отправляем прочтение всех сообщений"); _socketService.sendReadAllChat(_currentContact.id); _markAllLocalMessagesAsRead(); } } } Future _markAllLocalMessagesAsRead() async { bool updated = false; 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); updated = true; } } if (updated && mounted) { setState(() {}); } // Обновляем список контактов в провайдере final contactProvider = context.read(); await contactProvider.updateContact( _currentContact.id, unreadCount: 0, firstUnreadMessageId: 0, ); // Обновляем локальную базу данных try { await _localDbService.markAllAsRead(_currentContact.id, myId); } catch (e) { print('Ошибка при пометке всех сообщений прочитанными в БД: $e'); } } Future _scrollToBottom() async { if (messages.isEmpty || !_itemScrollController.isAttached) return; _itemScrollController.jumpTo(index: 0, alignment: 0); } Future _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 _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 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 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 history = responseBody['messages'] ?? []; List loadedMessages = []; List 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 _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 _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 _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 _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 _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 createState() => _TypingIndicatorState(); } class _TypingIndicatorState extends State 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); }); } } }