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