Chepuhagram/lib/presentation/screens/chat_screen.dart

3527 lines
126 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import '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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> with RouteAware {
static const String _notificationLaunchKey = 'notification_launch_data';
static const int _autoMediaLoadLimitBytes = 10 * 1024 * 1024; // 10MB
int myId = 0;
late Contact _currentContact;
bool _isKeyLoading = true;
final TextEditingController _controller = TextEditingController();
final FocusNode _inputFocusNode = FocusNode();
final ContactRepository _contactRepository = ContactRepository();
final apiService = ApiService();
final CryptoService _cryptoService = CryptoService();
List<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _messageKeys = {};
Map<int, MessageModel> _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<String, Future<Uint8List?>> _mediaLoadFutures = {};
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
// Состояния для аудио/видео записи
bool _isRecording = false;
bool _isRecordLocked = false; // Режим "замок" (свайп вверх)
bool _isVoiceMode = true; // true - голосовое, false - кружок
double _recordDragX = 0.0; // Для отслеживания свайпа влево (отмена)
double _recordDragY = 0.0; // Для отслеживания свайпа вверх (замок)
// Дополнительно для UI анимаций (опционально, сколько протащили для отмены)
static const double _swipeCancelThreshold =
-80.0; // Порог свайпа влево для отмены
static const double _swipeLockThreshold =
-80.0; // Порог свайпа вверх для лока
final AudioRecorder _audioRecorder = AudioRecorder();
Stopwatch _stopwatch = Stopwatch();
Timer? _stopwatchTimer;
String _stopwatchDisplay = "0:00";
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
_socketService = Provider.of<SocketService>(context, listen: false);
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
_loadLocalName();
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_loadHistory();
_loadOnlineStatus();
startOnlineUpdates();
_controller.addListener(_sendTypingStatus);
_scrollController.addListener(_updateScrollButtonVisibility);
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
_initCameras();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
print("Пользователь вернулся на этот экран!");
_loadLocalName();
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
}
Future<void> _loadLocalName() async {
final prefs = await SharedPreferences.getInstance();
final String? savedName = prefs.getString(
'firstname_${_currentContact.id}',
);
final String? savedSurname = prefs.getString(
'lastname_${_currentContact.id}',
);
print('Загружены имя $savedName, $savedSurname');
if (mounted) {
setState(() {
if (savedName != null) {
_currentContact.name = savedName;
}
if (savedSurname != null) {
_currentContact.surname = savedSurname;
}
});
}
}
// Инициализация камер устройства для кружочков
Future<void> _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<void> _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<void> _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<void> _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<SocketService>(context, listen: false);
socketService.sendMessage({
'type': 'typing',
'receiver_id': _currentContact.id,
});
}
}
void _sendStopTypingStatus() {
_socketService.sendMessage({
'type': 'stop_typing',
'receiver_id': _currentContact.id,
});
}
Future<void> _loadOnlineStatus() async {
if (currentActiveChatContactId == null) return;
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
try {
print(
"🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})",
);
final data = await apiService.getUserById(_currentContact.id);
if (!mounted) return;
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
print(
"✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}",
);
setState(() {
_isOnline = data['online'] ?? false;
if (data['last_online'] != null)
_lastOnline = DateTime.parse(data['last_online']).add(offset);
else
_lastOnline = null;
});
} catch (e) {
print(e);
}
}
void startOnlineUpdates() {
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_loadOnlineStatus();
});
}
Future<void> _loadContactKey() async {
if (!mounted) return;
setState(() => _isKeyLoading = true);
try {
final updatedContact = await _contactRepository.fetchContactById(
_currentContact.id,
);
if (!mounted) return;
setState(() {
_currentContact = updatedContact;
_isKeyLoading = false;
});
print(updatedContact.publicKey);
} catch (e) {
if (!mounted) return;
setState(() => _isKeyLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Не удалось получить ключ шифрования собеседника"),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 3),
),
);
}
}
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<ThemeProvider>();
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<int>(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<String>('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<void> _showMessageActions(MessageModel msg) async {
if (!mounted) return;
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Ответить'),
onTap: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop();
String text = msg.text;
if (msg.text.isEmpty &&
msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(() => _replyTo = msg.copyWith(text: text));
});
},
),
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<void> _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<void> _editMessage(MessageModel msg) async {
final controller = TextEditingController(text: msg.text);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Изменить сообщение'),
content: TextField(
controller: controller,
minLines: 1,
maxLines: 5,
autofocus: true,
decoration: const InputDecoration(hintText: 'Новый текст сообщения'),
),
actions: [
TextButton(
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop(false);
});
},
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(ctx).pop(true);
});
},
child: const Text('Сохранить'),
),
],
),
);
if (result != true || controller.text.trim().isEmpty) return;
final newText = controller.text.trim();
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) return;
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
_currentContact.publicKey!,
);
final encryptedContent = await _cryptoService.encryptMessage(
newText,
sharedSecret,
);
final content50 = newText.length > 50 ? newText.substring(0, 50) : newText;
final encryptedContent50 = await _cryptoService.encryptMessage(
content50,
sharedSecret,
);
setState(() {
messages = messages.map((m) {
if (m.id != null && m.id == msg.id) {
return m.copyWith(text: newText, editedAt: DateTime.now());
}
return m;
}).toList();
});
if (msg.id != null) {
try {
await _localDbService.updateMessageContent(
msg.id!,
encryptedContent,
DateTime.now(),
);
} catch (_) {}
Provider.of<SocketService>(context, listen: false).sendMessage({
'type': 'edit_message',
'message_id': msg.id,
'content': encryptedContent,
'content50': encryptedContent50,
});
}
}
Future<void> _deleteMessage(MessageModel msg) async {
setState(() {
messages.removeWhere(
(m) =>
(m.id != null && m.id == msg.id) ||
(m.tempId != null && m.tempId == msg.tempId),
);
});
final id = msg.id;
if (id != null) {
try {
await _localDbService.deleteMessage(id);
} catch (_) {}
Provider.of<SocketService>(
context,
listen: false,
).sendMessage({'type': 'delete_message', 'message_id': id});
}
}
Future<void> _showForwardContactPicker(MessageModel msg) async {
// Открываем новый красивый экран выбора вместо bottomSheet
final selectedContact = await Navigator.of(context).push<Contact?>(
MaterialPageRoute(
builder: (context) => ForwardContactPickerScreen(message: msg),
),
);
// Если контакт был выбран и нажата кнопка «Продолжить»
if (selectedContact != null && mounted) {
// Запускаем твою готовую и исправленную функцию пересылки медиа/текста
await _forwardMessage(msg, selectedContact);
}
}
Future<void> _forwardMessage(
MessageModel originalMsg,
Contact targetContact,
) async {
try {
final isSameChat = _currentContact.id == targetContact.id;
String? newFileId;
String? newEncryptedKey;
File? newLocalFile;
final tempId = DateTime.now().millisecondsSinceEpoch;
// 1. E2EE Защита: Если публичного ключа нет в объекте, пробуем запросить его у сервера
String? targetPublicKey = targetContact.publicKey;
if (originalMsg.fileId != null &&
(targetPublicKey == null || targetPublicKey.isEmpty)) {
debugPrint(
"==> [Forward] У контакта нет публичного ключа в кэше. Запрашиваем с сервера...",
);
try {
// Вызываем метод твоего API для получения свежих данных пользователя
final freshContact = await apiService.getUserByUsername(
targetContact.username,
);
if (freshContact != null && freshContact.publicKey != null) {
targetPublicKey = freshContact.publicKey;
targetContact.publicKey =
freshContact.publicKey; // Обновляем инстанс в памяти
}
} catch (e) {
debugPrint(
"==> [Forward] Не удалось запросить ключ получателя с сервера: $e",
);
}
}
// 2. Если есть медиа — обрабатываем на сервере и перешифровываем ключи
if (originalMsg.fileId != null) {
debugPrint(
"==> [Forward] Старт копирования медиа. fileId: ${originalMsg.fileId}",
);
final copiedFileId = await apiService.copyMediaOnServer(
originalMsg.fileId!,
targetContact.id,
);
if (copiedFileId == null) {
throw Exception("Сервер отказал в копировании файла");
}
newFileId = copiedFileId;
// Копируем локальный файл асинхронно, дожидаясь (await) завершения
if (originalMsg.localFile != null) {
final directory = await getApplicationDocumentsDirectory();
// Сохраняем строго под префиксом file_, который ожидает MessageBubble
final decFile = '${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<SocketService>(context, listen: false);
final isSent = socket.sendMessage(payload);
if (!isSent) throw Exception("Ошибка отправки через сокет");
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!isSameChat) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(contact: targetContact),
),
);
} else {
// Если переслали в текущий чат — обновляем статус
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: MessageStatus.sent,
fileId: newFileId ?? originalMsg.fileId,
localFile: newLocalFile,
);
}
});
}
});
} catch (e) {
debugPrint("[Error] Ошибка пересылки: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceAll("Exception: ", "")),
backgroundColor: Colors.redAccent,
),
);
}
}
Widget _buildMessageInput() {
final bool hasTextOrFile =
_controller.text.trim().isNotEmpty || _pendingFile != null;
final bool showSendButton = hasTextOrFile || _isRecordLocked;
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<String>(
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<void> _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<void> _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<AssetEntity>? result = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
maxAssets: 1,
pageSize: 33,
gridCount: 3,
pickerTheme: ThemeData(
brightness: Theme.of(context).brightness,
primaryColor: Theme.of(context).primaryColor,
colorScheme: Theme.of(context).colorScheme,
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBarTheme: AppBarTheme(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
),
specialItemBuilder: null,
),
);
if (result != null && result.isNotEmpty) {
final asset = result.first;
try {
Uint8List? bytes;
if (asset.type == AssetType.image) {
bytes = await asset.originBytes;
}
final File? file = await asset.file;
if (file == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось получить доступ к файлу медиа.'),
duration: Duration(seconds: 2),
),
);
return;
}
if (!mounted) return;
setState(() {
if (asset.type == AssetType.image && bytes != null) {
_previewBytes = bytes;
}
_pendingFile = file;
_pendingFileName =
asset.title ?? 'media_${DateTime.now().millisecondsSinceEpoch}';
_pendingMessageType = asset.type == AssetType.video
? MessageType.video
: MessageType.image;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе медиа: $e'),
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _pickFile() async {
FilePickerResult? result = await FilePicker.pickFiles(type: FileType.any);
if (result != null && result.files.isNotEmpty) {
try {
final file = File(result.files.single.path!);
if (!mounted) return;
setState(() {
_pendingFile = file;
_pendingFileName = result.files.single.name;
_pendingMessageType = MessageType.file;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выборе файла: $e'),
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<File?> _compressAndCropVideoNoteSafe(File originalVideoFile) async {
try {
if (!await originalVideoFile.exists()) {
debugPrint('==> FFmpeg: Исходный файл не найден на диске.');
return null;
}
final String targetOriginalPath = originalVideoFile.path;
debugPrint(
'==> Исходный файл: $targetOriginalPath, размер: ${await originalVideoFile.length()} байт',
);
// 1. Мгновенно переименовываем оригинальный файл во временный входной файл
final String tempInputPath = '${targetOriginalPath}_temp_input.mp4';
final File movedOriginalFile = await originalVideoFile.rename(
tempInputPath,
);
// 2. Строим команду в виде списка аргументов (List<String>).
// Больше никаких ручных кавычек вокруг путей типа "$tempInputPath" — плагин сам всё экранирует!
final List<String> ffmpegArgs = [
'-i',
tempInputPath,
'-vf',
'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512',
'-vcodec',
'libx264',
'-crf',
'28',
'-preset',
'fast',
'-y',
targetOriginalPath,
];
debugPrint(
'==> FFmpeg: Запуск потоковой обработки через массив аргументов...',
);
// 3. Вызываем 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<void> _sendMessage() async {
_sendStopTypingStatus();
String rawText = _controller.text.trim();
File? file = _pendingFile;
final MessageType messageType = _pendingMessageType;
final hasMedia = _pendingFile != null;
final replyTo = _replyTo;
if (messageType == MessageType.videoNote ||
messageType == MessageType.voiceNote) {
rawText =
""; // Для видеозаметок и голосовых сообщений текст не обязателен, игнорируем его
}
// Если и текст пустой, и медиа нет — выходим
if (rawText.isEmpty && !hasMedia) return;
_scrollToBottom();
// Блокируем UI на время загрузки
_controller.clear();
_pendingFile = null;
_pendingMessageType = MessageType.text; // Сбрасываем тип медиа
_previewBytes = null; // Очищаем превью
_pendingFileName = null;
_replyTo = null;
final tempId = DateTime.now().millisecondsSinceEpoch;
try {
print(
"Исходный файл: ${file?.path}, размер: ${await file?.length()} байт",
);
if (messageType == MessageType.videoNote && file != null) {
file = await _compressAndCropVideoNoteSafe(file);
print(
"После обработки видеозаметки: ${file?.path}, размер: ${await file?.length()} байт",
);
}
int? fileSize = await file?.length();
// создаем первичную модель отобрадения
MessageModel tempMsg = MessageModel(
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
isMe: true,
text: rawText,
tempId: tempId,
messageType: messageType,
localFile: file,
status: MessageStatus.encrypting,
fileSize: fileSize,
replyToId: replyTo?.id,
replyToText: replyTo?.text,
fileId: tempId.toString(),
fileName: file != null ? p.basename(file.path) : "file",
);
setState(() => messages.add(tempMsg));
// 1. Подготовка ключей
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
_currentContact.publicKey!,
);
String? fileId;
String? encryptedFileKey;
String encryptedContent;
String encryptedContent50;
String? encryptedReplyToText;
_messageProgressNotifiers['${tempMsg.fileId}'] ??= ValueNotifier<double?>(
0.0,
);
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
// 2. Если есть медиа — сначала загружаем его
if (hasMedia && file != null && fileSize != null) {
final fileStream = file.openRead();
final encryptedStream = await _cryptoService.encryptFileStream(
fileStream,
sharedSecret,
totalSize: fileSize,
onProgress: (received, total) {
print(received);
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
}
},
);
final fileKeyForServer = encryptedStream.$2;
final tempDir = await getTemporaryDirectory();
final encFile = File('${tempDir.path}/enc_${tempId}.tmp');
final ios = encFile.openWrite();
await ios.addStream(encryptedStream.$1);
await ios.close();
final int exactEncryptedSize = await encFile.length();
setState(() {
tempMsg = tempMsg.copyWith(status: MessageStatus.sending);
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = tempMsg;
}
fileSize = exactEncryptedSize;
});
_messageProgressNotifiers['${tempMsg.fileId}'] ??=
ValueNotifier<double?>(0.0);
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
fileId = await apiService.uploadFileStream(
encFile.openRead(),
exactEncryptedSize,
purpose: messageType.name,
fileName: p.basename(file.path),
onProgress: (received, total) {
print(received);
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
}
},
);
if (await encFile.exists()) {
await encFile.delete();
}
if (fileId == null) {
throw Exception("Ошибка загрузки файла на сервер");
}
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<SocketService>(
context,
listen: false,
).sendMessage(payload);
// Обновляем статус
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
}
_replyTo = null;
});
} catch (e) {
try {
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages.removeAt(idx);
}
_replyTo = null;
});
} catch (e) {
print(e);
}
print(e);
// В случае ошибки возвращаем текст и медиа в контроллер
_controller.text = rawText;
_pendingFile = file;
_pendingMessageType = messageType;
_replyTo = replyTo;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Ошибка отправки: $e"),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 5),
),
);
}
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
print('Meesage from websocket: $data');
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (data['type'] == '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<ContactProvider>();
if (messages.isNotEmpty && messages.last.id == messageId) {
await contactProvider.updateContactLastMessage(
widget.contact.id,
lastMessage: decryptedText,
lastMessageTime: ts,
isLastMsgDecrypted: true,
lastMessageId: messageId,
isEdited: true,
);
}
return;
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId == null) return;
if (!mounted) return;
setState(() {
messages.removeWhere((m) => m.id != null && m.id == messageId);
});
try {
await _localDbService.deleteMessage(messageId);
} catch (_) {}
// Обновить последнее сообщение в списке контактов
final contactProvider = context.read<ContactProvider>();
if (messages.isEmpty) {
// Если не осталось сообщений, очистить последнее сообщение
await contactProvider.updateContactLastMessage(
widget.contact.id,
lastMessage: null,
lastMessageTime: null,
lastMessageId: null,
);
} else {
// Обновить на предпоследнее сообщение
await contactProvider.refreshContactLastMessage(widget.contact.id);
}
return;
}
if (data['type'] == 'message_read') {
final messageId = int.tryParse(data['message_id'].toString());
if (messageId == null) return;
var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (!mounted) return;
setState(() {
_updateMessageInList(
messageId,
(m) => m.copyWith(status: MessageStatus.read),
);
});
if (ts != null) {
try {
await _localDbService.updateReadAt(messageId, ts);
} catch (_) {}
}
return;
}
if (data['type'] == 'private_message') {
print('DEBUG incoming private_message raw: $data');
setState(() {
_typingTimer?.cancel();
_isTyping = false;
});
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse(
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
);
if (senderId == null || receiverId == null) {
print(
'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}',
);
return;
}
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
final isFromPartnerToMe =
senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try {
final myPrivKey = await _cryptoService.getPrivateKey();
// 2. Вычисляем общий секрет для расшифровки
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
// 3. Расшифровываем контент
final decryptedText = await _cryptoService.decryptMessage(
data['content'],
sharedSecret,
);
// 4. Добавляем в список и обновляем экран
String? encryptedFileKey = data['encrypted_key']?.toString();
Uint8List? decryptedImageBytes;
// Lazy load images later
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null &&
!_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(
context,
listen: false,
).sendReadReceipt(serverMessageId);
_sentReadReceipts.add(serverMessageId);
}
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
);
setState(() {
messages.add(
MessageModel(
id: int.tryParse(data['id']?.toString() ?? ''),
text: decryptedText,
isMe: false,
senderId: senderId,
receiverId: myId,
createdAt: DateTime.parse(data['timestamp']).add(offset),
status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null
? null
: int.tryParse(data['reply_to_id'].toString()),
replyToText: replyToText,
messageType: _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<void> _loadHistory() async {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
try {
print('[DEBUG] Начало загрузки истории');
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
print('[DEBUG] Ключи получены');
final cached = await _localDbService.getChatHistory(
widget.contact.id,
myId,
);
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
_chatSharedSecret = sharedSecret;
// Сюда будем складывать успешно расшифрованные локальные сообщения
// Используем Map<int, MessageModel>, где ключ — id сообщения, для мгновенного поиска
Map<int, MessageModel> 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 = <int>{};
List<MessageModel> 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<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;
setState(() => _isKeyLoading = false);
}
}
final Map<String, StreamSubscription<List<int>>> _activeDownloads = {};
Future<void> _stopFileLoading(MessageModel message) async {
if (message.fileId == null) return;
final subscription = _activeDownloads.remove(message.fileId!);
if (subscription != null) {
await subscription.cancel();
debugPrint("Загрузка файла ${message.fileId} отменена пользователем.");
if (message.localFile != null && message.localFile!.existsSync()) {
try {
await message.localFile!.delete();
debugPrint(
"Локальный файл успешно удален с диска: ${message.fileId}",
);
} catch (e) {
debugPrint("Ошибка при физическом удалении файла с диска: $e");
}
}
}
if (mounted) {
setState(() {
_messageProgressNotifiers[message.fileId!]?.value = null;
});
}
}
int _findMessageIndex(MessageModel message) {
return messages.indexWhere((m) {
if (message.id != null && m.id == message.id) return true;
if (message.tempId != null && m.tempId == message.tempId) return true;
if (message.fileId != null && m.fileId == message.fileId) return true;
return false;
});
}
Future<void> _fetchFileSizeIfNeeded(MessageModel message) async {
if (message.fileId == null) return;
try {
debugPrint("Фоновый запрос размера для файла: ${message.fileId}");
final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName(
message.fileId!,
);
if (remoteSize != null && remoteSize > 0 && mounted) {
if (message.fileSize != null && message.fileSize! > 0) {
if (message.fileSize != remoteSize) {
debugPrint(
"Размер файла на сервере (${remoteSize} байт) отличается от локального (${message.fileSize} байт). Локальный файл признан недействительным.",
);
if (message.localFile != null) {
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<void> _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<double?>(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<List<int>> decryptedStream = _cryptoService.decryptFileStream(
networkStream,
_chatSharedSecret!,
msg.encryptedFileKey!,
totalBytes: msg.fileSize,
onProgress: (received, total) {
if (total != -1) {
double progress = received / total;
if (progress > 1.0) progress = 1.0;
_messageProgressNotifiers[msg.fileId!]?.value = progress;
}
},
);
final iosink = decFile.openWrite();
final Completer<void> downloadCompleter = Completer<void>();
final subscription = decryptedStream.listen(
(chunk) {
iosink.add(chunk);
},
onError: (error) async {
debugPrint("Ошибка внутри криптострима: $error");
await iosink.close();
if (!downloadCompleter.isCompleted) downloadCompleter.complete();
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<void> _updateScrollButtonVisibility() async {
if (!mounted) return;
final shouldShow =
_scrollController.hasClients && _scrollController.offset > 100;
if (shouldShow != _showScrollToEnd) {
setState(() {
_showScrollToEnd = shouldShow;
});
}
}
Future<void> _scrollToBottom() async {
if (!_scrollController.hasClients) return;
await _scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
Future<void> _scrollToMessage(int? messageId) async {
if (messageId == null) return;
final itemKey = _messageKeys[messageId];
if (itemKey?.currentContext == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Сообщение не найдено для перехода.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
await Scrollable.ensureVisible(
itemKey!.currentContext!,
duration: const Duration(milliseconds: 300),
alignment: 0.1,
curve: Curves.easeInOut,
);
}
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<void> _saveMediaToGallery(MessageModel msg) async {
final originalFile = msg.localFile;
if (originalFile == null || !originalFile.existsSync()) return;
File? tempFile;
try {
// 1. Проверка доступности файла
// Пытаемся открыть файл на чтение. Если файл занят, это выбросит ошибку,
// и мы подождем перед тем, как копировать.
bool isFileReady = false;
int attempts = 0;
while (!isFileReady && attempts < 5) {
try {
final raf = await originalFile.open(mode: FileMode.read);
await raf.close();
isFileReady = true;
} catch (e) {
await Future.delayed(const Duration(milliseconds: 500));
attempts++;
}
}
// 2. Копирование с правильным именем
String ext = p.extension(msg.fileName ?? '');
if (ext.isEmpty)
ext = (msg.messageType == MessageType.video) ? '.mp4' : '.jpg';
final String tempName =
'save_${DateTime.now().millisecondsSinceEpoch}$ext';
final Directory tempDir = await getTemporaryDirectory();
tempFile = await originalFile.copy(p.join(tempDir.path, tempName));
// 3. Сохранение
if (msg.messageType == MessageType.video ||
msg.messageType == MessageType.videoNote) {
await Gal.putVideo(tempFile.path);
} else {
await Gal.putImage(tempFile.path);
}
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Сохранено в галерею')));
} catch (e) {
debugPrint("Ошибка: $e");
// Если ошибка "битый файл", возможно, Gal еще держит временный файл
} finally {
// ВАЖНО: Удаляем только если файл существует
if (tempFile != null && await tempFile.exists()) {
try {
await tempFile.delete();
} catch (_) {}
}
}
}
Future<Uint8List?> _downloadAndDecryptImage(
String fileId,
String encryptedFileKey,
SecretKey sharedSecret, {
void Function(int received, int total)? onProgress,
}) async {
try {
print('DEBUG downloadMedia(fileId=$fileId)');
final bytes = await apiService.downloadMedia(
fileId,
onProgress: onProgress,
);
if (bytes == null) {
print('DEBUG downloadMedia returned null for fileId=$fileId');
return null;
}
print(
'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId',
);
final result = await _cryptoService.decryptMedia(
bytes,
encryptedFileKey,
sharedSecret,
);
print(
'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId',
);
return result;
} catch (e) {
print('Ошибка загрузки и дешифровки медиа: $e');
return null;
}
}
Future<String?> _decryptReplyText(
String? encryptedReplyText,
SecretKey sharedSecret,
) async {
if (encryptedReplyText == null) return null;
try {
return await _cryptoService.decryptMessage(
encryptedReplyText,
sharedSecret,
);
} catch (_) {
return encryptedReplyText;
}
}
Widget _buildPreviewIcon() {
switch (_pendingMessageType) {
case MessageType.image:
return _previewBytes != null
? Image.memory(_previewBytes!, fit: BoxFit.cover)
: Container(
color: Colors.grey.withOpacity(0.2),
child: const Icon(Icons.image, color: Colors.grey),
);
case MessageType.video:
return Container(
color: Colors.black,
child: const Icon(Icons.videocam, color: Colors.white),
);
case MessageType.file:
default:
return Container(
color: Colors.blue.withOpacity(0.1),
child: const Icon(Icons.insert_drive_file, color: Colors.blue),
);
}
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..repeat(reverse: true); // Анимация идет туда-сюда
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildDot(int index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// Рассчитываем смещение: только отрицательные значения (вверх)
double delay = index * 0.5; // Увеличили задержку для плавности
double shift = sin((_controller.value * 2 * pi) + delay);
// Используем clamp или abs, чтобы точка не уходила ниже базовой линии
double yOffset = (shift < 0 ? shift : 0) * 4;
return SizedBox(
width: 4, // Фиксированная зона для одной точки
height: 5, // Фиксированная высота зоны анимации
child: Align(
alignment: Alignment.bottomCenter, // Точка всегда прижата к низу
child: Container(
width: 2,
height: 2,
decoration: const BoxDecoration(
color: Colors.greenAccent,
shape: BoxShape.circle,
),
transform: Matrix4.translationValues(0, yOffset, 0),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 12,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (index) => _buildDot(index)),
),
);
}
}
typedef _OnWidgetSizeChange = void Function(Size size);
class _MeasureSize extends SingleChildRenderObjectWidget {
final _OnWidgetSizeChange onChange;
const _MeasureSize({super.key, required super.child, required this.onChange});
@override
RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange: onChange);
}
@override
void updateRenderObject(
BuildContext context,
_MeasureSizeRenderObject renderObject,
) {
renderObject.onChange = onChange;
}
}
class _MeasureSizeRenderObject extends RenderProxyBox {
_OnWidgetSizeChange onChange;
Size? _oldSize;
_MeasureSizeRenderObject({required this.onChange, RenderBox? child})
: super(child);
@override
void performLayout() {
super.performLayout();
final newSize = size;
if (_oldSize != newSize) {
_oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
}