import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:cryptography/cryptography.dart'; import '/data/models/message_model.dart'; import '/data/models/contact_model.dart'; import 'package:chepuhagram/presentation/widgets/message_bubble.dart'; import 'package:gal/gal.dart'; import 'package:chepuhagram/data/repositories/contact_repository.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:provider/provider.dart'; import '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:image_picker/image_picker.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'; class ChatScreen extends StatefulWidget { final Contact contact; const ChatScreen({super.key, required this.contact}); @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 ScrollController _scrollController = ScrollController(); final Map _messageKeys = {}; Map _messageMap = {}; bool _showScrollToEnd = 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; double _inputBarHeight = 0; SecretKey? _chatSharedSecret; final Map> _mediaLoadFutures = {}; 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(); Stopwatch _stopwatch = Stopwatch(); Timer? _stopwatchTimer; String _stopwatchDisplay = "0:00"; CameraController? _cameraController; List? _cameras; bool _isCameraInitialized = false; @override void initState() { super.initState(); _currentContact = widget.contact; _socketService = Provider.of(context, listen: false); currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе _loadLocalName(); if (_currentContact.publicKey == null) { _loadContactKey(); } _loadHistory(); _loadOnlineStatus(); startOnlineUpdates(); _controller.addListener(_sendTypingStatus); _scrollController.addListener(_updateScrollButtonVisibility); final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); _initCameras(); } @override void didChangeDependencies() { super.didChangeDependencies(); routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); } @override void didPopNext() { print("Пользователь вернулся на этот экран!"); _loadLocalName(); flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); } Future _loadLocalName() async { final prefs = await SharedPreferences.getInstance(); final String? savedName = prefs.getString( 'firstname_${_currentContact.id}', ); final String? savedSurname = prefs.getString( 'lastname_${_currentContact.id}', ); print('Загружены имя $savedName, $savedSurname'); if (mounted) { setState(() { if (savedName != null) { _currentContact.name = savedName; } if (savedSurname != null) { _currentContact.surname = savedSurname; } }); } } // Инициализация камер устройства для кружочков Future _initCameras() async { try { _cameras = await availableCameras(); if (!mounted) return; if (_cameras != null && _cameras!.isNotEmpty) { // Пытаемся найти фронтальную камеру по умолчанию для кружков final frontCamera = _cameras!.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => _cameras!.first, ); _cameraController = CameraController( frontCamera, ResolutionPreset.medium, enableAudio: true, // Звук пишется в видео-файл ); } } 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; }); try { if (_isVoiceMode) { // Проверка разрешений микрофона встроенная в record 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 { // Режим кружочка (видео) if (_cameraController != null) { await _cameraController!.initialize(); if (mounted) { setState(() { _isCameraInitialized = true; }); await _cameraController!.startVideoRecording(); } } } } catch (e) { debugPrint("Ошибка старта записи: $e"); } } // ФИКСАЦИЯ НА ЗАМОК void _lockRecording() { HapticFeedback.mediumImpact(); setState(() { _isRecordLocked = true; }); } // ОТМЕНА ЗАПИСИ Future _cancelRecording() async { HapticFeedback.heavyImpact(); _stopStopwatch(); setState(() { _isRecording = false; _isRecordLocked = false; _isCameraInitialized = false; }); try { if (_isVoiceMode) { await _audioRecorder.stop(); // Просто останавливаем без сохранения } else { if (_cameraController != null && _cameraController!.value.isRecordingVideo) { await _cameraController!.stopVideoRecording(); } } } 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) { XFile videoFile = await _cameraController!.stopVideoRecording(); filePath = videoFile.path; } } } catch (e) { debugPrint("Ошибка при остановке записи: $e"); } setState(() { _isRecording = false; _isRecordLocked = false; _isCameraInitialized = false; }); if (filePath != null) { File fileToSend = File(filePath); setState(() { _pendingFile = fileToSend; _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; flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); try { print( "🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})", ); final data = await apiService.getUserById(_currentContact.id); if (!mounted) return; DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; print( "✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}", ); setState(() { _isOnline = data['online'] ?? false; if (data['last_online'] != null) _lastOnline = DateTime.parse(data['last_online']).add(offset); else _lastOnline = null; }); } catch (e) { print(e); } } void startOnlineUpdates() { _onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) { _loadOnlineStatus(); }); } Future _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: default: 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(); _scrollController.removeListener(_updateScrollButtonVisibility); _scrollController.dispose(); _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(); return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { if (Navigator.canPop(context)) { Navigator.pop(context); } else { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const ContactsScreen()), ); } }, ), title: GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => UserProfileScreen( userId: _currentContact.id, username: _currentContact.username, name: _currentContact.name, ), ), ); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}', ), if (_isKeyLoading == true) const Text( 'загрузка...', style: TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ) else if (_isTyping) Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const Text( 'печатает', style: TextStyle(fontSize: 12, color: Colors.greenAccent), ), const SizedBox(width: 4), TypingIndicator(), ], ) else if (_isOnline) const Text( 'онлайн', style: TextStyle(fontSize: 12, color: Colors.greenAccent), ) else if (_lastOnline != null) Text( 'был(а) в сети ${_formatLastOnline(_lastOnline!)}', style: const TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ) else const Text( 'был(а) недавно', style: TextStyle( fontSize: 12, color: Color.fromARGB(255, 219, 219, 219), ), ), ], ), ), ), body: Container( decoration: themeProv.wallpaperPath != null ? BoxDecoration( image: DecorationImage( image: FileImage(File(themeProv.wallpaperPath!)), fit: BoxFit.cover, ), ) : null, child: Stack( children: [ Positioned.fill( child: CustomScrollView( controller: _scrollController, reverse: true, cacheExtent: 0, // Сохраняем: строго запрещает предзагрузку элементов вне экрана physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( padding: EdgeInsets.only( bottom: _inputBarHeight * (MediaQuery.of(context).viewInsets.bottom > 0 ? 1.0 : 1.0) + 28, left: 8, right: 8, top: 8, ), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final msg = messages[messages.length - 1 - index]; final keyId = msg.id ?? msg.tempId ?? index; final itemKey = _messageKeys.putIfAbsent( keyId, () => GlobalKey(), ); final isMedia = msg.messageType == MessageType.image || msg.messageType == MessageType.video || msg.messageType == MessageType.file; final showDateDivider = _isNewDay(index); // Формируем основное содержимое элемента сообщения Widget itemChild = Column( crossAxisAlignment: CrossAxisAlignment.start, key: ValueKey(keyId.hashCode), mainAxisSize: MainAxisSize.min, children: [ if (showDateDivider) 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(msg.createdAt), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), ), ), Dismissible( direction: DismissDirection.endToStart, key: ValueKey('dismiss_$keyId'), confirmDismiss: (DismissDirection direction) async { String text = msg.text; if (msg.text.isEmpty && msg.messageType == MessageType.image) { text = "[Фото]"; } setState( () => _replyTo = msg.copyWith(text: text), ); return false; }, child: RepaintBoundary( child: MessageBubble( key: ValueKey( '${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}', ), message: msg, onTap: () => _showMessageActions(msg), onReplyTap: msg.replyToId != null ? () => _scrollToMessage(msg.replyToId) : null, onImageTap: () => _openFullScreenMedia(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); }, ), ), ), ], ); // Если это медиафайл или документ, оборачиваем в VisibilityDetector if (isMedia) { return VisibilityDetector( key: ValueKey('visible_${keyId}'), onVisibilityChanged: (visibilityInfo) { // Как только элемент показался в зоне видимости хотя бы на 10% if (visibilityInfo.visibleFraction > 0.1) { if (msg.fileSize == null || msg.fileSize == 0) { print( "Элемент стал видим. Фоновый запрос размера для: ${msg.fileId}", ); _fetchFileSizeIfNeeded(msg); } } }, child: itemChild, ); } // Обычный текст возвращаем без детектора видимости return itemChild; }, childCount: messages.length), ), ), ], ), ), // 3. Кнопка скролла AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, right: 16.0, bottom: _inputBarHeight + 8.0 + 16, child: AnimatedOpacity( opacity: _showScrollToEnd ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, child: IgnorePointer( ignoring: !_showScrollToEnd, child: ClipOval( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of( context, ).colorScheme.surfaceVariant.withOpacity(0.75), border: Border.all( color: Theme.of( context, ).dividerColor.withOpacity(0.25), width: 1, ), ), child: SizedBox( width: 40, height: 40, child: IconButton( onPressed: _scrollToBottom, icon: Icon( Icons.keyboard_arrow_down, color: Theme.of(context).colorScheme.onSurface, ), ), ), ), ), ), ), ), ), if (_isRecording && !_isVoiceMode && _isCameraInitialized && _cameraController != null) IgnorePointer( child: Center( child: Container( width: MediaQuery.of(context).size.width * 0.9, // Увеличили размер, так как по центру экрана круг должен быть крупным и четким height: MediaQuery.of(context).size.width * 0.9, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.5), blurRadius: 25, spreadRadius: 4, ), ], ), child: ClipOval( child: LayoutBuilder( builder: (context, constraints) { // Берем пропорции самой камеры (важно: для вертикального отображения инвертируем) final cameraAspectRatio = _cameraController!.value.aspectRatio; // Оборачиваем в OverflowBox, чтобы видео заполняло круг по меньшей стороне, а лишнее обрезалось return OverflowBox( alignment: Alignment.center, child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: constraints.maxWidth, height: constraints.maxWidth * cameraAspectRatio, child: CameraPreview(_cameraController!), ), ), ); }, ), ), ), ), ), // 4. Плавающее поле ввода Positioned( left: 0, right: 0, bottom: 16, child: _MeasureSize( onChange: (size) { if (_inputBarHeight != size.height) { setState(() { _inputBarHeight = size.height; }); if (!_showScrollToEnd) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( 0.0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } } }, child: SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 0, 16, 2), child: ClipRRect( borderRadius: BorderRadius.circular(18), clipBehavior: Clip.antiAlias, child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, alignment: Alignment.bottomCenter, child: ClipRRect( borderRadius: BorderRadius.circular(18), clipBehavior: Clip.hardEdge, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( color: Theme.of( context, ).colorScheme.surfaceVariant.withOpacity(0.5), child: _buildMessageInput(), ), ), ), ), ), ), ), ), ], ), ), ); } 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)); }); }, ), 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); }); }, ), ListTile( leading: const Icon(Icons.forward), title: const Text('Переслать'), onTap: () { WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.of(ctx).pop(); _showForwardContactPicker(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 _deleteLocalFile(MessageModel msg) async { if (msg.localFile != null && msg.localFile!.existsSync()) { try { await msg.localFile!.delete(); debugPrint("Локальный файл успешно удален с диска: ${msg.fileId}"); } catch (e) { debugPrint("Ошибка при физическом удалении файла с диска: $e"); // Даже если файл не удалился физически, мы всё равно очистим стейт, // чтобы приложение не пыталось его прочитать и не падало. } final sharedPrefs = await SharedPreferences.getInstance(); final String sizeKey = 'valid_dec_size_${msg.fileId}'; await sharedPrefs.remove(sizeKey); if (mounted) { setState(() { final idx = messages.indexWhere((m) => m.id == msg.id); print( "Очистка локального файла для сообщения ${msg.id}. Индекс в списке: $idx", ); if (idx != -1) { messages[idx] = messages[idx].copyWith(localFile: null); } }); } } } 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 > 50 ? newText.substring(0, 50) : newText; final encryptedContent50 = await _cryptoService.encryptMessage( content50, sharedSecret, ); setState(() { messages = messages.map((m) { if (m.id != null && m.id == msg.id) { return m.copyWith(text: newText, editedAt: DateTime.now()); } return m; }).toList(); }); if (msg.id != null) { try { await _localDbService.updateMessageContent( msg.id!, encryptedContent, DateTime.now(), ); } catch (_) {} Provider.of(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}); } } 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 = '${directory.path}/dec_$copiedFileId'; newLocalFile = await originalMsg.localFile!.copy(decFile); print( "Локальный файл для пересылки создан по пути: ${newLocalFile.path}", ); } else if (originalMsg.fileId != null) { final directory = await getApplicationDocumentsDirectory(); // Сохраняем строго под префиксом file_, который ожидает MessageBubble final decFile = '${directory.path}/dec_$copiedFileId'; final File oldFile = File( '${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; return Stack( clipBehavior: Clip.none, children: [ Container( constraints: const BoxConstraints(maxHeight: 250), decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.surfaceVariant.withOpacity(0.75), borderRadius: BorderRadius.circular(18), border: Border.all( color: Theme.of(context).dividerColor.withOpacity(0.25), width: 1, ), ), padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_replyTo != null) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ const Icon(Icons.reply, size: 18), const SizedBox(width: 8), Expanded( child: Text( _replyTo!.text.isNotEmpty ? _replyTo!.text : _getMediaPreview(_replyTo!.messageType), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.close, size: 18), onPressed: () => setState(() => _replyTo = null), ), ], ), ), if (_pendingFile != null) Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.withOpacity(0.3)), ), child: Row( children: [ SizedBox( width: 44, height: 44, child: ClipRRect( borderRadius: BorderRadius.circular(8), 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, ), ), Text( _pendingMessageType.name.toUpperCase(), style: const TextStyle(fontSize: 12), ), ], ), ), IconButton( icon: const Icon(Icons.close, size: 22), 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: 32, height: 32, alignment: Alignment.center, child: const Icon(Icons.photo, size: 22), ), ) else const Padding( padding: EdgeInsets.symmetric(horizontal: 6, vertical: 6), child: Icon( Icons.fiber_manual_record, color: Colors.red, size: 20, ), ), Expanded( child: _isRecording ? Container( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 4, ), child: Row( children: [ Text( _stopwatchDisplay, style: const TextStyle( color: Colors.red, fontSize: 15, fontWeight: FontWeight.bold, ), ), const SizedBox(width: 8), _isRecordLocked ? Text( "Удержание записи", style: TextStyle( color: Colors.grey.shade500, fontSize: 11, ), ) : Text( _recordDragX < _swipeCancelThreshold / 2 ? "Отпусти для отмены" : (_recordDragY < _swipeLockThreshold / 2 ? "Отпусти для удержания" : "Проведите вверх для удержания"), style: TextStyle( color: Colors.grey.shade500, fontSize: 11, ), ), const SizedBox(width: 4), ], ), ) : TextField( controller: _controller, minLines: 1, maxLines: 5, readOnly: _isRecordLocked, textInputAction: TextInputAction.newline, textCapitalization: TextCapitalization.sentences, textAlignVertical: TextAlignVertical.center, style: const TextStyle(fontSize: 15), decoration: InputDecoration( hintText: _isRecordLocked ? "Запись зафиксирована..." : "Напиши сообщение...", isDense: true, isCollapsed: true, contentPadding: const EdgeInsets.symmetric( horizontal: 4, vertical: 6, ), ), onChanged: (text) => setState(() {}), ), ), _buildContextButton(showSendButton), ], ), ], ), ), ], ); } Widget _buildContextButton(bool showSendButton) { if (showSendButton) { return GestureDetector( onTap: () { if (_isRecordLocked) { _stopAndSendRecording(); } else { _sendMessage(); } }, child: Container( width: 36, height: 36, alignment: Alignment.center, child: const Icon(Icons.send, size: 22), ), ); } 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: 100), width: 36, height: 36, alignment: Alignment.center, decoration: BoxDecoration( color: _isRecording ? Colors.red.withOpacity(0.15) : Colors.transparent, shape: BoxShape.circle, ), child: Icon( _isVoiceMode ? Icons.mic : Icons.videocam, size: _isRecording ? 24 : 22, color: _isRecording ? Colors.red : Theme.of(context).iconTheme.color, ), ), ); } 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: [ 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 { 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 { 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). // Больше никаких ручных кавычек вокруг путей типа "$tempInputPath" — плагин сам всё экранирует! 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. Вызываем executeWithArguments вместо обычной строки final session = await FFmpegKit.executeWithArguments(ffmpegArgs); final returnCode = await session.getReturnCode(); // Логируем внутренний вывод FFmpeg на случай непредвиденных ошибок кодека устройства 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('==> Критическая ошибка при изменении файла: $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("Ошибка загрузки файла на сервер"); } encryptedFileKey = fileKeyForServer; } // 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к медиа) // Если текста нет, но есть медиа, отправим пустую строку final String textToEncrypt = rawText.isNotEmpty ? rawText : (hasMedia ? "" : ""); encryptedContent = await _cryptoService.encryptMessage( textToEncrypt, sharedSecret, ); // Генерируем превью текст в зависимости от типа медиа String previewText; if (rawText.isNotEmpty) { previewText = rawText; } else if (hasMedia) { previewText = switch (messageType) { MessageType.voiceNote => "[Кружок}", MessageType.videoNote => "[Голосовое]", MessageType.image => "[Фото]", MessageType.video => "[Видео]", MessageType.file => "[Файл]", MessageType.text => "", }; } else { previewText = ""; } if (previewText.length > 50) previewText = previewText.substring(0, 50); encryptedContent50 = await _cryptoService.encryptMessage( previewText, sharedSecret, ); if (replyTo?.id != null && replyTo!.text.trim().isNotEmpty) { encryptedReplyToText = await _cryptoService.encryptMessage( replyTo.text, sharedSecret, ); } // 4. Создаем локальную модель для мгновенного отображения final 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) { await file.copy('${directory.path}/dec_$fileId'); print( "DEBUG: Сохраняю файл: ${file.path}, существует: ${await file.exists()}, размер: ${await file.length()}", ); final sharedPrefs = await SharedPreferences.getInstance(); final String sizeKey = 'valid_dec_size_$fileId'; 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; }); } 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'] == 'message_sent') { final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); var ts = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); if (tempId == null) return; if (!mounted) return; setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); if (idx == -1) return; // 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; } }); 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(); Uint8List? decryptedImageBytes; // Lazy load images later if (!mounted) return; final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) { Provider.of( context, listen: false, ).sendReadReceipt(serverMessageId); _sentReadReceipts.add(serverMessageId); } final replyToText = await _decryptReplyText( data['reply_to_text']?.toString(), sharedSecret, ); setState(() { messages.add( MessageModel( id: int.tryParse(data['id']?.toString() ?? ''), text: decryptedText, isMe: false, senderId: senderId, receiverId: myId, createdAt: DateTime.parse(data['timestamp']).add(offset), status: MessageStatus.delivered, replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()), replyToText: replyToText, messageType: _parseMessageTypeString(data['message_type']), fileId: data['file_id']?.toString(), encryptedFileKey: encryptedFileKey, //localFile: decryptedImageBytes,!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ), ); }); // Save to local DB with cached image bytes try { await _localDbService.saveMessages([messages.last]); } catch (e) { print('Error saving incoming message to DB: $e'); } } catch (e) { print("Ошибка расшифровки входящего сообщения: $e"); } } else { print( "Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате", ); } } if (data['type'] == 'user_online') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId == widget.contact.id) { setState(() => _isOnline = true); } } if (data['type'] == 'user_offline') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId == widget.contact.id) { setState(() { _isOnline = false; _lastOnline = DateTime.now(); }); _loadOnlineStatus(); } } if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) { if (mounted) { setState(() => _isTyping = true); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 4), () { if (mounted) setState(() => _isTyping = false); }); } } if (data['type'] == 'stop_typing' && data['sender_id'] == _currentContact.id) { if (mounted) { setState(() => _isTyping = false); _typingTimer?.cancel(); } } } Future _loadHistory() async { DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); try { print('[DEBUG] Начало загрузки истории'); final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); print('[DEBUG] Ключи получены'); final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); print('[DEBUG] Локальная история загружена: ${cached.length} сообщений'); _chatSharedSecret = sharedSecret; // Сюда будем складывать успешно расшифрованные локальные сообщения // Используем Map, где ключ — id сообщения, для мгновенного поиска Map localMessagesMap = {}; try { for (var msg in cached) { final msgId = int.tryParse(msg['id']?.toString() ?? ''); if (msgId == null) continue; try { final decrypted = await _cryptoService.decryptMessage( msg['content'], sharedSecret, ); final deliveredAt = msg['delivered_at'] == null ? null : DateTime.tryParse( msg['delivered_at'].toString(), )?.add(offset); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString())?.add(offset); MessageStatus status = (msg['sender_id'] == myId) ? MessageStatus.sent : MessageStatus.delivered; if (msg['sender_id'] == myId) { if (readAt != null) { status = MessageStatus.read; } else if (deliveredAt != null) { status = MessageStatus.delivered; } } localMessagesMap[msgId] = MessageModel( id: msgId, text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']).add(offset), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: await _decryptReplyText( msg['reply_to_text']?.toString(), sharedSecret, ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, messageType: _parseMessageTypeString(msg['message_type']), fileId: msg['file_id']?.toString(), encryptedFileKey: msg['encrypted_key']?.toString(), fileName: msg['file_name']?.toString(), fileSize: msg['file_size'] == null ? null : int.tryParse(msg['file_size'].toString()), // ВАЖНО: Если в твоем MessageModel при чтении из БД как-то парсится localFile, // обязательно пропиши его инициализацию здесь! ); } catch (e) { // Обработка ошибки дешифровки локального сообщения... final deliveredAt = msg['delivered_at'] == null ? null : DateTime.tryParse( msg['delivered_at'].toString(), )?.add(offset); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString())?.add(offset); MessageStatus status = (msg['sender_id'] == myId) ? MessageStatus.sent : MessageStatus.delivered; if (msg['sender_id'] == myId) { if (readAt != null) { status = MessageStatus.read; } else if (deliveredAt != null) { status = MessageStatus.delivered; } } localMessagesMap[msgId] = MessageModel( id: msgId, text: msg['content'], isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']).add(offset), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: await _decryptReplyText( msg['reply_to_text']?.toString(), sharedSecret, ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, messageType: _parseMessageTypeString(msg['message_type']), fileId: msg['file_id']?.toString(), encryptedFileKey: msg['encrypted_key']?.toString(), fileName: msg['file_name']?.toString(), fileSize: msg['file_size'] == null ? null : int.tryParse(msg['file_size'].toString()), ); //print('Ошибка дешифровки сообщения: $e'); } } if (localMessagesMap.isNotEmpty) { if (!mounted) return; setState(() { messages = localMessagesMap.values.toList(); _isKeyLoading = false; }); } } catch (e) { print(e); } final history = await apiService.getChatHistory(widget.contact.id); print('[DEBUG] Загружена история из API: ${history.length}'); final alreadyReadIncomingMessageIds = {}; List loadedMessages = []; for (var msg in history) { print(msg); final msgId = int.tryParse(msg['id']?.toString() ?? ''); if (msgId != null && msg['sender_id'] != myId && msg['read_at'] != null) { alreadyReadIncomingMessageIds.add(msgId); } final decrypted = await _cryptoService.decryptMessage( msg['content'], sharedSecret, ); final deliveredAt = msg['delivered_at'] == null ? null : DateTime.tryParse(msg['delivered_at'].toString())?.add(offset); final readAt = msg['read_at'] == null ? null : DateTime.tryParse(msg['read_at'].toString())?.add(offset); MessageStatus status = (msg['sender_id'] == myId) ? MessageStatus.sent : MessageStatus.delivered; if (msg['sender_id'] == myId) { if (readAt != null) { status = MessageStatus.read; } else if (deliveredAt != null) { status = MessageStatus.delivered; } } // КРИТИЧЕСКИЙ ФИКС: Ищем, есть ли это сообщение уже в локальном кэше File? existingLocalFile; if (msgId != null && localMessagesMap.containsKey(msgId)) { existingLocalFile = localMessagesMap[msgId]?.localFile; } loadedMessages.insert( 0, MessageModel( id: msgId, text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']).add(offset), status: status, replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), replyToText: await _decryptReplyText( msg['reply_to_text']?.toString(), sharedSecret, ), editedAt: msg['edited_at'] != null ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) : null, messageType: _parseMessageTypeString(msg['message_type']), fileId: msg['file_id']?.toString(), encryptedFileKey: msg['encrypted_key']?.toString(), fileName: msg['file_name']?.toString(), fileSize: msg['file_size'] == null ? null : int.tryParse(msg['file_size'].toString()), // СОХРАНЯЕМ ФАЙЛ: Если он уже был скачан, мы не даем ему стать null! localFile: existingLocalFile, ), ); } try { print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД'); await _localDbService.saveMessages(loadedMessages); print('[DEBUG] Сообщения сохранени в локальную бд'); } catch (e) { print("[ERROR] Ошибка сохранения истории в локальную базу: $e"); } if (!mounted) return; setState(() { messages = loadedMessages; _isKeyLoading = false; }); // Отправка read_receipt... for (final m in loadedMessages) { if (m.isMe) continue; final id = m.id; if (id == null) continue; if (alreadyReadIncomingMessageIds.contains(id)) continue; if (_sentReadReceipts.contains(id)) continue; Provider.of(context, listen: false).sendReadReceipt(id); _sentReadReceipts.add(id); } } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; setState(() => _isKeyLoading = false); } } 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) { message.localFile!.delete().catchError((e) { debugPrint( "Ошибка удаления недействительного локального файла: $e", ); }); } } } print( "Получен размер файла из сети: $remoteSize байт. Обновляем модель сообщения.", ); setState(() { final index = _findMessageIndex(message); if (index != -1) { messages[index] = messages[index].copyWith( fileSize: remoteSize, fileName: filename, ); } }); // Опционально: сохраняем в локальную БД, чтобы не дергать сеть в следующий раз // await LocalDbService.updateMessageFileSize(message.id, remoteSize); } } catch (e) { debugPrint("Ошибка фонового получения размера файла: $e"); } } Future _ensureFileDecrypted( MessageModel message, { bool dontLoad = false, }) async { MessageModel msg = message; if (_activeDownloads.containsKey(msg.fileId)) return; if (msg.fileId == null || _chatSharedSecret == null) return; final directory = await getApplicationDocumentsDirectory(); final decFile = File('${directory.path}/dec_${msg.fileId}'); 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 байт). Файл поврежден. Удаляем.", ); await decFile.delete().catchError( (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 { await decFile.delete().catchError( (e) => debugPrint("Удаление пустого файла: $e"), ); } } // --- КОНЕЦ БЛОКА ВАЛИДАЦИИ --- if (dontLoad) return; debugPrint("=== ОТЛАДКА СКАЧИВАНИЯ ==="); debugPrint("ID файла: ${msg.fileId}"); debugPrint("Ключ файла (Base64): ${msg.encryptedFileKey}"); debugPrint("Общий ключ чата готов?: ${_chatSharedSecret != null}"); // Получаем или инициализируем Notifier прогресса для этого файла final bool createdNewNotifier = _messageProgressNotifiers[msg.fileId!] == null; _messageProgressNotifiers[msg.fileId!] ??= ValueNotifier(0.0); if (createdNewNotifier && mounted) { setState(() {}); } _messageProgressNotifiers[msg.fileId!]!.value = 0.0; // Если размера файла в модели всё ещё нет, запрашиваем его у сервера через HEAD-запрос if (msg.fileSize == null || msg.fileSize == 0) { debugPrint("Размер файла в модели пуст. Делаем HEAD запрос..."); final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName( msg.fileId!, ); if (remoteSize != null && remoteSize > 0) { debugPrint("Успешно получен размер файла из сети: $remoteSize байт"); if (mounted) { setState(() { final index = _findMessageIndex(msg); if (index != -1) { // Берем элемент ИЗ МАССИВА сообщений (там данные актуальнее) messages[index] = messages[index].copyWith( fileSize: remoteSize, fileName: filename, ); // Обновляем локальную переменную msg для дальнейшей корректной работы msg = messages[index]; } else { msg = msg.copyWith(fileSize: remoteSize, fileName: filename); } }); } } } final response = await apiService.downloadFileAsStream(msg.fileId!); final networkStream = response.$1; final fileName = response.$2; 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; } }, ); final iosink = decFile.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(); await decFile.delete(); }, onDone: () async { await iosink.close(); // --- СОХРАНЕНИЕ ТЕКУЩЕГО РАЗМЕРА ПОСЛЕ УСПЕШНОЙ ЗАГРУЗКИ --- final finalLocalLength = await decFile.length(); if (finalLocalLength > 0) { // Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл await sharedPrefs.setInt(sizeKey, finalLocalLength); debugPrint( "Файл успешно скачан и расшифрован. Сохранен валидный размер: $finalLocalLength байт.", ); } if (mounted) { setState(() { final updatedMsg = msg.copyWith( localFile: decFile, fileName: fileName, ); 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 decFile.exists()) { await decFile.delete().catchError( (e) => debugPrint("Ошибка очистки файла: $e"), ); } await sharedPrefs.remove(sizeKey); // Чистим ключ при ошибке } } Future _updateScrollButtonVisibility() async { if (!mounted) return; final shouldShow = _scrollController.hasClients && _scrollController.offset > 100; if (shouldShow != _showScrollToEnd) { setState(() { _showScrollToEnd = shouldShow; }); } } Future _scrollToBottom() async { if (!_scrollController.hasClients) return; await _scrollController.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } Future _scrollToMessage(int? messageId) async { if (messageId == null) return; final itemKey = _messageKeys[messageId]; if (itemKey?.currentContext == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Сообщение не найдено для перехода.'), behavior: SnackBarBehavior.floating, margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), ), ); return; } await Scrollable.ensureVisible( itemKey!.currentContext!, duration: const Duration(milliseconds: 300), alignment: 0.1, curve: Curves.easeInOut, ); } void _openFullScreenMedia(MessageModel msg) async { print('Открытие медиа'); if (msg.fileId == null) return; // Получаем доступ к папке документов приложения final directory = await getApplicationDocumentsDirectory(); final decPath = '${directory.path}/dec_${msg.fileId}'; final decFile = File(decPath); // 1. ПРОВЕРКА КЭША: если расшифрованный файл уже есть, открываем мгновенно if (await decFile.exists() && await decFile.length() > 0) { _navigateToViewer(decPath, msg); return; } // Проверяем наличие ключей перед началом долгого процесса if (_chatSharedSecret == null || msg.encryptedFileKey == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Ошибка: Ключи шифрования недоступны")), ); return; } // Показываем индикатор загрузки (диалог или крутилку) _showLoadingDialog(); try { _ensureFileDecrypted(msg); final decFile = File(decPath); if (await decFile.exists() && await decFile.length() > 0) { _navigateToViewer(decPath, msg); return; } } catch (e) { debugPrint('Ошибка при обработке и дешифрации файла: $e'); } // Если что-то пошло не так (ошибка сети, ошибка дешифрации) if (mounted) { // Закрываем _showLoadingDialog(), если он все еще открыт WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.of(context, rootNavigator: true).pop(); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Не удалось загрузить или расшифровать файл"), ), ); } } 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 _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), ); } } } 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); }); } } }