3527 lines
126 KiB
Dart
3527 lines
126 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
import 'dart:ui';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:cryptography/cryptography.dart';
|
||
import '/data/models/message_model.dart';
|
||
import '/data/models/contact_model.dart';
|
||
import 'package:chepuhagram/presentation/widgets/message_bubble.dart';
|
||
import 'package:gal/gal.dart';
|
||
import 'package:chepuhagram/data/repositories/contact_repository.dart';
|
||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import '/logic/contact_provider.dart';
|
||
import '../../domain/services/api_service.dart';
|
||
import 'dart:math';
|
||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||
import 'package:chepuhagram/main.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'contacts_screen.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'user_profile_screen.dart';
|
||
import '/core/theme_manager.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'camera_screen.dart';
|
||
import 'media_viewer_screen.dart';
|
||
import 'package:visibility_detector/visibility_detector.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import 'package:record/record.dart';
|
||
import 'package:camera/camera.dart';
|
||
import 'package:ffmpeg_kit_flutter_new_min_gpl/ffmpeg_kit.dart';
|
||
import 'package:ffmpeg_kit_flutter_new_min_gpl/return_code.dart';
|
||
import '../screens/forward_contact_picker_screen.dart';
|
||
|
||
class ChatScreen extends StatefulWidget {
|
||
final Contact contact;
|
||
|
||
const ChatScreen({super.key, required this.contact});
|
||
|
||
@override
|
||
State<ChatScreen> createState() => _ChatScreenState();
|
||
}
|
||
|
||
class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||
static const String _notificationLaunchKey = 'notification_launch_data';
|
||
static const int _autoMediaLoadLimitBytes = 10 * 1024 * 1024; // 10MB
|
||
int myId = 0;
|
||
late Contact _currentContact;
|
||
bool _isKeyLoading = true;
|
||
final TextEditingController _controller = TextEditingController();
|
||
final FocusNode _inputFocusNode = FocusNode();
|
||
final ContactRepository _contactRepository = ContactRepository();
|
||
final apiService = ApiService();
|
||
final CryptoService _cryptoService = CryptoService();
|
||
List<MessageModel> messages = [];
|
||
StreamSubscription<dynamic>? _socketSubscription;
|
||
final Set<int> _sentReadReceipts = <int>{};
|
||
final LocalDbService _localDbService = LocalDbService();
|
||
final ScrollController _scrollController = ScrollController();
|
||
final Map<int, GlobalKey> _messageKeys = {};
|
||
Map<int, MessageModel> _messageMap = {};
|
||
bool _showScrollToEnd = false;
|
||
MessageModel? _replyTo;
|
||
bool _isOnline = false;
|
||
DateTime? _lastOnline;
|
||
Timer? _onlineTimer;
|
||
DateTime? _lastTypingSent;
|
||
bool _isTyping = false;
|
||
Timer? _typingTimer;
|
||
late SocketService _socketService;
|
||
MessageType _pendingMessageType = MessageType.text;
|
||
String? _pendingFileName;
|
||
File? _pendingFile;
|
||
Uint8List? _previewBytes;
|
||
double _inputBarHeight = 0;
|
||
|
||
SecretKey? _chatSharedSecret;
|
||
final Map<String, Future<Uint8List?>> _mediaLoadFutures = {};
|
||
final Map<String, ValueNotifier<double?>> _messageProgressNotifiers = {};
|
||
|
||
// Состояния для аудио/видео записи
|
||
bool _isRecording = false;
|
||
bool _isRecordLocked = false; // Режим "замок" (свайп вверх)
|
||
bool _isVoiceMode = true; // true - голосовое, false - кружок
|
||
double _recordDragX = 0.0; // Для отслеживания свайпа влево (отмена)
|
||
double _recordDragY = 0.0; // Для отслеживания свайпа вверх (замок)
|
||
|
||
// Дополнительно для UI анимаций (опционально, сколько протащили для отмены)
|
||
static const double _swipeCancelThreshold =
|
||
-80.0; // Порог свайпа влево для отмены
|
||
static const double _swipeLockThreshold =
|
||
-80.0; // Порог свайпа вверх для лока
|
||
final AudioRecorder _audioRecorder = AudioRecorder();
|
||
|
||
Stopwatch _stopwatch = Stopwatch();
|
||
Timer? _stopwatchTimer;
|
||
String _stopwatchDisplay = "0:00";
|
||
|
||
CameraController? _cameraController;
|
||
List<CameraDescription>? _cameras;
|
||
bool _isCameraInitialized = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_currentContact = widget.contact;
|
||
_socketService = Provider.of<SocketService>(context, listen: false);
|
||
currentActiveChatContactId =
|
||
_currentContact.id; // Устанавливаем активный чат
|
||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||
final contactProvider = context.read<ContactProvider>();
|
||
myId = contactProvider.getCurrentUserId() ?? 0;
|
||
// Если ключа нет, загружаем его при входе
|
||
_loadLocalName();
|
||
if (_currentContact.publicKey == null) {
|
||
_loadContactKey();
|
||
}
|
||
_loadHistory();
|
||
_loadOnlineStatus();
|
||
startOnlineUpdates();
|
||
_controller.addListener(_sendTypingStatus);
|
||
|
||
_scrollController.addListener(_updateScrollButtonVisibility);
|
||
|
||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||
|
||
_initCameras();
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
|
||
}
|
||
|
||
@override
|
||
void didPopNext() {
|
||
print("Пользователь вернулся на этот экран!");
|
||
_loadLocalName();
|
||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||
}
|
||
|
||
Future<void> _loadLocalName() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
|
||
final String? savedName = prefs.getString(
|
||
'firstname_${_currentContact.id}',
|
||
);
|
||
final String? savedSurname = prefs.getString(
|
||
'lastname_${_currentContact.id}',
|
||
);
|
||
print('Загружены имя $savedName, $savedSurname');
|
||
if (mounted) {
|
||
setState(() {
|
||
if (savedName != null) {
|
||
_currentContact.name = savedName;
|
||
}
|
||
if (savedSurname != null) {
|
||
_currentContact.surname = savedSurname;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Инициализация камер устройства для кружочков
|
||
Future<void> _initCameras() async {
|
||
try {
|
||
_cameras = await availableCameras();
|
||
if (!mounted) return;
|
||
if (_cameras != null && _cameras!.isNotEmpty) {
|
||
// Пытаемся найти фронтальную камеру по умолчанию для кружков
|
||
final frontCamera = _cameras!.firstWhere(
|
||
(camera) => camera.lensDirection == CameraLensDirection.front,
|
||
orElse: () => _cameras!.first,
|
||
);
|
||
_cameraController = CameraController(
|
||
frontCamera,
|
||
ResolutionPreset.medium,
|
||
enableAudio: true, // Звук пишется в видео-файл
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка инициализации камер: $e");
|
||
}
|
||
}
|
||
|
||
// Секундомер
|
||
void _startStopwatch() {
|
||
_stopwatch.reset();
|
||
_stopwatch.start();
|
||
_stopwatchTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||
timer,
|
||
) {
|
||
if (_stopwatch.isRunning) {
|
||
setState(() {
|
||
final elapsed = _stopwatch.elapsed;
|
||
String minutes = (elapsed.inMinutes % 60).toString();
|
||
String seconds = (elapsed.inSeconds % 60).toString().padLeft(2, '0');
|
||
_stopwatchDisplay = "$minutes:$seconds";
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
void _stopStopwatch() {
|
||
_stopwatch.stop();
|
||
_stopwatchTimer?.cancel();
|
||
setState(() {
|
||
_stopwatchDisplay = "0:00";
|
||
});
|
||
}
|
||
|
||
// СТАРТ ЗАПИСИ
|
||
Future<void> _startRecording() async {
|
||
HapticFeedback.lightImpact();
|
||
_startStopwatch();
|
||
|
||
setState(() {
|
||
_isRecording = true;
|
||
_isRecordLocked = false;
|
||
_recordDragX = 0.0;
|
||
_recordDragY = 0.0;
|
||
});
|
||
|
||
try {
|
||
if (_isVoiceMode) {
|
||
// Проверка разрешений микрофона встроенная в record
|
||
if (await _audioRecorder.hasPermission()) {
|
||
final directory = await getTemporaryDirectory();
|
||
final path =
|
||
'${directory.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||
await _audioRecorder.start(
|
||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||
path: path,
|
||
);
|
||
}
|
||
} else {
|
||
// Режим кружочка (видео)
|
||
if (_cameraController != null) {
|
||
await _cameraController!.initialize();
|
||
if (mounted) {
|
||
setState(() {
|
||
_isCameraInitialized = true;
|
||
});
|
||
await _cameraController!.startVideoRecording();
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка старта записи: $e");
|
||
}
|
||
}
|
||
|
||
// ФИКСАЦИЯ НА ЗАМОК
|
||
void _lockRecording() {
|
||
HapticFeedback.mediumImpact();
|
||
setState(() {
|
||
_isRecordLocked = true;
|
||
});
|
||
}
|
||
|
||
// ОТМЕНА ЗАПИСИ
|
||
Future<void> _cancelRecording() async {
|
||
HapticFeedback.heavyImpact();
|
||
_stopStopwatch();
|
||
|
||
setState(() {
|
||
_isRecording = false;
|
||
_isRecordLocked = false;
|
||
_isCameraInitialized = false;
|
||
});
|
||
|
||
try {
|
||
if (_isVoiceMode) {
|
||
await _audioRecorder.stop(); // Просто останавливаем без сохранения
|
||
} else {
|
||
if (_cameraController != null &&
|
||
_cameraController!.value.isRecordingVideo) {
|
||
await _cameraController!.stopVideoRecording();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка при отмене записи: $e");
|
||
}
|
||
}
|
||
|
||
// УСПЕШНОЕ ЗАВЕРШЕНИЕ И ОТПРАВКА
|
||
Future<void> _stopAndSendRecording() async {
|
||
if (!_isRecording) return;
|
||
_stopStopwatch();
|
||
|
||
String? filePath;
|
||
|
||
try {
|
||
if (_isVoiceMode) {
|
||
filePath = await _audioRecorder.stop();
|
||
} else {
|
||
if (_cameraController != null &&
|
||
_cameraController!.value.isRecordingVideo) {
|
||
XFile videoFile = await _cameraController!.stopVideoRecording();
|
||
filePath = videoFile.path;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка при остановке записи: $e");
|
||
}
|
||
|
||
setState(() {
|
||
_isRecording = false;
|
||
_isRecordLocked = false;
|
||
_isCameraInitialized = false;
|
||
});
|
||
|
||
if (filePath != null) {
|
||
File fileToSend = File(filePath);
|
||
setState(() {
|
||
_pendingFile = fileToSend;
|
||
_pendingFileName = _isVoiceMode
|
||
? "Голосовое сообщение.m4a"
|
||
: "Видеосообщение.mp4";
|
||
_pendingMessageType = _isVoiceMode
|
||
? MessageType.voiceNote
|
||
: MessageType.videoNote;
|
||
});
|
||
|
||
// Вызываем твой существующий метод отправки, который упакует файл в чат
|
||
_sendMessage();
|
||
}
|
||
}
|
||
|
||
void _toggleRecordMode() {
|
||
if (_isRecording) return;
|
||
setState(() {
|
||
_isVoiceMode = !_isVoiceMode;
|
||
});
|
||
}
|
||
|
||
void _updateMessageInList(
|
||
int messageId,
|
||
MessageModel Function(MessageModel) updater,
|
||
) {
|
||
if (!_messageMap.containsKey(messageId)) return;
|
||
|
||
final oldMessage = _messageMap[messageId]!;
|
||
final newMessage = updater(oldMessage);
|
||
|
||
setState(() {
|
||
_messageMap[messageId] = newMessage;
|
||
final idx = messages.indexWhere((m) => m.id == messageId);
|
||
if (idx != -1) messages[idx] = newMessage;
|
||
});
|
||
}
|
||
|
||
void _sendTypingStatus() {
|
||
final now = DateTime.now();
|
||
if (_lastTypingSent == null ||
|
||
now.difference(_lastTypingSent!) > const Duration(seconds: 3)) {
|
||
_lastTypingSent = now;
|
||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||
socketService.sendMessage({
|
||
'type': 'typing',
|
||
'receiver_id': _currentContact.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
void _sendStopTypingStatus() {
|
||
_socketService.sendMessage({
|
||
'type': 'stop_typing',
|
||
'receiver_id': _currentContact.id,
|
||
});
|
||
}
|
||
|
||
Future<void> _loadOnlineStatus() async {
|
||
if (currentActiveChatContactId == null) return;
|
||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||
try {
|
||
print(
|
||
"🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})",
|
||
);
|
||
final data = await apiService.getUserById(_currentContact.id);
|
||
if (!mounted) return;
|
||
DateTime now = DateTime.now();
|
||
|
||
Duration offset = now.timeZoneOffset;
|
||
print(
|
||
"✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}",
|
||
);
|
||
|
||
setState(() {
|
||
_isOnline = data['online'] ?? false;
|
||
if (data['last_online'] != null)
|
||
_lastOnline = DateTime.parse(data['last_online']).add(offset);
|
||
else
|
||
_lastOnline = null;
|
||
});
|
||
} catch (e) {
|
||
print(e);
|
||
}
|
||
}
|
||
|
||
void startOnlineUpdates() {
|
||
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||
_loadOnlineStatus();
|
||
});
|
||
}
|
||
|
||
Future<void> _loadContactKey() async {
|
||
if (!mounted) return;
|
||
setState(() => _isKeyLoading = true);
|
||
try {
|
||
final updatedContact = await _contactRepository.fetchContactById(
|
||
_currentContact.id,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_currentContact = updatedContact;
|
||
_isKeyLoading = false;
|
||
});
|
||
print(updatedContact.publicKey);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isKeyLoading = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("Не удалось получить ключ шифрования собеседника"),
|
||
behavior: SnackBarBehavior.floating, // Обязательно для margin
|
||
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
|
||
duration: Duration(seconds: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _getMediaPreview(MessageType type) {
|
||
switch (type) {
|
||
case MessageType.videoNote:
|
||
return '[Кружок]';
|
||
case MessageType.voiceNote:
|
||
return '[Голосовое]';
|
||
case MessageType.image:
|
||
return '[Фото]';
|
||
case MessageType.video:
|
||
return '[Видео]';
|
||
case MessageType.file:
|
||
return '[Файл]';
|
||
case MessageType.text:
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
MessageType _parseMessageTypeString(String? typeStr) {
|
||
switch (typeStr?.toLowerCase()) {
|
||
case 'voicenote':
|
||
return MessageType.voiceNote;
|
||
case 'videonote':
|
||
return MessageType.videoNote;
|
||
case 'image':
|
||
return MessageType.image;
|
||
case 'video':
|
||
return MessageType.video;
|
||
case 'file':
|
||
return MessageType.file;
|
||
case 'text':
|
||
default:
|
||
return MessageType.text;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
currentActiveChatContactId = null;
|
||
_socketSubscription?.cancel();
|
||
_scrollController.removeListener(_updateScrollButtonVisibility);
|
||
_scrollController.dispose();
|
||
_controller.dispose();
|
||
for (final n in _messageProgressNotifiers.values) {
|
||
n.dispose();
|
||
}
|
||
routeObserver.unsubscribe(this);
|
||
_inputFocusNode.dispose();
|
||
_onlineTimer?.cancel();
|
||
_typingTimer?.cancel();
|
||
_controller.removeListener(_sendTypingStatus);
|
||
_sendStopTypingStatus();
|
||
_audioRecorder.dispose();
|
||
_cameraController?.dispose();
|
||
_stopwatchTimer?.cancel();
|
||
_stopwatch.stop();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final themeProv = context.watch<ThemeProvider>();
|
||
return Scaffold(
|
||
resizeToAvoidBottomInset: true,
|
||
appBar: AppBar(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () {
|
||
if (Navigator.canPop(context)) {
|
||
Navigator.pop(context);
|
||
} else {
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
title: GestureDetector(
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => UserProfileScreen(
|
||
userId: _currentContact.id,
|
||
username: _currentContact.username,
|
||
name: _currentContact.name,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}',
|
||
),
|
||
if (_isKeyLoading == true)
|
||
const Text(
|
||
'загрузка...',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Color.fromARGB(255, 219, 219, 219),
|
||
),
|
||
)
|
||
else if (_isTyping)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
const Text(
|
||
'печатает',
|
||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||
),
|
||
const SizedBox(width: 4),
|
||
TypingIndicator(),
|
||
],
|
||
)
|
||
else if (_isOnline)
|
||
const Text(
|
||
'онлайн',
|
||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||
)
|
||
else if (_lastOnline != null)
|
||
Text(
|
||
'был(а) в сети ${_formatLastOnline(_lastOnline!)}',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Color.fromARGB(255, 219, 219, 219),
|
||
),
|
||
)
|
||
else
|
||
const Text(
|
||
'был(а) недавно',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Color.fromARGB(255, 219, 219, 219),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
body: Container(
|
||
decoration: themeProv.wallpaperPath != null
|
||
? BoxDecoration(
|
||
image: DecorationImage(
|
||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||
fit: BoxFit.cover,
|
||
),
|
||
)
|
||
: null,
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: CustomScrollView(
|
||
controller: _scrollController,
|
||
reverse: true,
|
||
cacheExtent:
|
||
0, // Сохраняем: строго запрещает предзагрузку элементов вне экрана
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
slivers: [
|
||
SliverPadding(
|
||
padding: EdgeInsets.only(
|
||
bottom:
|
||
_inputBarHeight *
|
||
(MediaQuery.of(context).viewInsets.bottom > 0
|
||
? 1.0
|
||
: 1.0) +
|
||
28,
|
||
left: 8,
|
||
right: 8,
|
||
top: 8,
|
||
),
|
||
sliver: SliverList(
|
||
delegate: SliverChildBuilderDelegate((context, index) {
|
||
final msg = messages[messages.length - 1 - index];
|
||
final keyId = msg.id ?? msg.tempId ?? index;
|
||
final itemKey = _messageKeys.putIfAbsent(
|
||
keyId,
|
||
() => GlobalKey(),
|
||
);
|
||
final isMedia =
|
||
msg.messageType == MessageType.image ||
|
||
msg.messageType == MessageType.video ||
|
||
msg.messageType == MessageType.file;
|
||
|
||
final showDateDivider = _isNewDay(index);
|
||
|
||
// Формируем основное содержимое элемента сообщения
|
||
Widget itemChild = Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
key: ValueKey<int>(keyId.hashCode),
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (showDateDivider)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 10,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHighest
|
||
.withOpacity(0.75),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
_formatDividerDate(msg.createdAt),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Dismissible(
|
||
direction: DismissDirection.endToStart,
|
||
key: ValueKey<String>('dismiss_$keyId'),
|
||
confirmDismiss:
|
||
(DismissDirection direction) async {
|
||
String text = msg.text;
|
||
if (msg.text.isEmpty &&
|
||
msg.messageType == MessageType.image) {
|
||
text = "[Фото]";
|
||
}
|
||
setState(
|
||
() => _replyTo = msg.copyWith(text: text),
|
||
);
|
||
return false;
|
||
},
|
||
child: RepaintBoundary(
|
||
child: MessageBubble(
|
||
key: ValueKey(
|
||
'${msg.id ?? msg.tempId}_${msg.localFile?.path ?? 'none'}',
|
||
),
|
||
message: msg,
|
||
onTap: () => _showMessageActions(msg),
|
||
onReplyTap: msg.replyToId != null
|
||
? () => _scrollToMessage(msg.replyToId)
|
||
: null,
|
||
onImageTap: () => _openFullScreenMedia(msg),
|
||
onDownloadRequested: (m) async {
|
||
await _ensureFileDecrypted(
|
||
m,
|
||
dontLoad: false,
|
||
);
|
||
},
|
||
onDownloadRequestedWithoutLoad: (m) async {
|
||
await _ensureFileDecrypted(
|
||
m,
|
||
dontLoad: true,
|
||
);
|
||
},
|
||
autoLoadMedia:
|
||
msg.messageType != MessageType.image
|
||
? true
|
||
: (msg.fileSize == null ||
|
||
msg.fileSize! <=
|
||
_autoMediaLoadLimitBytes),
|
||
downloadProgress:
|
||
_messageProgressNotifiers['${msg.fileId}'],
|
||
onDownloadStoped: (m) async {
|
||
await _stopFileLoading(msg);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
// Если это медиафайл или документ, оборачиваем в VisibilityDetector
|
||
if (isMedia) {
|
||
return VisibilityDetector(
|
||
key: ValueKey('visible_${keyId}'),
|
||
onVisibilityChanged: (visibilityInfo) {
|
||
// Как только элемент показался в зоне видимости хотя бы на 10%
|
||
if (visibilityInfo.visibleFraction > 0.1) {
|
||
if (msg.fileSize == null || msg.fileSize == 0) {
|
||
print(
|
||
"Элемент стал видим. Фоновый запрос размера для: ${msg.fileId}",
|
||
);
|
||
_fetchFileSizeIfNeeded(msg);
|
||
}
|
||
}
|
||
},
|
||
child: itemChild,
|
||
);
|
||
}
|
||
|
||
// Обычный текст возвращаем без детектора видимости
|
||
return itemChild;
|
||
}, childCount: messages.length),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 3. Кнопка скролла
|
||
AnimatedPositioned(
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOutCubic,
|
||
right: 16.0,
|
||
bottom: _inputBarHeight + 8.0 + 16,
|
||
child: AnimatedOpacity(
|
||
opacity: _showScrollToEnd ? 1.0 : 0.0,
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeInOut,
|
||
child: IgnorePointer(
|
||
ignoring: !_showScrollToEnd,
|
||
child: ClipOval(
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.surfaceVariant.withOpacity(0.75),
|
||
border: Border.all(
|
||
color: Theme.of(
|
||
context,
|
||
).dividerColor.withOpacity(0.25),
|
||
width: 1,
|
||
),
|
||
),
|
||
child: SizedBox(
|
||
width: 40,
|
||
height: 40,
|
||
child: IconButton(
|
||
onPressed: _scrollToBottom,
|
||
icon: Icon(
|
||
Icons.keyboard_arrow_down,
|
||
color: Theme.of(context).colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
if (_isRecording &&
|
||
!_isVoiceMode &&
|
||
_isCameraInitialized &&
|
||
_cameraController != null)
|
||
IgnorePointer(
|
||
child: Center(
|
||
child: Container(
|
||
width:
|
||
MediaQuery.of(context).size.width *
|
||
0.9, // Увеличили размер, так как по центру экрана круг должен быть крупным и четким
|
||
height: MediaQuery.of(context).size.width * 0.9,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.black,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.5),
|
||
blurRadius: 25,
|
||
spreadRadius: 4,
|
||
),
|
||
],
|
||
),
|
||
child: ClipOval(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// Берем пропорции самой камеры (важно: для вертикального отображения инвертируем)
|
||
final cameraAspectRatio =
|
||
_cameraController!.value.aspectRatio;
|
||
// Оборачиваем в OverflowBox, чтобы видео заполняло круг по меньшей стороне, а лишнее обрезалось
|
||
return OverflowBox(
|
||
alignment: Alignment.center,
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: SizedBox(
|
||
width: constraints.maxWidth,
|
||
height:
|
||
constraints.maxWidth * cameraAspectRatio,
|
||
child: CameraPreview(_cameraController!),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 4. Плавающее поле ввода
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 16,
|
||
child: _MeasureSize(
|
||
onChange: (size) {
|
||
if (_inputBarHeight != size.height) {
|
||
setState(() {
|
||
_inputBarHeight = size.height;
|
||
});
|
||
|
||
if (!_showScrollToEnd) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
0.0,
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
},
|
||
child: SafeArea(
|
||
top: false,
|
||
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 2),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(18),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: AnimatedSize(
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.easeOutCubic,
|
||
alignment: Alignment.bottomCenter,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(18),
|
||
clipBehavior: Clip.hardEdge,
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||
child: Container(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.surfaceVariant.withOpacity(0.5),
|
||
child: _buildMessageInput(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _isNewDay(int currentIndex) {
|
||
final int realIndex = messages.length - 1 - currentIndex;
|
||
|
||
if (realIndex == 0) return true;
|
||
|
||
final currentMsgTime = messages[realIndex].createdAt;
|
||
final previousMsgTime = messages[realIndex - 1].createdAt;
|
||
|
||
return currentMsgTime.year != previousMsgTime.year ||
|
||
currentMsgTime.month != previousMsgTime.month ||
|
||
currentMsgTime.day != previousMsgTime.day;
|
||
}
|
||
|
||
// Форматирование даты для плашки
|
||
String _formatDividerDate(DateTime date) {
|
||
final now = DateTime.now();
|
||
if (date.year == now.year &&
|
||
date.month == now.month &&
|
||
date.day == now.day) {
|
||
return "Сегодня";
|
||
}
|
||
final yesterday = now.subtract(const Duration(days: 1));
|
||
if (date.year == yesterday.year &&
|
||
date.month == yesterday.month &&
|
||
date.day == yesterday.day) {
|
||
return "Вчера";
|
||
}
|
||
|
||
const months = [
|
||
"января",
|
||
"февраля",
|
||
"марта",
|
||
"апреля",
|
||
"мая",
|
||
"июня",
|
||
"июля",
|
||
"августа",
|
||
"сентября",
|
||
"октября",
|
||
"ноября",
|
||
"декабря",
|
||
];
|
||
|
||
return "${date.day} ${months[date.month - 1]} ${date.year != now.year ? date.year : ''}"
|
||
.trim();
|
||
}
|
||
|
||
String _formatLastOnline(DateTime lastOnline) {
|
||
final now = DateTime.now();
|
||
final difference = now.difference(lastOnline);
|
||
|
||
if (difference.inSeconds < 60) {
|
||
return 'только что';
|
||
} else if (difference.inMinutes < 60) {
|
||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
||
} else if (difference.inHours < 24) {
|
||
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
|
||
} else if (difference.inDays < 7) {
|
||
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||
} else if (difference.inDays < 30) {
|
||
final weeks = (difference.inDays / 7).floor();
|
||
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
||
} else {
|
||
return 'давно';
|
||
}
|
||
}
|
||
|
||
String _pluralize(int count, String form1, String form2, String form5) {
|
||
final mod10 = count % 10;
|
||
final mod100 = count % 100;
|
||
if (mod10 == 1 && mod100 != 11) {
|
||
return form1;
|
||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||
return form2;
|
||
} else {
|
||
return form5;
|
||
}
|
||
}
|
||
|
||
Future<void> _showMessageActions(MessageModel msg) async {
|
||
if (!mounted) return;
|
||
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
showDragHandle: true,
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.reply),
|
||
title: const Text('Ответить'),
|
||
onTap: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop();
|
||
String text = msg.text;
|
||
if (msg.text.isEmpty &&
|
||
msg.messageType == MessageType.image) {
|
||
text = "[Фото]";
|
||
}
|
||
setState(() => _replyTo = msg.copyWith(text: text));
|
||
});
|
||
},
|
||
),
|
||
if (msg.isMe)
|
||
ListTile(
|
||
leading: const Icon(Icons.edit),
|
||
title: const Text('Изменить'),
|
||
onTap: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop();
|
||
_editMessage(msg);
|
||
});
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.copy),
|
||
title: const Text('Скопировать'),
|
||
onTap: () async {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
Navigator.of(ctx).pop();
|
||
await Clipboard.setData(ClipboardData(text: msg.text));
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Скопировано'),
|
||
behavior:
|
||
SnackBarBehavior.floating, // Обязательно для margin
|
||
margin: EdgeInsets.only(
|
||
bottom:
|
||
80.0 +
|
||
10.0, // 20px + стандартный отступ (по желанию)
|
||
left: 10.0,
|
||
right: 10.0,
|
||
),
|
||
duration: Duration(seconds: 2),
|
||
),
|
||
);
|
||
});
|
||
},
|
||
),
|
||
if (msg.messageType == MessageType.image ||
|
||
msg.messageType == MessageType.video ||
|
||
msg.messageType == MessageType.videoNote)
|
||
ListTile(
|
||
leading: const Icon(Icons.save_alt),
|
||
title: const Text('Сохранить в галерею'),
|
||
onTap: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop();
|
||
_saveMediaToGallery(msg);
|
||
});
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.forward),
|
||
title: const Text('Переслать'),
|
||
onTap: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop();
|
||
_showForwardContactPicker(msg);
|
||
});
|
||
},
|
||
),
|
||
/*if (msg.messageType == MessageType.image ||
|
||
msg.messageType == MessageType.video ||
|
||
msg.messageType == MessageType.file ||
|
||
msg.messageType == MessageType.videoNote ||
|
||
msg.messageType == MessageType.voiceNote)
|
||
ListTile(
|
||
leading: const Icon(Icons.delete_outline),
|
||
title: const Text('Удалить локальный файл'),
|
||
textColor: Colors.red,
|
||
iconColor: Colors.red,
|
||
onTap: () {
|
||
Navigator.of(ctx).pop();
|
||
_deleteLocalFile(msg);
|
||
},
|
||
),*/
|
||
if (msg.isMe)
|
||
ListTile(
|
||
leading: const Icon(Icons.delete_outline),
|
||
title: const Text('Удалить'),
|
||
textColor: Colors.red,
|
||
iconColor: Colors.red,
|
||
onTap: () async {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
Navigator.of(ctx).pop();
|
||
await _deleteMessage(msg);
|
||
});
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _deleteLocalFile(MessageModel msg) async {
|
||
if (msg.localFile != null && msg.localFile!.existsSync()) {
|
||
try {
|
||
await msg.localFile!.delete();
|
||
debugPrint("Локальный файл успешно удален с диска: ${msg.fileId}");
|
||
} catch (e) {
|
||
debugPrint("Ошибка при физическом удалении файла с диска: $e");
|
||
// Даже если файл не удалился физически, мы всё равно очистим стейт,
|
||
// чтобы приложение не пыталось его прочитать и не падало.
|
||
}
|
||
|
||
final sharedPrefs = await SharedPreferences.getInstance();
|
||
final String sizeKey = 'valid_dec_size_${msg.fileId}';
|
||
await sharedPrefs.remove(sizeKey);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.id == msg.id);
|
||
print(
|
||
"Очистка локального файла для сообщения ${msg.id}. Индекс в списке: $idx",
|
||
);
|
||
if (idx != -1) {
|
||
messages[idx] = messages[idx].copyWith(localFile: null);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _editMessage(MessageModel msg) async {
|
||
final controller = TextEditingController(text: msg.text);
|
||
final result = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Изменить сообщение'),
|
||
content: TextField(
|
||
controller: controller,
|
||
minLines: 1,
|
||
maxLines: 5,
|
||
autofocus: true,
|
||
decoration: const InputDecoration(hintText: 'Новый текст сообщения'),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop(false);
|
||
});
|
||
},
|
||
child: const Text('Отмена'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(ctx).pop(true);
|
||
});
|
||
},
|
||
child: const Text('Сохранить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (result != true || controller.text.trim().isEmpty) return;
|
||
|
||
final newText = controller.text.trim();
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
if (myPrivKey == null) return;
|
||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey,
|
||
_currentContact.publicKey!,
|
||
);
|
||
final encryptedContent = await _cryptoService.encryptMessage(
|
||
newText,
|
||
sharedSecret,
|
||
);
|
||
|
||
final content50 = newText.length > 50 ? newText.substring(0, 50) : newText;
|
||
final encryptedContent50 = await _cryptoService.encryptMessage(
|
||
content50,
|
||
sharedSecret,
|
||
);
|
||
|
||
setState(() {
|
||
messages = messages.map((m) {
|
||
if (m.id != null && m.id == msg.id) {
|
||
return m.copyWith(text: newText, editedAt: DateTime.now());
|
||
}
|
||
return m;
|
||
}).toList();
|
||
});
|
||
|
||
if (msg.id != null) {
|
||
try {
|
||
await _localDbService.updateMessageContent(
|
||
msg.id!,
|
||
encryptedContent,
|
||
DateTime.now(),
|
||
);
|
||
} catch (_) {}
|
||
Provider.of<SocketService>(context, listen: false).sendMessage({
|
||
'type': 'edit_message',
|
||
'message_id': msg.id,
|
||
'content': encryptedContent,
|
||
'content50': encryptedContent50,
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _deleteMessage(MessageModel msg) async {
|
||
setState(() {
|
||
messages.removeWhere(
|
||
(m) =>
|
||
(m.id != null && m.id == msg.id) ||
|
||
(m.tempId != null && m.tempId == msg.tempId),
|
||
);
|
||
});
|
||
|
||
final id = msg.id;
|
||
if (id != null) {
|
||
try {
|
||
await _localDbService.deleteMessage(id);
|
||
} catch (_) {}
|
||
Provider.of<SocketService>(
|
||
context,
|
||
listen: false,
|
||
).sendMessage({'type': 'delete_message', 'message_id': id});
|
||
}
|
||
}
|
||
|
||
Future<void> _showForwardContactPicker(MessageModel msg) async {
|
||
// Открываем новый красивый экран выбора вместо bottomSheet
|
||
final selectedContact = await Navigator.of(context).push<Contact?>(
|
||
MaterialPageRoute(
|
||
builder: (context) => ForwardContactPickerScreen(message: msg),
|
||
),
|
||
);
|
||
|
||
// Если контакт был выбран и нажата кнопка «Продолжить»
|
||
if (selectedContact != null && mounted) {
|
||
// Запускаем твою готовую и исправленную функцию пересылки медиа/текста
|
||
await _forwardMessage(msg, selectedContact);
|
||
}
|
||
}
|
||
|
||
Future<void> _forwardMessage(
|
||
MessageModel originalMsg,
|
||
Contact targetContact,
|
||
) async {
|
||
try {
|
||
final isSameChat = _currentContact.id == targetContact.id;
|
||
String? newFileId;
|
||
String? newEncryptedKey;
|
||
File? newLocalFile;
|
||
|
||
final tempId = DateTime.now().millisecondsSinceEpoch;
|
||
|
||
// 1. E2EE Защита: Если публичного ключа нет в объекте, пробуем запросить его у сервера
|
||
String? targetPublicKey = targetContact.publicKey;
|
||
if (originalMsg.fileId != null &&
|
||
(targetPublicKey == null || targetPublicKey.isEmpty)) {
|
||
debugPrint(
|
||
"==> [Forward] У контакта нет публичного ключа в кэше. Запрашиваем с сервера...",
|
||
);
|
||
try {
|
||
// Вызываем метод твоего API для получения свежих данных пользователя
|
||
final freshContact = await apiService.getUserByUsername(
|
||
targetContact.username,
|
||
);
|
||
if (freshContact != null && freshContact.publicKey != null) {
|
||
targetPublicKey = freshContact.publicKey;
|
||
targetContact.publicKey =
|
||
freshContact.publicKey; // Обновляем инстанс в памяти
|
||
}
|
||
} catch (e) {
|
||
debugPrint(
|
||
"==> [Forward] Не удалось запросить ключ получателя с сервера: $e",
|
||
);
|
||
}
|
||
}
|
||
|
||
// 2. Если есть медиа — обрабатываем на сервере и перешифровываем ключи
|
||
if (originalMsg.fileId != null) {
|
||
debugPrint(
|
||
"==> [Forward] Старт копирования медиа. fileId: ${originalMsg.fileId}",
|
||
);
|
||
|
||
final copiedFileId = await apiService.copyMediaOnServer(
|
||
originalMsg.fileId!,
|
||
targetContact.id,
|
||
);
|
||
|
||
if (copiedFileId == null) {
|
||
throw Exception("Сервер отказал в копировании файла");
|
||
}
|
||
newFileId = copiedFileId;
|
||
|
||
// Копируем локальный файл асинхронно, дожидаясь (await) завершения
|
||
if (originalMsg.localFile != null) {
|
||
final directory = await getApplicationDocumentsDirectory();
|
||
// Сохраняем строго под префиксом file_, который ожидает MessageBubble
|
||
final decFile = '${directory.path}/dec_$copiedFileId';
|
||
newLocalFile = await originalMsg.localFile!.copy(decFile);
|
||
print(
|
||
"Локальный файл для пересылки создан по пути: ${newLocalFile.path}",
|
||
);
|
||
} else if (originalMsg.fileId != null) {
|
||
final directory = await getApplicationDocumentsDirectory();
|
||
// Сохраняем строго под префиксом file_, который ожидает MessageBubble
|
||
final decFile = '${directory.path}/dec_$copiedFileId';
|
||
final File oldFile = File(
|
||
'${directory.path}/dec_${originalMsg.fileId!}',
|
||
);
|
||
newLocalFile = await oldFile.copy(decFile);
|
||
print(
|
||
"Локальный файл для пересылки создан через id по пути: ${newLocalFile.path}",
|
||
);
|
||
} else {
|
||
print(
|
||
"Невозможно создать локальную копию файла для пересылки: отсутствует и локальный файл, и fileId.",
|
||
);
|
||
}
|
||
final sharedPrefs = await SharedPreferences.getInstance();
|
||
final String sizeKey = 'valid_dec_size_$copiedFileId';
|
||
final finalFileSize = await newLocalFile?.length();
|
||
if (finalFileSize != null && finalFileSize > 0) {
|
||
// Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл
|
||
await sharedPrefs.setInt(sizeKey, finalFileSize);
|
||
debugPrint(
|
||
"Файл успешно загружен. Размер сохранен: $finalFileSize байт.",
|
||
);
|
||
}
|
||
|
||
// Проверяем условия для криптографии
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
|
||
if (originalMsg.encryptedFileKey == null) {
|
||
throw Exception(
|
||
"У оригинального сообщения отсутствует ключ шифрования файла.",
|
||
);
|
||
}
|
||
if (targetPublicKey == null || targetPublicKey.isEmpty) {
|
||
throw Exception(
|
||
"Невозможно переслать медиа: у получателя отсутствует публичный ключ шифрования E2EE.",
|
||
);
|
||
}
|
||
|
||
final oldSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey!,
|
||
_currentContact.publicKey!,
|
||
);
|
||
final newSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey,
|
||
targetPublicKey,
|
||
);
|
||
|
||
final decryptedKey = await _cryptoService.decryptAesKey(
|
||
originalMsg.encryptedFileKey!,
|
||
oldSecret,
|
||
);
|
||
if (decryptedKey == null) {
|
||
throw Exception("Не удалось расшифровать ключ файла для пересылки");
|
||
}
|
||
newEncryptedKey = await _cryptoService.encryptAesKey(
|
||
decryptedKey,
|
||
newSecret,
|
||
);
|
||
}
|
||
|
||
// 3. Отрисовываем сообщение в текущем чате (если пересылаем сами себе)
|
||
// Теперь localFile передается сразу, предотвращая ложные индикаторы загрузки
|
||
print(
|
||
"Перед добавлением пересланного сообщения в UI: newFileId=$newFileId, newEncryptedKey=${newEncryptedKey != null ? 'есть' : 'нет'}, newLocalFile=${newLocalFile != null ? 'есть' : 'нет'}",
|
||
);
|
||
if (isSameChat) {
|
||
final localMsg = MessageModel(
|
||
tempId: tempId,
|
||
text: originalMsg.text,
|
||
isMe: true,
|
||
senderId: myId,
|
||
receiverId: targetContact.id,
|
||
createdAt: DateTime.now(),
|
||
status: MessageStatus.sending,
|
||
messageType: originalMsg.messageType,
|
||
fileId: newFileId ?? originalMsg.fileId,
|
||
fileName: originalMsg.fileName,
|
||
localFile: newLocalFile,
|
||
fileSize: originalMsg.fileSize,
|
||
encryptedFileKey: newEncryptedKey ?? originalMsg.encryptedFileKey,
|
||
);
|
||
|
||
setState(() {
|
||
messages.add(localMsg);
|
||
});
|
||
_scrollToBottom();
|
||
}
|
||
|
||
// 4. Шифруем текстовую часть контента
|
||
if (targetPublicKey == null || targetPublicKey.isEmpty) {
|
||
throw Exception(
|
||
"У получателя отсутствует публичный ключ для шифрования текста.",
|
||
);
|
||
}
|
||
|
||
final textSecret = await _cryptoService.deriveSharedSecret(
|
||
(await _cryptoService.getPrivateKey())!,
|
||
targetPublicKey,
|
||
);
|
||
final encryptedContent = await _cryptoService.encryptMessage(
|
||
originalMsg.text,
|
||
textSecret,
|
||
);
|
||
|
||
// 5. Формируем Payload
|
||
final payload = {
|
||
"type": "private_message",
|
||
"receiver_id": targetContact.id,
|
||
"message_type": originalMsg.messageType.name,
|
||
"content": encryptedContent,
|
||
"temp_id": tempId,
|
||
if (newFileId != null) ...{
|
||
"file_id": newFileId,
|
||
"encrypted_key": newEncryptedKey,
|
||
},
|
||
};
|
||
|
||
// 6. Отправка и навигация
|
||
final socket = Provider.of<SocketService>(context, listen: false);
|
||
final isSent = socket.sendMessage(payload);
|
||
|
||
if (!isSent) throw Exception("Ошибка отправки через сокет");
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
if (!isSameChat) {
|
||
Navigator.pushReplacement(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatScreen(contact: targetContact),
|
||
),
|
||
);
|
||
} else {
|
||
// Если переслали в текущий чат — обновляем статус
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx != -1) {
|
||
messages[idx] = messages[idx].copyWith(
|
||
status: MessageStatus.sent,
|
||
fileId: newFileId ?? originalMsg.fileId,
|
||
localFile: newLocalFile,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
} catch (e) {
|
||
debugPrint("[Error] Ошибка пересылки: $e");
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll("Exception: ", "")),
|
||
backgroundColor: Colors.redAccent,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildMessageInput() {
|
||
final bool hasTextOrFile =
|
||
_controller.text.trim().isNotEmpty || _pendingFile != null;
|
||
final bool showSendButton = hasTextOrFile || _isRecordLocked;
|
||
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Container(
|
||
constraints: const BoxConstraints(maxHeight: 250),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.surfaceVariant.withOpacity(0.75),
|
||
borderRadius: BorderRadius.circular(18),
|
||
border: Border.all(
|
||
color: Theme.of(context).dividerColor.withOpacity(0.25),
|
||
width: 1,
|
||
),
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_replyTo != null)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 8,
|
||
),
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.reply, size: 18),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
_replyTo!.text.isNotEmpty
|
||
? _replyTo!.text
|
||
: _getMediaPreview(_replyTo!.messageType),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 18),
|
||
onPressed: () => setState(() => _replyTo = null),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (_pendingFile != null)
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: _buildPreviewIcon(),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
_pendingFileName ?? "Файл",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
Text(
|
||
_pendingMessageType.name.toUpperCase(),
|
||
style: const TextStyle(fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 22),
|
||
onPressed: () => setState(() {
|
||
_pendingFile = null;
|
||
_pendingFileName = null;
|
||
_previewBytes = null;
|
||
_pendingMessageType = MessageType.text;
|
||
}),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (!_isRecording)
|
||
GestureDetector(
|
||
onTapDown: (details) {
|
||
_showPopup(context, details.globalPosition);
|
||
},
|
||
child: Container(
|
||
width: 32,
|
||
height: 32,
|
||
alignment: Alignment.center,
|
||
child: const Icon(Icons.photo, size: 22),
|
||
),
|
||
)
|
||
else
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||
child: Icon(
|
||
Icons.fiber_manual_record,
|
||
color: Colors.red,
|
||
size: 20,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _isRecording
|
||
? Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 6,
|
||
horizontal: 4,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
_stopwatchDisplay,
|
||
style: const TextStyle(
|
||
color: Colors.red,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_isRecordLocked
|
||
? Text(
|
||
"Удержание записи",
|
||
style: TextStyle(
|
||
color: Colors.grey.shade500,
|
||
fontSize: 11,
|
||
),
|
||
)
|
||
: Text(
|
||
_recordDragX < _swipeCancelThreshold / 2
|
||
? "Отпусти для отмены"
|
||
: (_recordDragY <
|
||
_swipeLockThreshold / 2
|
||
? "Отпусти для удержания"
|
||
: "Проведите вверх для удержания"),
|
||
style: TextStyle(
|
||
color: Colors.grey.shade500,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
],
|
||
),
|
||
)
|
||
: TextField(
|
||
controller: _controller,
|
||
minLines: 1,
|
||
maxLines: 5,
|
||
readOnly: _isRecordLocked,
|
||
textInputAction: TextInputAction.newline,
|
||
textCapitalization: TextCapitalization.sentences,
|
||
textAlignVertical: TextAlignVertical.center,
|
||
style: const TextStyle(fontSize: 15),
|
||
decoration: InputDecoration(
|
||
hintText: _isRecordLocked
|
||
? "Запись зафиксирована..."
|
||
: "Напиши сообщение...",
|
||
isDense: true,
|
||
isCollapsed: true,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 4,
|
||
vertical: 6,
|
||
),
|
||
),
|
||
onChanged: (text) => setState(() {}),
|
||
),
|
||
),
|
||
_buildContextButton(showSendButton),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildContextButton(bool showSendButton) {
|
||
if (showSendButton) {
|
||
return GestureDetector(
|
||
onTap: () {
|
||
if (_isRecordLocked) {
|
||
_stopAndSendRecording();
|
||
} else {
|
||
_sendMessage();
|
||
}
|
||
},
|
||
child: Container(
|
||
width: 36,
|
||
height: 36,
|
||
alignment: Alignment.center,
|
||
child: const Icon(Icons.send, size: 22),
|
||
),
|
||
);
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: _toggleRecordMode,
|
||
onLongPressStart: (_) => _startRecording(),
|
||
onLongPressMoveUpdate: (details) {
|
||
if (!_isRecording || _isRecordLocked) return;
|
||
|
||
setState(() {
|
||
_recordDragX = details.localOffsetFromOrigin.dx;
|
||
_recordDragY = details.localOffsetFromOrigin.dy;
|
||
});
|
||
|
||
if (_recordDragX < _swipeCancelThreshold) {
|
||
_cancelRecording();
|
||
} else if (_recordDragY < _swipeLockThreshold) {
|
||
_lockRecording();
|
||
}
|
||
},
|
||
onLongPressEnd: (_) {
|
||
if (_isRecording && !_isRecordLocked) {
|
||
_stopAndSendRecording();
|
||
}
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 100),
|
||
width: 36,
|
||
height: 36,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: _isRecording
|
||
? Colors.red.withOpacity(0.15)
|
||
: Colors.transparent,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
_isVoiceMode ? Icons.mic : Icons.videocam,
|
||
size: _isRecording ? 24 : 22,
|
||
color: _isRecording ? Colors.red : Theme.of(context).iconTheme.color,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showPopup(BuildContext context, Offset position) async {
|
||
final selected = await showMenu<String>(
|
||
context: context,
|
||
position: RelativeRect.fromLTRB(
|
||
position.dx,
|
||
position.dy,
|
||
position.dx,
|
||
position.dy,
|
||
),
|
||
items: [
|
||
PopupMenuItem(
|
||
value: 'camera',
|
||
child: Row(
|
||
children: const [
|
||
Icon(Icons.camera_alt),
|
||
SizedBox(width: 8),
|
||
Text("Камера"),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'gallery',
|
||
child: Row(
|
||
children: const [
|
||
Icon(Icons.photo_library),
|
||
SizedBox(width: 8),
|
||
Text("Галерея"),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'file',
|
||
child: Row(
|
||
children: const [
|
||
Icon(Icons.insert_drive_file),
|
||
SizedBox(width: 8),
|
||
Text("Файлы"),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
// обработка выбора
|
||
switch (selected) {
|
||
case 'camera':
|
||
_pickCamera();
|
||
break;
|
||
case 'gallery':
|
||
_pickGallery();
|
||
break;
|
||
case 'file':
|
||
_pickFile();
|
||
break;
|
||
}
|
||
}
|
||
|
||
Future<void> _pickCamera() async {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
final result = await Navigator.push<(XFile, String)>(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const CameraScreen()),
|
||
);
|
||
if (result == null) return;
|
||
final file = result.$1;
|
||
final type = result.$2;
|
||
final bytes = type == 'image' ? await file.readAsBytes() : null;
|
||
setState(() {
|
||
if (type == 'image') {
|
||
_previewBytes = bytes;
|
||
}
|
||
_pendingFile = File(file.path);
|
||
_pendingFileName = 'media_${DateTime.now().millisecondsSinceEpoch}';
|
||
_pendingMessageType = type == 'video'
|
||
? MessageType.video
|
||
: MessageType.image;
|
||
});
|
||
});
|
||
}
|
||
|
||
Future<void> _pickGallery() async {
|
||
final photosGranted = await Permission.photos.request();
|
||
final videosGranted = await Permission.videos.request();
|
||
if (!photosGranted.isGranted || !videosGranted.isGranted) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
"Разрешение на доступ к медиа необходимо для выбора фото или видео.",
|
||
),
|
||
behavior: SnackBarBehavior.floating,
|
||
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
|
||
duration: Duration(seconds: 3),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
final List<AssetEntity>? result = await AssetPicker.pickAssets(
|
||
context,
|
||
pickerConfig: AssetPickerConfig(
|
||
maxAssets: 1,
|
||
pageSize: 33,
|
||
gridCount: 3,
|
||
pickerTheme: ThemeData(
|
||
brightness: Theme.of(context).brightness,
|
||
primaryColor: Theme.of(context).primaryColor,
|
||
colorScheme: Theme.of(context).colorScheme,
|
||
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||
appBarTheme: AppBarTheme(
|
||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||
),
|
||
),
|
||
specialItemBuilder: null,
|
||
),
|
||
);
|
||
if (result != null && result.isNotEmpty) {
|
||
final asset = result.first;
|
||
try {
|
||
Uint8List? bytes;
|
||
if (asset.type == AssetType.image) {
|
||
bytes = await asset.originBytes;
|
||
}
|
||
final File? file = await asset.file;
|
||
if (file == null) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Не удалось получить доступ к файлу медиа.'),
|
||
duration: Duration(seconds: 2),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {
|
||
if (asset.type == AssetType.image && bytes != null) {
|
||
_previewBytes = bytes;
|
||
}
|
||
_pendingFile = file;
|
||
_pendingFileName =
|
||
asset.title ?? 'media_${DateTime.now().millisecondsSinceEpoch}';
|
||
_pendingMessageType = asset.type == AssetType.video
|
||
? MessageType.video
|
||
: MessageType.image;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Ошибка при выборе медиа: $e'),
|
||
duration: const Duration(seconds: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _pickFile() async {
|
||
FilePickerResult? result = await FilePicker.pickFiles(type: FileType.any);
|
||
|
||
if (result != null && result.files.isNotEmpty) {
|
||
try {
|
||
final file = File(result.files.single.path!);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_pendingFile = file;
|
||
_pendingFileName = result.files.single.name;
|
||
_pendingMessageType = MessageType.file;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Ошибка при выборе файла: $e'),
|
||
duration: const Duration(seconds: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<File?> _compressAndCropVideoNoteSafe(File originalVideoFile) async {
|
||
try {
|
||
if (!await originalVideoFile.exists()) {
|
||
debugPrint('==> FFmpeg: Исходный файл не найден на диске.');
|
||
return null;
|
||
}
|
||
|
||
final String targetOriginalPath = originalVideoFile.path;
|
||
debugPrint(
|
||
'==> Исходный файл: $targetOriginalPath, размер: ${await originalVideoFile.length()} байт',
|
||
);
|
||
|
||
// 1. Мгновенно переименовываем оригинальный файл во временный входной файл
|
||
final String tempInputPath = '${targetOriginalPath}_temp_input.mp4';
|
||
final File movedOriginalFile = await originalVideoFile.rename(
|
||
tempInputPath,
|
||
);
|
||
|
||
// 2. Строим команду в виде списка аргументов (List<String>).
|
||
// Больше никаких ручных кавычек вокруг путей типа "$tempInputPath" — плагин сам всё экранирует!
|
||
final List<String> ffmpegArgs = [
|
||
'-i',
|
||
tempInputPath,
|
||
'-vf',
|
||
'crop=min(iw\\,ih):min(iw\\,ih),scale=512:512',
|
||
'-vcodec',
|
||
'libx264',
|
||
'-crf',
|
||
'28',
|
||
'-preset',
|
||
'fast',
|
||
'-y',
|
||
targetOriginalPath,
|
||
];
|
||
|
||
debugPrint(
|
||
'==> FFmpeg: Запуск потоковой обработки через массив аргументов...',
|
||
);
|
||
|
||
// 3. Вызываем executeWithArguments вместо обычной строки
|
||
final session = await FFmpegKit.executeWithArguments(ffmpegArgs);
|
||
final returnCode = await session.getReturnCode();
|
||
|
||
// Логируем внутренний вывод FFmpeg на случай непредвиденных ошибок кодека устройства
|
||
final output = await session.getOutput();
|
||
if (output != null && output.isNotEmpty) {
|
||
debugPrint('==> FFmpeg Консоль:\n$output');
|
||
}
|
||
|
||
if (ReturnCode.isSuccess(returnCode)) {
|
||
final outputFile = File(targetOriginalPath);
|
||
if (await outputFile.exists()) {
|
||
debugPrint(
|
||
'==> FFmpeg: Успех! Новый размер файла: ${await outputFile.length()} байт',
|
||
);
|
||
}
|
||
|
||
// Безопасно удаляем временный файл исходника
|
||
if (await movedOriginalFile.exists()) {
|
||
await movedOriginalFile.delete();
|
||
}
|
||
|
||
return outputFile;
|
||
} else {
|
||
final failStackTrace = await session.getFailStackTrace();
|
||
debugPrint(
|
||
'==> FFmpeg: Ошибка кодирования. Код возврата: $returnCode. Стек: $failStackTrace',
|
||
);
|
||
|
||
// ВОССТАНОВЛЕНИЕ: Возвращаем оригинальный файл на место, если перекодирование не удалось
|
||
if (await movedOriginalFile.exists()) {
|
||
await movedOriginalFile.rename(targetOriginalPath);
|
||
}
|
||
return null;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('==> Критическая ошибка при изменении файла: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> _sendMessage() async {
|
||
_sendStopTypingStatus();
|
||
String rawText = _controller.text.trim();
|
||
File? file = _pendingFile;
|
||
final MessageType messageType = _pendingMessageType;
|
||
final hasMedia = _pendingFile != null;
|
||
final replyTo = _replyTo;
|
||
|
||
if (messageType == MessageType.videoNote ||
|
||
messageType == MessageType.voiceNote) {
|
||
rawText =
|
||
""; // Для видеозаметок и голосовых сообщений текст не обязателен, игнорируем его
|
||
}
|
||
|
||
// Если и текст пустой, и медиа нет — выходим
|
||
if (rawText.isEmpty && !hasMedia) return;
|
||
_scrollToBottom();
|
||
// Блокируем UI на время загрузки
|
||
_controller.clear();
|
||
_pendingFile = null;
|
||
_pendingMessageType = MessageType.text; // Сбрасываем тип медиа
|
||
_previewBytes = null; // Очищаем превью
|
||
_pendingFileName = null;
|
||
_replyTo = null;
|
||
|
||
final tempId = DateTime.now().millisecondsSinceEpoch;
|
||
try {
|
||
print(
|
||
"Исходный файл: ${file?.path}, размер: ${await file?.length()} байт",
|
||
);
|
||
if (messageType == MessageType.videoNote && file != null) {
|
||
file = await _compressAndCropVideoNoteSafe(file);
|
||
print(
|
||
"После обработки видеозаметки: ${file?.path}, размер: ${await file?.length()} байт",
|
||
);
|
||
}
|
||
|
||
int? fileSize = await file?.length();
|
||
// создаем первичную модель отобрадения
|
||
MessageModel tempMsg = MessageModel(
|
||
senderId: myId,
|
||
receiverId: _currentContact.id,
|
||
createdAt: DateTime.now(),
|
||
isMe: true,
|
||
text: rawText,
|
||
tempId: tempId,
|
||
messageType: messageType,
|
||
localFile: file,
|
||
status: MessageStatus.encrypting,
|
||
fileSize: fileSize,
|
||
replyToId: replyTo?.id,
|
||
replyToText: replyTo?.text,
|
||
fileId: tempId.toString(),
|
||
fileName: file != null ? p.basename(file.path) : "file",
|
||
);
|
||
|
||
setState(() => messages.add(tempMsg));
|
||
// 1. Подготовка ключей
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey!,
|
||
_currentContact.publicKey!,
|
||
);
|
||
|
||
String? fileId;
|
||
String? encryptedFileKey;
|
||
String encryptedContent;
|
||
String encryptedContent50;
|
||
String? encryptedReplyToText;
|
||
_messageProgressNotifiers['${tempMsg.fileId}'] ??= ValueNotifier<double?>(
|
||
0.0,
|
||
);
|
||
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
|
||
// 2. Если есть медиа — сначала загружаем его
|
||
if (hasMedia && file != null && fileSize != null) {
|
||
final fileStream = file.openRead();
|
||
final encryptedStream = await _cryptoService.encryptFileStream(
|
||
fileStream,
|
||
sharedSecret,
|
||
totalSize: fileSize,
|
||
onProgress: (received, total) {
|
||
print(received);
|
||
if (total != -1) {
|
||
double progress = received / total;
|
||
if (progress > 1.0) progress = 1.0;
|
||
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
|
||
}
|
||
},
|
||
);
|
||
final fileKeyForServer = encryptedStream.$2;
|
||
final tempDir = await getTemporaryDirectory();
|
||
final encFile = File('${tempDir.path}/enc_${tempId}.tmp');
|
||
final ios = encFile.openWrite();
|
||
await ios.addStream(encryptedStream.$1);
|
||
await ios.close();
|
||
|
||
final int exactEncryptedSize = await encFile.length();
|
||
|
||
setState(() {
|
||
tempMsg = tempMsg.copyWith(status: MessageStatus.sending);
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx != -1) {
|
||
messages[idx] = tempMsg;
|
||
}
|
||
fileSize = exactEncryptedSize;
|
||
});
|
||
|
||
_messageProgressNotifiers['${tempMsg.fileId}'] ??=
|
||
ValueNotifier<double?>(0.0);
|
||
_messageProgressNotifiers['${tempMsg.fileId}']!.value = 0.0;
|
||
fileId = await apiService.uploadFileStream(
|
||
encFile.openRead(),
|
||
exactEncryptedSize,
|
||
purpose: messageType.name,
|
||
fileName: p.basename(file.path),
|
||
|
||
onProgress: (received, total) {
|
||
print(received);
|
||
if (total != -1) {
|
||
double progress = received / total;
|
||
if (progress > 1.0) progress = 1.0;
|
||
_messageProgressNotifiers['${tempMsg.fileId}']?.value = progress;
|
||
}
|
||
},
|
||
);
|
||
if (await encFile.exists()) {
|
||
await encFile.delete();
|
||
}
|
||
|
||
if (fileId == null) {
|
||
throw Exception("Ошибка загрузки файла на сервер");
|
||
}
|
||
|
||
encryptedFileKey = fileKeyForServer;
|
||
}
|
||
|
||
// 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к медиа)
|
||
// Если текста нет, но есть медиа, отправим пустую строку
|
||
final String textToEncrypt = rawText.isNotEmpty
|
||
? rawText
|
||
: (hasMedia ? "" : "");
|
||
|
||
encryptedContent = await _cryptoService.encryptMessage(
|
||
textToEncrypt,
|
||
sharedSecret,
|
||
);
|
||
|
||
// Генерируем превью текст в зависимости от типа медиа
|
||
String previewText;
|
||
if (rawText.isNotEmpty) {
|
||
previewText = rawText;
|
||
} else if (hasMedia) {
|
||
previewText = switch (messageType) {
|
||
MessageType.videoNote => "[Кружок]",
|
||
MessageType.voiceNote => "[Голосовое]",
|
||
MessageType.image => "[Фото]",
|
||
MessageType.video => "[Видео]",
|
||
MessageType.file => "[Файл]",
|
||
MessageType.text => "",
|
||
};
|
||
} else {
|
||
previewText = "";
|
||
}
|
||
if (previewText.length > 50) previewText = previewText.substring(0, 50);
|
||
encryptedContent50 = await _cryptoService.encryptMessage(
|
||
previewText,
|
||
sharedSecret,
|
||
);
|
||
|
||
if (replyTo?.id != null && replyTo!.text.trim().isNotEmpty) {
|
||
encryptedReplyToText = await _cryptoService.encryptMessage(
|
||
replyTo.text,
|
||
sharedSecret,
|
||
);
|
||
}
|
||
|
||
// 4. Создаем локальную модель для мгновенного отображения
|
||
final localMessage = MessageModel(
|
||
tempId: tempId,
|
||
text: rawText,
|
||
isMe: true,
|
||
senderId: myId,
|
||
receiverId: _currentContact.id,
|
||
createdAt: DateTime.now(),
|
||
status: MessageStatus.sending,
|
||
localFile: file,
|
||
messageType: messageType,
|
||
fileId: fileId,
|
||
encryptedFileKey: encryptedFileKey,
|
||
replyToId: replyTo?.id,
|
||
replyToText: replyTo?.text,
|
||
fileSize: await file?.length(),
|
||
fileName: file != null ? p.basename(file.path) : "file",
|
||
);
|
||
final directory = await getApplicationDocumentsDirectory();
|
||
if (file != null) {
|
||
await file.copy('${directory.path}/dec_$fileId');
|
||
print(
|
||
"DEBUG: Сохраняю файл: ${file.path}, существует: ${await file.exists()}, размер: ${await file.length()}",
|
||
);
|
||
|
||
final sharedPrefs = await SharedPreferences.getInstance();
|
||
final String sizeKey = 'valid_dec_size_$fileId';
|
||
final finalFileSize = await file.length();
|
||
if (finalFileSize > 0) {
|
||
// Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл
|
||
await sharedPrefs.setInt(sizeKey, finalFileSize);
|
||
debugPrint(
|
||
"Файл успешно загружен. Размер сохранен: $finalFileSize байт.",
|
||
);
|
||
}
|
||
} else {
|
||
print(
|
||
"==> [Warning] Локальный файл отсутствует для сообщения с tempId: $tempId",
|
||
);
|
||
}
|
||
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx != -1) {
|
||
messages[idx] = localMessage;
|
||
}
|
||
file = null; // Очищаем черновик
|
||
});
|
||
|
||
if (hasMedia && (fileId == null || encryptedFileKey == null)) {
|
||
throw Exception(
|
||
'Не удалось загрузить медиа перед отправкой сообщения.',
|
||
);
|
||
}
|
||
|
||
// 5. Формируем финальный payload для сокета
|
||
final payload = {
|
||
"type": "private_message",
|
||
"receiver_id": _currentContact.id,
|
||
"message_type": messageType.name,
|
||
"content": encryptedContent,
|
||
"content50": encryptedContent50,
|
||
"temp_id": tempId,
|
||
if (hasMedia) ...{
|
||
"file_id": fileId,
|
||
"encrypted_key": encryptedFileKey,
|
||
if (messageType == MessageType.file)
|
||
"file_name": file != null ? p.basename(file!.path) : "file",
|
||
},
|
||
if (replyTo?.id != null) ...{
|
||
"reply_to_id": replyTo!.id,
|
||
if (encryptedReplyToText != null)
|
||
"reply_to_text": encryptedReplyToText,
|
||
},
|
||
};
|
||
|
||
// Логирование для отладки
|
||
print('[DEBUG] _sendMessage payload:');
|
||
print('[DEBUG] - type: ${payload['type']}');
|
||
print('[DEBUG] - receiver_id: ${payload['receiver_id']}');
|
||
print('[DEBUG] - message_type: ${payload['message_type']}');
|
||
print(
|
||
'[DEBUG] - content length: ${(payload['content'] as String?)?.length ?? 0}',
|
||
);
|
||
print('[DEBUG] - temp_id: ${payload['temp_id']}');
|
||
if (hasMedia) {
|
||
print('[DEBUG] - file_id: ${payload['file_id']}');
|
||
print(
|
||
'[DEBUG] - encrypted_key: ${(payload['encrypted_key'] as String?)?.length ?? 0}',
|
||
);
|
||
if (payload.containsKey('file_name')) {
|
||
print('[DEBUG] - file_name: ${payload['file_name']}');
|
||
}
|
||
}
|
||
|
||
// 6. Отправка через сокет
|
||
final ok = Provider.of<SocketService>(
|
||
context,
|
||
listen: false,
|
||
).sendMessage(payload);
|
||
|
||
// Обновляем статус
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx != -1) {
|
||
messages[idx] = messages[idx].copyWith(
|
||
status: ok ? MessageStatus.sent : MessageStatus.failed,
|
||
);
|
||
}
|
||
_replyTo = null;
|
||
});
|
||
} catch (e) {
|
||
try {
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx != -1) {
|
||
messages.removeAt(idx);
|
||
}
|
||
_replyTo = null;
|
||
});
|
||
} catch (e) {
|
||
print(e);
|
||
}
|
||
print(e);
|
||
// В случае ошибки возвращаем текст и медиа в контроллер
|
||
_controller.text = rawText;
|
||
_pendingFile = file;
|
||
_pendingMessageType = messageType;
|
||
_replyTo = replyTo;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text("Ошибка отправки: $e"),
|
||
behavior: SnackBarBehavior.floating,
|
||
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
|
||
duration: Duration(seconds: 5),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
||
print('Meesage from websocket: $data');
|
||
DateTime now = DateTime.now();
|
||
|
||
Duration offset = now.timeZoneOffset;
|
||
if (data['type'] == 'message_sent') {
|
||
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
|
||
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
|
||
var ts = DateTime.tryParse(
|
||
data['timestamp']?.toString() ?? '',
|
||
)?.add(offset);
|
||
|
||
if (tempId == null) return;
|
||
if (!mounted) return;
|
||
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx == -1) return;
|
||
|
||
// 1. Создаем обновленный объект сообщения с серверным ID
|
||
final updatedMsg = messages[idx].copyWith(
|
||
id: serverId ?? messages[idx].id,
|
||
createdAt: ts ?? messages[idx].createdAt,
|
||
status: MessageStatus.sent,
|
||
);
|
||
|
||
// 2. Обновляем его в основном списке
|
||
messages[idx] = updatedMsg;
|
||
|
||
// 3. ИСПРАВЛЕНИЕ: Обязательно регистрируем сообщение в мапе по его серверному ID!
|
||
if (serverId != null) {
|
||
_messageMap[serverId] = updatedMsg;
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Backward compatibility: старый ack мог приходить как message_delivered с temp_id/server_id
|
||
if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) {
|
||
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
|
||
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
|
||
var ts = DateTime.tryParse(
|
||
data['timestamp']?.toString() ?? '',
|
||
)?.add(offset);
|
||
|
||
if (tempId == null) return;
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||
if (idx == -1) return;
|
||
messages[idx] = messages[idx].copyWith(
|
||
id: serverId ?? messages[idx].id,
|
||
createdAt: ts ?? messages[idx].createdAt,
|
||
status: MessageStatus.sent,
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Доставка онлайн (получатель был в сети)
|
||
if (data['type'] == 'message_delivered') {
|
||
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
|
||
var ts = DateTime.tryParse(
|
||
data['timestamp']?.toString() ?? '',
|
||
)?.add(offset);
|
||
if (messageId == null) return;
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_updateMessageInList(
|
||
messageId,
|
||
(m) => m.copyWith(status: MessageStatus.delivered),
|
||
);
|
||
});
|
||
|
||
if (ts != null) {
|
||
try {
|
||
await _localDbService.updateDeliveredAt(messageId, ts);
|
||
} catch (_) {}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data['type'] == 'message_edited') {
|
||
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
|
||
var ts = DateTime.tryParse(
|
||
data['edited_at']?.toString() ?? '',
|
||
)?.add(offset);
|
||
if (messageId == null) return;
|
||
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
if (myPrivKey == null) return;
|
||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey,
|
||
_currentContact.publicKey!,
|
||
);
|
||
final decryptedText = await _cryptoService.decryptMessage(
|
||
data['content'],
|
||
sharedSecret,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
messages = messages.map((m) {
|
||
if (m.id != null && m.id == messageId) {
|
||
return m.copyWith(text: decryptedText, editedAt: ts);
|
||
}
|
||
return m;
|
||
}).toList();
|
||
});
|
||
|
||
try {
|
||
await _localDbService.updateMessageContent(
|
||
messageId,
|
||
data['content'].toString(),
|
||
ts,
|
||
);
|
||
} catch (_) {}
|
||
|
||
// Обновить последнее сообщение в списке контактов
|
||
final contactProvider = context.read<ContactProvider>();
|
||
if (messages.isNotEmpty && messages.last.id == messageId) {
|
||
await contactProvider.updateContactLastMessage(
|
||
widget.contact.id,
|
||
lastMessage: decryptedText,
|
||
lastMessageTime: ts,
|
||
isLastMsgDecrypted: true,
|
||
lastMessageId: messageId,
|
||
isEdited: true,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data['type'] == 'message_deleted') {
|
||
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
|
||
if (messageId == null) return;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
messages.removeWhere((m) => m.id != null && m.id == messageId);
|
||
});
|
||
try {
|
||
await _localDbService.deleteMessage(messageId);
|
||
} catch (_) {}
|
||
|
||
// Обновить последнее сообщение в списке контактов
|
||
final contactProvider = context.read<ContactProvider>();
|
||
if (messages.isEmpty) {
|
||
// Если не осталось сообщений, очистить последнее сообщение
|
||
await contactProvider.updateContactLastMessage(
|
||
widget.contact.id,
|
||
lastMessage: null,
|
||
lastMessageTime: null,
|
||
lastMessageId: null,
|
||
);
|
||
} else {
|
||
// Обновить на предпоследнее сообщение
|
||
await contactProvider.refreshContactLastMessage(widget.contact.id);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data['type'] == 'message_read') {
|
||
final messageId = int.tryParse(data['message_id'].toString());
|
||
if (messageId == null) return;
|
||
var ts = DateTime.tryParse(
|
||
data['timestamp']?.toString() ?? '',
|
||
)?.add(offset);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_updateMessageInList(
|
||
messageId,
|
||
(m) => m.copyWith(status: MessageStatus.read),
|
||
);
|
||
});
|
||
|
||
if (ts != null) {
|
||
try {
|
||
await _localDbService.updateReadAt(messageId, ts);
|
||
} catch (_) {}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data['type'] == 'private_message') {
|
||
print('DEBUG incoming private_message raw: $data');
|
||
setState(() {
|
||
_typingTimer?.cancel();
|
||
_isTyping = false;
|
||
});
|
||
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
|
||
final receiverId = int.tryParse(
|
||
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
|
||
);
|
||
if (senderId == null || receiverId == null) {
|
||
print(
|
||
'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}',
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
|
||
final isFromPartnerToMe =
|
||
senderId == widget.contact.id && receiverId == myId;
|
||
if (isFromPartnerToMe) {
|
||
try {
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
|
||
// 2. Вычисляем общий секрет для расшифровки
|
||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey!,
|
||
widget.contact.publicKey!,
|
||
);
|
||
|
||
// 3. Расшифровываем контент
|
||
final decryptedText = await _cryptoService.decryptMessage(
|
||
data['content'],
|
||
sharedSecret,
|
||
);
|
||
|
||
// 4. Добавляем в список и обновляем экран
|
||
String? encryptedFileKey = data['encrypted_key']?.toString();
|
||
Uint8List? decryptedImageBytes;
|
||
// Lazy load images later
|
||
|
||
if (!mounted) return;
|
||
|
||
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
|
||
if (serverMessageId != null &&
|
||
!_sentReadReceipts.contains(serverMessageId)) {
|
||
Provider.of<SocketService>(
|
||
context,
|
||
listen: false,
|
||
).sendReadReceipt(serverMessageId);
|
||
_sentReadReceipts.add(serverMessageId);
|
||
}
|
||
|
||
final replyToText = await _decryptReplyText(
|
||
data['reply_to_text']?.toString(),
|
||
sharedSecret,
|
||
);
|
||
|
||
setState(() {
|
||
messages.add(
|
||
MessageModel(
|
||
id: int.tryParse(data['id']?.toString() ?? ''),
|
||
text: decryptedText,
|
||
isMe: false,
|
||
senderId: senderId,
|
||
receiverId: myId,
|
||
createdAt: DateTime.parse(data['timestamp']).add(offset),
|
||
status: MessageStatus.delivered,
|
||
replyToId: data['reply_to_id'] == null
|
||
? null
|
||
: int.tryParse(data['reply_to_id'].toString()),
|
||
replyToText: replyToText,
|
||
messageType: _parseMessageTypeString(data['message_type']),
|
||
fileId: data['file_id']?.toString(),
|
||
encryptedFileKey: encryptedFileKey,
|
||
//localFile: decryptedImageBytes,!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||
),
|
||
);
|
||
});
|
||
// Save to local DB with cached image bytes
|
||
try {
|
||
await _localDbService.saveMessages([messages.last]);
|
||
} catch (e) {
|
||
print('Error saving incoming message to DB: $e');
|
||
}
|
||
} catch (e) {
|
||
print("Ошибка расшифровки входящего сообщения: $e");
|
||
}
|
||
} else {
|
||
print(
|
||
"Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате",
|
||
);
|
||
}
|
||
}
|
||
if (data['type'] == 'user_online') {
|
||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||
if (userId == widget.contact.id) {
|
||
setState(() => _isOnline = true);
|
||
}
|
||
}
|
||
if (data['type'] == 'user_offline') {
|
||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||
if (userId == widget.contact.id) {
|
||
setState(() {
|
||
_isOnline = false;
|
||
_lastOnline = DateTime.now();
|
||
});
|
||
|
||
_loadOnlineStatus();
|
||
}
|
||
}
|
||
|
||
if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) {
|
||
if (mounted) {
|
||
setState(() => _isTyping = true);
|
||
|
||
_typingTimer?.cancel();
|
||
_typingTimer = Timer(const Duration(seconds: 4), () {
|
||
if (mounted) setState(() => _isTyping = false);
|
||
});
|
||
}
|
||
}
|
||
if (data['type'] == 'stop_typing' &&
|
||
data['sender_id'] == _currentContact.id) {
|
||
if (mounted) {
|
||
setState(() => _isTyping = false);
|
||
|
||
_typingTimer?.cancel();
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _loadHistory() async {
|
||
DateTime now = DateTime.now();
|
||
|
||
Duration offset = now.timeZoneOffset;
|
||
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove(_notificationLaunchKey);
|
||
try {
|
||
print('[DEBUG] Начало загрузки истории');
|
||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||
myPrivKey!,
|
||
widget.contact.publicKey!,
|
||
);
|
||
print('[DEBUG] Ключи получены');
|
||
final cached = await _localDbService.getChatHistory(
|
||
widget.contact.id,
|
||
myId,
|
||
);
|
||
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
|
||
|
||
_chatSharedSecret = sharedSecret;
|
||
|
||
// Сюда будем складывать успешно расшифрованные локальные сообщения
|
||
// Используем Map<int, MessageModel>, где ключ — id сообщения, для мгновенного поиска
|
||
Map<int, MessageModel> localMessagesMap = {};
|
||
|
||
try {
|
||
for (var msg in cached) {
|
||
final msgId = int.tryParse(msg['id']?.toString() ?? '');
|
||
if (msgId == null) continue;
|
||
|
||
try {
|
||
final decrypted = await _cryptoService.decryptMessage(
|
||
msg['content'],
|
||
sharedSecret,
|
||
);
|
||
|
||
final deliveredAt = msg['delivered_at'] == null
|
||
? null
|
||
: DateTime.tryParse(
|
||
msg['delivered_at'].toString(),
|
||
)?.add(offset);
|
||
final readAt = msg['read_at'] == null
|
||
? null
|
||
: DateTime.tryParse(msg['read_at'].toString())?.add(offset);
|
||
|
||
MessageStatus status = (msg['sender_id'] == myId)
|
||
? MessageStatus.sent
|
||
: MessageStatus.delivered;
|
||
if (msg['sender_id'] == myId) {
|
||
if (readAt != null) {
|
||
status = MessageStatus.read;
|
||
} else if (deliveredAt != null) {
|
||
status = MessageStatus.delivered;
|
||
}
|
||
}
|
||
|
||
localMessagesMap[msgId] = MessageModel(
|
||
id: msgId,
|
||
text: decrypted,
|
||
isMe: msg['sender_id'] == myId,
|
||
senderId: msg['sender_id'],
|
||
receiverId: msg['receiver_id'],
|
||
createdAt: DateTime.parse(msg['timestamp']).add(offset),
|
||
status: status,
|
||
replyToId: msg['reply_to_id'] == null
|
||
? null
|
||
: int.tryParse(msg['reply_to_id'].toString()),
|
||
replyToText: await _decryptReplyText(
|
||
msg['reply_to_text']?.toString(),
|
||
sharedSecret,
|
||
),
|
||
editedAt: msg['edited_at'] != null
|
||
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
|
||
: null,
|
||
messageType: _parseMessageTypeString(msg['message_type']),
|
||
fileId: msg['file_id']?.toString(),
|
||
encryptedFileKey: msg['encrypted_key']?.toString(),
|
||
fileName: msg['file_name']?.toString(),
|
||
fileSize: msg['file_size'] == null
|
||
? null
|
||
: int.tryParse(msg['file_size'].toString()),
|
||
// ВАЖНО: Если в твоем MessageModel при чтении из БД как-то парсится localFile,
|
||
// обязательно пропиши его инициализацию здесь!
|
||
);
|
||
} catch (e) {
|
||
// Обработка ошибки дешифровки локального сообщения...
|
||
final deliveredAt = msg['delivered_at'] == null
|
||
? null
|
||
: DateTime.tryParse(
|
||
msg['delivered_at'].toString(),
|
||
)?.add(offset);
|
||
final readAt = msg['read_at'] == null
|
||
? null
|
||
: DateTime.tryParse(msg['read_at'].toString())?.add(offset);
|
||
|
||
MessageStatus status = (msg['sender_id'] == myId)
|
||
? MessageStatus.sent
|
||
: MessageStatus.delivered;
|
||
if (msg['sender_id'] == myId) {
|
||
if (readAt != null) {
|
||
status = MessageStatus.read;
|
||
} else if (deliveredAt != null) {
|
||
status = MessageStatus.delivered;
|
||
}
|
||
}
|
||
localMessagesMap[msgId] = MessageModel(
|
||
id: msgId,
|
||
text: msg['content'],
|
||
isMe: msg['sender_id'] == myId,
|
||
senderId: msg['sender_id'],
|
||
receiverId: msg['receiver_id'],
|
||
createdAt: DateTime.parse(msg['timestamp']).add(offset),
|
||
status: status,
|
||
replyToId: msg['reply_to_id'] == null
|
||
? null
|
||
: int.tryParse(msg['reply_to_id'].toString()),
|
||
replyToText: await _decryptReplyText(
|
||
msg['reply_to_text']?.toString(),
|
||
sharedSecret,
|
||
),
|
||
editedAt: msg['edited_at'] != null
|
||
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
|
||
: null,
|
||
messageType: _parseMessageTypeString(msg['message_type']),
|
||
fileId: msg['file_id']?.toString(),
|
||
encryptedFileKey: msg['encrypted_key']?.toString(),
|
||
fileName: msg['file_name']?.toString(),
|
||
fileSize: msg['file_size'] == null
|
||
? null
|
||
: int.tryParse(msg['file_size'].toString()),
|
||
);
|
||
//print('Ошибка дешифровки сообщения: $e');
|
||
}
|
||
}
|
||
|
||
if (localMessagesMap.isNotEmpty) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
messages = localMessagesMap.values.toList();
|
||
_isKeyLoading = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
print(e);
|
||
}
|
||
|
||
final history = await apiService.getChatHistory(widget.contact.id);
|
||
print('[DEBUG] Загружена история из API: ${history.length}');
|
||
|
||
final alreadyReadIncomingMessageIds = <int>{};
|
||
List<MessageModel> loadedMessages = [];
|
||
|
||
for (var msg in history) {
|
||
print(msg);
|
||
final msgId = int.tryParse(msg['id']?.toString() ?? '');
|
||
if (msgId != null &&
|
||
msg['sender_id'] != myId &&
|
||
msg['read_at'] != null) {
|
||
alreadyReadIncomingMessageIds.add(msgId);
|
||
}
|
||
|
||
final decrypted = await _cryptoService.decryptMessage(
|
||
msg['content'],
|
||
sharedSecret,
|
||
);
|
||
|
||
final deliveredAt = msg['delivered_at'] == null
|
||
? null
|
||
: DateTime.tryParse(msg['delivered_at'].toString())?.add(offset);
|
||
final readAt = msg['read_at'] == null
|
||
? null
|
||
: DateTime.tryParse(msg['read_at'].toString())?.add(offset);
|
||
|
||
MessageStatus status = (msg['sender_id'] == myId)
|
||
? MessageStatus.sent
|
||
: MessageStatus.delivered;
|
||
if (msg['sender_id'] == myId) {
|
||
if (readAt != null) {
|
||
status = MessageStatus.read;
|
||
} else if (deliveredAt != null) {
|
||
status = MessageStatus.delivered;
|
||
}
|
||
}
|
||
|
||
// КРИТИЧЕСКИЙ ФИКС: Ищем, есть ли это сообщение уже в локальном кэше
|
||
File? existingLocalFile;
|
||
if (msgId != null && localMessagesMap.containsKey(msgId)) {
|
||
existingLocalFile = localMessagesMap[msgId]?.localFile;
|
||
}
|
||
|
||
loadedMessages.insert(
|
||
0,
|
||
MessageModel(
|
||
id: msgId,
|
||
text: decrypted,
|
||
isMe: msg['sender_id'] == myId,
|
||
senderId: msg['sender_id'],
|
||
receiverId: msg['receiver_id'],
|
||
createdAt: DateTime.parse(msg['timestamp']).add(offset),
|
||
status: status,
|
||
replyToId: msg['reply_to_id'] == null
|
||
? null
|
||
: int.tryParse(msg['reply_to_id'].toString()),
|
||
replyToText: await _decryptReplyText(
|
||
msg['reply_to_text']?.toString(),
|
||
sharedSecret,
|
||
),
|
||
editedAt: msg['edited_at'] != null
|
||
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
|
||
: null,
|
||
messageType: _parseMessageTypeString(msg['message_type']),
|
||
fileId: msg['file_id']?.toString(),
|
||
encryptedFileKey: msg['encrypted_key']?.toString(),
|
||
fileName: msg['file_name']?.toString(),
|
||
fileSize: msg['file_size'] == null
|
||
? null
|
||
: int.tryParse(msg['file_size'].toString()),
|
||
// СОХРАНЯЕМ ФАЙЛ: Если он уже был скачан, мы не даем ему стать null!
|
||
localFile: existingLocalFile,
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД');
|
||
await _localDbService.saveMessages(loadedMessages);
|
||
print('[DEBUG] Сообщения сохранени в локальную бд');
|
||
} catch (e) {
|
||
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
|
||
}
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
messages = loadedMessages;
|
||
_isKeyLoading = false;
|
||
});
|
||
|
||
// Отправка read_receipt...
|
||
for (final m in loadedMessages) {
|
||
if (m.isMe) continue;
|
||
final id = m.id;
|
||
if (id == null) continue;
|
||
if (alreadyReadIncomingMessageIds.contains(id)) continue;
|
||
if (_sentReadReceipts.contains(id)) continue;
|
||
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
|
||
_sentReadReceipts.add(id);
|
||
}
|
||
} catch (e) {
|
||
print("Ошибка загрузки истории: $e");
|
||
if (!mounted) return;
|
||
setState(() => _isKeyLoading = false);
|
||
}
|
||
}
|
||
|
||
final Map<String, StreamSubscription<List<int>>> _activeDownloads = {};
|
||
|
||
Future<void> _stopFileLoading(MessageModel message) async {
|
||
if (message.fileId == null) return;
|
||
|
||
final subscription = _activeDownloads.remove(message.fileId!);
|
||
if (subscription != null) {
|
||
await subscription.cancel();
|
||
debugPrint("Загрузка файла ${message.fileId} отменена пользователем.");
|
||
|
||
if (message.localFile != null && message.localFile!.existsSync()) {
|
||
try {
|
||
await message.localFile!.delete();
|
||
debugPrint(
|
||
"Локальный файл успешно удален с диска: ${message.fileId}",
|
||
);
|
||
} catch (e) {
|
||
debugPrint("Ошибка при физическом удалении файла с диска: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_messageProgressNotifiers[message.fileId!]?.value = null;
|
||
});
|
||
}
|
||
}
|
||
|
||
int _findMessageIndex(MessageModel message) {
|
||
return messages.indexWhere((m) {
|
||
if (message.id != null && m.id == message.id) return true;
|
||
if (message.tempId != null && m.tempId == message.tempId) return true;
|
||
if (message.fileId != null && m.fileId == message.fileId) return true;
|
||
return false;
|
||
});
|
||
}
|
||
|
||
Future<void> _fetchFileSizeIfNeeded(MessageModel message) async {
|
||
if (message.fileId == null) return;
|
||
|
||
try {
|
||
debugPrint("Фоновый запрос размера для файла: ${message.fileId}");
|
||
final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName(
|
||
message.fileId!,
|
||
);
|
||
|
||
if (remoteSize != null && remoteSize > 0 && mounted) {
|
||
if (message.fileSize != null && message.fileSize! > 0) {
|
||
if (message.fileSize != remoteSize) {
|
||
debugPrint(
|
||
"Размер файла на сервере (${remoteSize} байт) отличается от локального (${message.fileSize} байт). Локальный файл признан недействительным.",
|
||
);
|
||
if (message.localFile != null) {
|
||
message.localFile!.delete().catchError((e) {
|
||
debugPrint(
|
||
"Ошибка удаления недействительного локального файла: $e",
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
print(
|
||
"Получен размер файла из сети: $remoteSize байт. Обновляем модель сообщения.",
|
||
);
|
||
setState(() {
|
||
final index = _findMessageIndex(message);
|
||
if (index != -1) {
|
||
messages[index] = messages[index].copyWith(
|
||
fileSize: remoteSize,
|
||
fileName: filename,
|
||
);
|
||
}
|
||
});
|
||
// Опционально: сохраняем в локальную БД, чтобы не дергать сеть в следующий раз
|
||
// await LocalDbService.updateMessageFileSize(message.id, remoteSize);
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка фонового получения размера файла: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> _ensureFileDecrypted(
|
||
MessageModel message, {
|
||
bool dontLoad = false,
|
||
}) async {
|
||
MessageModel msg = message;
|
||
if (_activeDownloads.containsKey(msg.fileId)) return;
|
||
if (msg.fileId == null || _chatSharedSecret == null) return;
|
||
|
||
final directory = await getApplicationDocumentsDirectory();
|
||
final decFile = File('${directory.path}/dec_${msg.fileId}');
|
||
final sharedPrefs = await SharedPreferences.getInstance();
|
||
final String sizeKey = 'valid_dec_size_${msg.fileId}';
|
||
|
||
// --- НАЧАЛО ИСПРАВЛЕННОГО БЛОКА ВАЛИДАЦИИ РАЗМЕРА ---
|
||
if (await decFile.exists()) {
|
||
final localLength = await decFile.length();
|
||
// Проверяем, есть ли у нас сохраненный эталонный размер именно ДЛЯ РАСШИФРОВАННОГО файла
|
||
final int? expectedDecryptedSize = sharedPrefs.getInt(sizeKey);
|
||
|
||
if (expectedDecryptedSize != null) {
|
||
if (localLength != expectedDecryptedSize) {
|
||
debugPrint(
|
||
"Размер локального файла ($localLength байт) не совпадает с сохраненным эталоном ($expectedDecryptedSize байт). Файл поврежден. Удаляем.",
|
||
);
|
||
await decFile.delete().catchError(
|
||
(e) => debugPrint("Ошибка удаления: $e"),
|
||
);
|
||
await sharedPrefs.remove(sizeKey);
|
||
} else {
|
||
debugPrint(
|
||
"Локальный файл успешно прошел валидацию по сохраненному размеру.",
|
||
);
|
||
if (mounted) {
|
||
setState(() {
|
||
final index = _findMessageIndex(msg);
|
||
if (index != -1) {
|
||
messages[index] = messages[index].copyWith(localFile: decFile);
|
||
}
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
} else {
|
||
await decFile.delete().catchError(
|
||
(e) => debugPrint("Удаление пустого файла: $e"),
|
||
);
|
||
}
|
||
}
|
||
// --- КОНЕЦ БЛОКА ВАЛИДАЦИИ ---
|
||
|
||
if (dontLoad) return;
|
||
|
||
debugPrint("=== ОТЛАДКА СКАЧИВАНИЯ ===");
|
||
debugPrint("ID файла: ${msg.fileId}");
|
||
debugPrint("Ключ файла (Base64): ${msg.encryptedFileKey}");
|
||
debugPrint("Общий ключ чата готов?: ${_chatSharedSecret != null}");
|
||
|
||
// Получаем или инициализируем Notifier прогресса для этого файла
|
||
final bool createdNewNotifier =
|
||
_messageProgressNotifiers[msg.fileId!] == null;
|
||
_messageProgressNotifiers[msg.fileId!] ??= ValueNotifier<double?>(0.0);
|
||
if (createdNewNotifier && mounted) {
|
||
setState(() {});
|
||
}
|
||
_messageProgressNotifiers[msg.fileId!]!.value = 0.0;
|
||
|
||
// Если размера файла в модели всё ещё нет, запрашиваем его у сервера через HEAD-запрос
|
||
if (msg.fileSize == null || msg.fileSize == 0) {
|
||
debugPrint("Размер файла в модели пуст. Делаем HEAD запрос...");
|
||
final (remoteSize, filename) = await apiService.getRemoteFileSizeAndName(
|
||
msg.fileId!,
|
||
);
|
||
if (remoteSize != null && remoteSize > 0) {
|
||
debugPrint("Успешно получен размер файла из сети: $remoteSize байт");
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
final index = _findMessageIndex(msg);
|
||
if (index != -1) {
|
||
// Берем элемент ИЗ МАССИВА сообщений (там данные актуальнее)
|
||
messages[index] = messages[index].copyWith(
|
||
fileSize: remoteSize,
|
||
fileName: filename,
|
||
);
|
||
// Обновляем локальную переменную msg для дальнейшей корректной работы
|
||
msg = messages[index];
|
||
} else {
|
||
msg = msg.copyWith(fileSize: remoteSize, fileName: filename);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
final response = await apiService.downloadFileAsStream(msg.fileId!);
|
||
final networkStream = response.$1;
|
||
final fileName = response.$2;
|
||
|
||
Stream<List<int>> decryptedStream = _cryptoService.decryptFileStream(
|
||
networkStream,
|
||
_chatSharedSecret!,
|
||
msg.encryptedFileKey!,
|
||
totalBytes: msg.fileSize,
|
||
onProgress: (received, total) {
|
||
if (total != -1) {
|
||
double progress = received / total;
|
||
if (progress > 1.0) progress = 1.0;
|
||
_messageProgressNotifiers[msg.fileId!]?.value = progress;
|
||
}
|
||
},
|
||
);
|
||
|
||
final iosink = decFile.openWrite();
|
||
final Completer<void> downloadCompleter = Completer<void>();
|
||
|
||
final subscription = decryptedStream.listen(
|
||
(chunk) {
|
||
iosink.add(chunk);
|
||
},
|
||
onError: (error) async {
|
||
debugPrint("Ошибка внутри криптострима: $error");
|
||
await iosink.close();
|
||
if (!downloadCompleter.isCompleted) downloadCompleter.complete();
|
||
|
||
await decFile.delete();
|
||
},
|
||
onDone: () async {
|
||
await iosink.close();
|
||
|
||
// --- СОХРАНЕНИЕ ТЕКУЩЕГО РАЗМЕРА ПОСЛЕ УСПЕШНОЙ ЗАГРУЗКИ ---
|
||
final finalLocalLength = await decFile.length();
|
||
if (finalLocalLength > 0) {
|
||
// Запоминаем, сколько байт весит ЧИСТЫЙ расшифрованный файл
|
||
await sharedPrefs.setInt(sizeKey, finalLocalLength);
|
||
debugPrint(
|
||
"Файл успешно скачан и расшифрован. Сохранен валидный размер: $finalLocalLength байт.",
|
||
);
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
final updatedMsg = msg.copyWith(
|
||
localFile: decFile,
|
||
fileName: fileName,
|
||
);
|
||
final index = _findMessageIndex(msg);
|
||
if (index != -1) {
|
||
messages[index] = updatedMsg;
|
||
}
|
||
});
|
||
}
|
||
|
||
try {
|
||
_activeDownloads.remove(msg.fileId)?.cancel();
|
||
if (mounted) {
|
||
setState(() {
|
||
_messageProgressNotifiers[msg.fileId!]?.value = null;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка при очистке ресурсов загрузки: $e");
|
||
}
|
||
if (!downloadCompleter.isCompleted) downloadCompleter.complete();
|
||
},
|
||
cancelOnError: true,
|
||
);
|
||
|
||
_activeDownloads[msg.fileId!] = subscription;
|
||
|
||
try {
|
||
await downloadCompleter.future;
|
||
} catch (e) {
|
||
debugPrint("Загрузка прервана или завершилась с ошибкой: $e");
|
||
if (await decFile.exists()) {
|
||
await decFile.delete().catchError(
|
||
(e) => debugPrint("Ошибка очистки файла: $e"),
|
||
);
|
||
}
|
||
await sharedPrefs.remove(sizeKey); // Чистим ключ при ошибке
|
||
}
|
||
}
|
||
|
||
Future<void> _updateScrollButtonVisibility() async {
|
||
if (!mounted) return;
|
||
final shouldShow =
|
||
_scrollController.hasClients && _scrollController.offset > 100;
|
||
if (shouldShow != _showScrollToEnd) {
|
||
setState(() {
|
||
_showScrollToEnd = shouldShow;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _scrollToBottom() async {
|
||
if (!_scrollController.hasClients) return;
|
||
await _scrollController.animateTo(
|
||
0,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
|
||
Future<void> _scrollToMessage(int? messageId) async {
|
||
if (messageId == null) return;
|
||
final itemKey = _messageKeys[messageId];
|
||
if (itemKey?.currentContext == null) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Сообщение не найдено для перехода.'),
|
||
behavior: SnackBarBehavior.floating,
|
||
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
await Scrollable.ensureVisible(
|
||
itemKey!.currentContext!,
|
||
duration: const Duration(milliseconds: 300),
|
||
alignment: 0.1,
|
||
curve: Curves.easeInOut,
|
||
);
|
||
}
|
||
|
||
void _openFullScreenMedia(MessageModel msg) async {
|
||
print('Открытие медиа');
|
||
if (msg.fileId == null) return;
|
||
|
||
// Получаем доступ к папке документов приложения
|
||
final directory = await getApplicationDocumentsDirectory();
|
||
|
||
final decPath = '${directory.path}/dec_${msg.fileId}';
|
||
|
||
final decFile = File(decPath);
|
||
|
||
// 1. ПРОВЕРКА КЭША: если расшифрованный файл уже есть, открываем мгновенно
|
||
if (await decFile.exists() && await decFile.length() > 0) {
|
||
_navigateToViewer(decPath, msg);
|
||
return;
|
||
}
|
||
|
||
// Проверяем наличие ключей перед началом долгого процесса
|
||
if (_chatSharedSecret == null || msg.encryptedFileKey == null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("Ошибка: Ключи шифрования недоступны")),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Показываем индикатор загрузки (диалог или крутилку)
|
||
_showLoadingDialog();
|
||
|
||
try {
|
||
_ensureFileDecrypted(msg);
|
||
final decFile = File(decPath);
|
||
|
||
if (await decFile.exists() && await decFile.length() > 0) {
|
||
_navigateToViewer(decPath, msg);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Ошибка при обработке и дешифрации файла: $e');
|
||
}
|
||
|
||
// Если что-то пошло не так (ошибка сети, ошибка дешифрации)
|
||
if (mounted) {
|
||
// Закрываем _showLoadingDialog(), если он все еще открыт
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.of(context, rootNavigator: true).pop();
|
||
});
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("Не удалось загрузить или расшифровать файл"),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _showLoadingDialog() {
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => const Center(
|
||
child: Card(
|
||
child: Padding(
|
||
padding: EdgeInsets.all(20.0),
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _navigateToViewer(String path, MessageModel msg) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => MediaViewer(
|
||
items: [
|
||
MediaItem(
|
||
path: path,
|
||
isVideo:
|
||
msg.messageType == MessageType.video ||
|
||
msg.messageType == 'video',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
Future<void> _saveMediaToGallery(MessageModel msg) async {
|
||
final originalFile = msg.localFile;
|
||
if (originalFile == null || !originalFile.existsSync()) return;
|
||
|
||
File? tempFile;
|
||
try {
|
||
// 1. Проверка доступности файла
|
||
// Пытаемся открыть файл на чтение. Если файл занят, это выбросит ошибку,
|
||
// и мы подождем перед тем, как копировать.
|
||
bool isFileReady = false;
|
||
int attempts = 0;
|
||
while (!isFileReady && attempts < 5) {
|
||
try {
|
||
final raf = await originalFile.open(mode: FileMode.read);
|
||
await raf.close();
|
||
isFileReady = true;
|
||
} catch (e) {
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
attempts++;
|
||
}
|
||
}
|
||
|
||
// 2. Копирование с правильным именем
|
||
String ext = p.extension(msg.fileName ?? '');
|
||
if (ext.isEmpty)
|
||
ext = (msg.messageType == MessageType.video) ? '.mp4' : '.jpg';
|
||
final String tempName =
|
||
'save_${DateTime.now().millisecondsSinceEpoch}$ext';
|
||
final Directory tempDir = await getTemporaryDirectory();
|
||
tempFile = await originalFile.copy(p.join(tempDir.path, tempName));
|
||
|
||
// 3. Сохранение
|
||
if (msg.messageType == MessageType.video ||
|
||
msg.messageType == MessageType.videoNote) {
|
||
await Gal.putVideo(tempFile.path);
|
||
} else {
|
||
await Gal.putImage(tempFile.path);
|
||
}
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Сохранено в галерею')));
|
||
} catch (e) {
|
||
debugPrint("Ошибка: $e");
|
||
// Если ошибка "битый файл", возможно, Gal еще держит временный файл
|
||
} finally {
|
||
// ВАЖНО: Удаляем только если файл существует
|
||
if (tempFile != null && await tempFile.exists()) {
|
||
try {
|
||
await tempFile.delete();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<Uint8List?> _downloadAndDecryptImage(
|
||
String fileId,
|
||
String encryptedFileKey,
|
||
SecretKey sharedSecret, {
|
||
void Function(int received, int total)? onProgress,
|
||
}) async {
|
||
try {
|
||
print('DEBUG downloadMedia(fileId=$fileId)');
|
||
final bytes = await apiService.downloadMedia(
|
||
fileId,
|
||
onProgress: onProgress,
|
||
);
|
||
if (bytes == null) {
|
||
print('DEBUG downloadMedia returned null for fileId=$fileId');
|
||
return null;
|
||
}
|
||
print(
|
||
'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId',
|
||
);
|
||
final result = await _cryptoService.decryptMedia(
|
||
bytes,
|
||
encryptedFileKey,
|
||
sharedSecret,
|
||
);
|
||
print(
|
||
'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId',
|
||
);
|
||
return result;
|
||
} catch (e) {
|
||
print('Ошибка загрузки и дешифровки медиа: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<String?> _decryptReplyText(
|
||
String? encryptedReplyText,
|
||
SecretKey sharedSecret,
|
||
) async {
|
||
if (encryptedReplyText == null) return null;
|
||
try {
|
||
return await _cryptoService.decryptMessage(
|
||
encryptedReplyText,
|
||
sharedSecret,
|
||
);
|
||
} catch (_) {
|
||
return encryptedReplyText;
|
||
}
|
||
}
|
||
|
||
Widget _buildPreviewIcon() {
|
||
switch (_pendingMessageType) {
|
||
case MessageType.image:
|
||
return _previewBytes != null
|
||
? Image.memory(_previewBytes!, fit: BoxFit.cover)
|
||
: Container(
|
||
color: Colors.grey.withOpacity(0.2),
|
||
child: const Icon(Icons.image, color: Colors.grey),
|
||
);
|
||
|
||
case MessageType.video:
|
||
return Container(
|
||
color: Colors.black,
|
||
child: const Icon(Icons.videocam, color: Colors.white),
|
||
);
|
||
|
||
case MessageType.file:
|
||
default:
|
||
return Container(
|
||
color: Colors.blue.withOpacity(0.1),
|
||
child: const Icon(Icons.insert_drive_file, color: Colors.blue),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class TypingIndicator extends StatefulWidget {
|
||
const TypingIndicator({super.key});
|
||
|
||
@override
|
||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||
}
|
||
|
||
class _TypingIndicatorState extends State<TypingIndicator>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 600),
|
||
)..repeat(reverse: true); // Анимация идет туда-сюда
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Widget _buildDot(int index) {
|
||
return AnimatedBuilder(
|
||
animation: _controller,
|
||
builder: (context, child) {
|
||
// Рассчитываем смещение: только отрицательные значения (вверх)
|
||
double delay = index * 0.5; // Увеличили задержку для плавности
|
||
double shift = sin((_controller.value * 2 * pi) + delay);
|
||
|
||
// Используем clamp или abs, чтобы точка не уходила ниже базовой линии
|
||
double yOffset = (shift < 0 ? shift : 0) * 4;
|
||
|
||
return SizedBox(
|
||
width: 4, // Фиксированная зона для одной точки
|
||
height: 5, // Фиксированная высота зоны анимации
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter, // Точка всегда прижата к низу
|
||
child: Container(
|
||
width: 2,
|
||
height: 2,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.greenAccent,
|
||
shape: BoxShape.circle,
|
||
),
|
||
transform: Matrix4.translationValues(0, yOffset, 0),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 12,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: List.generate(3, (index) => _buildDot(index)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
typedef _OnWidgetSizeChange = void Function(Size size);
|
||
|
||
class _MeasureSize extends SingleChildRenderObjectWidget {
|
||
final _OnWidgetSizeChange onChange;
|
||
|
||
const _MeasureSize({super.key, required super.child, required this.onChange});
|
||
|
||
@override
|
||
RenderObject createRenderObject(BuildContext context) {
|
||
return _MeasureSizeRenderObject(onChange: onChange);
|
||
}
|
||
|
||
@override
|
||
void updateRenderObject(
|
||
BuildContext context,
|
||
_MeasureSizeRenderObject renderObject,
|
||
) {
|
||
renderObject.onChange = onChange;
|
||
}
|
||
}
|
||
|
||
class _MeasureSizeRenderObject extends RenderProxyBox {
|
||
_OnWidgetSizeChange onChange;
|
||
Size? _oldSize;
|
||
|
||
_MeasureSizeRenderObject({required this.onChange, RenderBox? child})
|
||
: super(child);
|
||
|
||
@override
|
||
void performLayout() {
|
||
super.performLayout();
|
||
|
||
final newSize = size;
|
||
if (_oldSize != newSize) {
|
||
_oldSize = newSize;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
onChange(newSize);
|
||
});
|
||
}
|
||
}
|
||
}
|