2423 lines
78 KiB
Dart
2423 lines
78 KiB
Dart
import 'package:flutter/gestures.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import '/data/models/message_model.dart';
|
||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '/core/theme_manager.dart';
|
||
import 'dart:math';
|
||
import 'dart:io';
|
||
import 'dart:async';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:open_filex/open_filex.dart';
|
||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import 'dart:ui' as ui;
|
||
import 'dart:math' as math;
|
||
import 'package:video_player/video_player.dart';
|
||
import 'package:audioplayers/audioplayers.dart';
|
||
|
||
class MessageBubble extends StatefulWidget {
|
||
final MessageModel message;
|
||
final VoidCallback? onTap;
|
||
final VoidCallback? onReplyTap;
|
||
final VoidCallback? onForwardTap;
|
||
final VoidCallback? onImageTap;
|
||
final VoidCallback? onReplyToTap;
|
||
final VoidCallback? onEditTap;
|
||
final VoidCallback? onDeleteTap;
|
||
|
||
final Future<void>? Function(MessageModel)? onDownloadRequested;
|
||
final Future<void>? Function(MessageModel)? onDownloadRequestedWithoutLoad;
|
||
final Future<void>? Function(MessageModel)? onDownloadStoped;
|
||
final Future<void>? Function(MessageModel)? onShowInExplorer;
|
||
final Future<void>? Function(MessageModel)? onSaveToGallery;
|
||
final Future<void>? Function(MessageModel)? onSaveToDownloads;
|
||
final bool autoLoadMedia;
|
||
final ValueListenable<double?>? downloadProgress;
|
||
|
||
const MessageBubble({
|
||
super.key,
|
||
required this.message,
|
||
this.onTap,
|
||
this.onReplyTap,
|
||
this.onForwardTap,
|
||
this.onImageTap,
|
||
this.onReplyToTap,
|
||
this.onEditTap,
|
||
this.onDeleteTap,
|
||
this.onDownloadRequested,
|
||
this.onDownloadRequestedWithoutLoad,
|
||
this.onDownloadStoped,
|
||
this.onShowInExplorer,
|
||
this.onSaveToGallery,
|
||
this.onSaveToDownloads,
|
||
this.autoLoadMedia = true,
|
||
this.downloadProgress,
|
||
});
|
||
|
||
@override
|
||
State<MessageBubble> createState() => _MessageBubbleState();
|
||
}
|
||
|
||
class _MessageBubbleState extends State<MessageBubble> {
|
||
bool _isMediaLoading = false;
|
||
bool _requiresManualLoad = false;
|
||
int _calculatedFileSize = 0;
|
||
final int _autoDownloadLimit = 20 * 1024 * 1024; // 20 MB
|
||
int minHeight = 0;
|
||
int minWidth = 0;
|
||
|
||
ValueListenable<double?>? _downloadProgressNotifier;
|
||
Future<void>? _delayFuture;
|
||
|
||
TextSelection? _currentSelection;
|
||
|
||
final MediaCacheManager _mediaCache = MediaCacheManager();
|
||
|
||
bool get _hasExistingLocalFile {
|
||
final localFile = widget.message.localFile;
|
||
return localFile != null && localFile.existsSync();
|
||
}
|
||
|
||
bool get _isDownloading {
|
||
return !_hasExistingLocalFile &&
|
||
(_isMediaLoading || widget.downloadProgress?.value != null);
|
||
}
|
||
|
||
String get _messageKeyId =>
|
||
(widget.message.id ?? widget.message.tempId ?? widget.message.hashCode)
|
||
.toString();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_syncDownloadProgressListener();
|
||
_resolveFileSize();
|
||
_generateVideoThumbnail();
|
||
|
||
if (widget.message.messageType == MessageType.image &&
|
||
_hasExistingLocalFile) {
|
||
_loadImageDimensionsFromFile(widget.message.localFile!);
|
||
}
|
||
|
||
final isMedia =
|
||
widget.message.messageType == MessageType.image ||
|
||
widget.message.messageType == MessageType.video ||
|
||
widget.message.messageType == MessageType.file ||
|
||
widget.message.messageType == MessageType.videoNote ||
|
||
widget.message.messageType == MessageType.voiceNote;
|
||
|
||
if (isMedia) {
|
||
final type = widget.message.messageType;
|
||
final bool isNote =
|
||
type == MessageType.voiceNote || type == MessageType.videoNote;
|
||
|
||
if (isNote) {
|
||
_requiresManualLoad = false;
|
||
} else if (_calculatedFileSize > 0) {
|
||
_requiresManualLoad =
|
||
!widget.autoLoadMedia || _calculatedFileSize > _autoDownloadLimit;
|
||
} else {
|
||
// Если размер ещё не определён, опираемся на флаг авто-скачивания.
|
||
// Если autoLoadMedia == true, разрешаем автозагрузку и попробуем скачать.
|
||
_requiresManualLoad = !widget.autoLoadMedia;
|
||
}
|
||
|
||
if (!_hasExistingLocalFile) {
|
||
widget.onDownloadRequestedWithoutLoad?.call(widget.message);
|
||
|
||
if (!_requiresManualLoad) {
|
||
_checkAutoDownload();
|
||
} else if (isNote) {
|
||
_startDownload();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (widget.message.messageType == MessageType.videoNote) {
|
||
setState(() {
|
||
minHeight = 160;
|
||
minWidth = 160;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<ui.Image> getImageDimensions(File imageFile) async {
|
||
final bytes = await imageFile.readAsBytes();
|
||
final codec = await ui.instantiateImageCodec(bytes);
|
||
final frameInfo = await codec.getNextFrame();
|
||
return frameInfo.image;
|
||
}
|
||
|
||
void _loadImageDimensionsFromFile(File file) async {
|
||
if (!file.existsSync()) return;
|
||
try {
|
||
final cached = _mediaCache.getDimensions(_messageKeyId);
|
||
if (cached != null) {
|
||
if (mounted) {
|
||
setState(() {
|
||
minWidth = cached.width.toInt();
|
||
minHeight = cached.height.toInt();
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
final img = await getImageDimensions(file);
|
||
_mediaCache.saveDimensions(_messageKeyId, img.width, img.height);
|
||
if (mounted) {
|
||
setState(() {
|
||
minWidth = img.width;
|
||
minHeight = img.height;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint("Ошибка чтения размеров картинки: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> _generateVideoThumbnail() async {
|
||
if (widget.message.messageType != MessageType.video) return;
|
||
|
||
_delayFuture = Future.delayed(const Duration(milliseconds: 200)).then((
|
||
_,
|
||
) async {
|
||
if (!mounted ||
|
||
widget.message.localFile == null ||
|
||
!_hasExistingLocalFile)
|
||
return;
|
||
|
||
if (_mediaCache.getDimensions(_messageKeyId) != null &&
|
||
_mediaCache.getThumbnailPath(_messageKeyId) != null)
|
||
return;
|
||
|
||
try {
|
||
final String videoPath = widget.message.localFile!.path;
|
||
final String timestamp = DateTime.now().millisecondsSinceEpoch
|
||
.toString();
|
||
final targetDirectory = Directory(
|
||
'${Directory.systemTemp.path}/thumbs/$timestamp',
|
||
);
|
||
await targetDirectory.create(recursive: true);
|
||
|
||
String? thumbPath;
|
||
|
||
if (Platform.isWindows) {
|
||
// --- Логика для Windows: Прямое обращение к ffmpeg.exe ---
|
||
final String outputPath = '${targetDirectory.path}/thumb.jpg';
|
||
String executable = 'ffmpeg';
|
||
|
||
// Поиск локального ffmpeg.exe (как в вашем методе сжатия)
|
||
final localFfmpeg = File(
|
||
p.join(p.dirname(Platform.resolvedExecutable), 'ffmpeg.exe'),
|
||
);
|
||
if (await localFfmpeg.exists()) {
|
||
executable = localFfmpeg.path;
|
||
}
|
||
|
||
final List<String> args = [
|
||
'-i',
|
||
videoPath,
|
||
'-ss',
|
||
'00:00:01',
|
||
'-vframes',
|
||
'1',
|
||
'-vf',
|
||
'scale=400:-1',
|
||
outputPath,
|
||
];
|
||
|
||
final result = await Process.run(executable, args);
|
||
if (result.exitCode == 0) {
|
||
thumbPath = outputPath;
|
||
} else {
|
||
debugPrint('Ошибка FFmpeg Windows: ${result.stderr}');
|
||
}
|
||
} else {
|
||
// --- Логика для мобильных платформ ---
|
||
thumbPath = await VideoThumbnail.thumbnailFile(
|
||
video: videoPath,
|
||
thumbnailPath: targetDirectory.path,
|
||
imageFormat: ImageFormat.JPEG,
|
||
maxWidth: 400,
|
||
quality: 75,
|
||
);
|
||
}
|
||
|
||
if (thumbPath != null && await File(thumbPath).exists()) {
|
||
File file = File(thumbPath);
|
||
ui.Image img = await getImageDimensions(file);
|
||
|
||
_mediaCache.saveDimensions(_messageKeyId, img.width, img.height);
|
||
_mediaCache.saveThumbnailPath(_messageKeyId, thumbPath);
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
minHeight = img.height;
|
||
minWidth = img.width;
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Ошибка генерации превью: $e');
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant MessageBubble oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
_syncDownloadProgressListener();
|
||
|
||
final bool fileExistsNow =
|
||
widget.message.localFile != null &&
|
||
widget.message.localFile!.existsSync();
|
||
final bool sizeChanged =
|
||
widget.message.fileSize != oldWidget.message.fileSize;
|
||
final bool _lastKnownFileExists =
|
||
oldWidget.message.localFile != null &&
|
||
oldWidget.message.localFile!.existsSync();
|
||
|
||
final bool becameReady = fileExistsNow && !_lastKnownFileExists;
|
||
|
||
// 1. Если файл скачался или уже существует
|
||
if (widget.message.localFile != null && fileExistsNow) {
|
||
if (_requiresManualLoad || _isMediaLoading || becameReady) {
|
||
setState(() {
|
||
_requiresManualLoad = false;
|
||
_isMediaLoading = false;
|
||
});
|
||
}
|
||
|
||
if (becameReady) {
|
||
_resolveFileSize();
|
||
if (widget.message.messageType == MessageType.image) {
|
||
_loadImageDimensionsFromFile(widget.message.localFile!);
|
||
} else {
|
||
_generateVideoThumbnail();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. Если файла нет, но изменились важные поля или статус (для обновления UI)
|
||
final bool statusChanged =
|
||
widget.message.status != oldWidget.message.status;
|
||
final bool textChanged = widget.message.text != oldWidget.message.text;
|
||
final bool fileChanged =
|
||
widget.message.localFile != oldWidget.message.localFile;
|
||
if (statusChanged || textChanged || fileChanged) {
|
||
_resolveFileSize();
|
||
setState(() {});
|
||
}
|
||
|
||
// 3. ИСПРАВЛЕНИЕ: Отслеживаем изменение размера для еще НЕ скачанных файлов
|
||
if (!fileExistsNow && sizeChanged) {
|
||
_resolveFileSize();
|
||
|
||
final oldSize = oldWidget.message.fileSize ?? 0;
|
||
final newSize = widget.message.fileSize ?? 0;
|
||
final type = widget.message.messageType;
|
||
final bool isNote =
|
||
type == MessageType.voiceNote || type == MessageType.videoNote;
|
||
|
||
// Если размер обновился с 0 до реального значения
|
||
if (oldSize == 0 && newSize > 0) {
|
||
setState(() {
|
||
_requiresManualLoad = isNote
|
||
? false
|
||
: !widget.autoLoadMedia || newSize > _autoDownloadLimit;
|
||
});
|
||
|
||
if (!_requiresManualLoad || isNote) {
|
||
_checkAutoDownload();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void _resolveFileSize() {
|
||
if (widget.message.fileSize != null && widget.message.fileSize! > 0) {
|
||
_calculatedFileSize = widget.message.fileSize!;
|
||
} else if (widget.message.localFile != null &&
|
||
widget.message.localFile!.existsSync()) {
|
||
_calculatedFileSize = widget.message.localFile!.lengthSync();
|
||
} else {
|
||
_calculatedFileSize = 0;
|
||
}
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
void _syncDownloadProgressListener() {
|
||
if (_downloadProgressNotifier == widget.downloadProgress) return;
|
||
_downloadProgressNotifier?.removeListener(_onDownloadProgressUpdated);
|
||
_downloadProgressNotifier = widget.downloadProgress;
|
||
_downloadProgressNotifier?.addListener(_onDownloadProgressUpdated);
|
||
|
||
if (_downloadProgressNotifier?.value == null &&
|
||
_isMediaLoading &&
|
||
mounted) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _onDownloadProgressUpdated() {
|
||
if (_downloadProgressNotifier?.value == null &&
|
||
_isMediaLoading &&
|
||
mounted) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _handleDownload() async {
|
||
if (widget.message.localFile != null) return;
|
||
if (_isMediaLoading) return;
|
||
if (!mounted) return;
|
||
|
||
setState(() => _isMediaLoading = true);
|
||
|
||
try {
|
||
await widget.onDownloadRequested?.call(widget.message);
|
||
if (widget.message.messageType == MessageType.image &&
|
||
widget.message.localFile != null) {
|
||
_loadImageDimensionsFromFile(widget.message.localFile!);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Download error: $e');
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _isMediaLoading = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _handleStopDownload() async {
|
||
if (!_isMediaLoading) return;
|
||
|
||
if (mounted) {
|
||
setState(() => _isMediaLoading = false);
|
||
}
|
||
try {
|
||
await widget.onDownloadStoped?.call(widget.message);
|
||
} catch (e) {
|
||
debugPrint('Download stop error: $e');
|
||
}
|
||
}
|
||
|
||
void _checkAutoDownload() {
|
||
if (_hasExistingLocalFile) return;
|
||
_resolveFileSize();
|
||
widget.onDownloadRequestedWithoutLoad?.call(widget.message);
|
||
|
||
final type = widget.message.messageType;
|
||
final isVoiceOrVideoNote =
|
||
type == MessageType.voiceNote || type == MessageType.videoNote;
|
||
|
||
// Если размер еще 0, не качаем файлы вслепую,
|
||
// так как они могут быть больше лимита. Ждем обновления размера.
|
||
if (_calculatedFileSize <= 0 && !isVoiceOrVideoNote) {
|
||
return;
|
||
}
|
||
|
||
if (!_hasExistingLocalFile && widget.message.fileId != null) {
|
||
if (isVoiceOrVideoNote || _calculatedFileSize <= _autoDownloadLimit) {
|
||
_startDownload();
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _startDownload() async {
|
||
if (_hasExistingLocalFile) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (_isMediaLoading || _hasExistingLocalFile) return;
|
||
setState(() => _isMediaLoading = true);
|
||
try {
|
||
await widget.onDownloadRequested?.call(widget.message);
|
||
} catch (e) {
|
||
debugPrint("Ошибка при скачивании медиа: $e");
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isMediaLoading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _openFile() async {
|
||
final localFile = widget.message.localFile;
|
||
if (localFile == null) return;
|
||
|
||
if (await localFile.exists()) {
|
||
debugPrint("Открываем файл напрямую: ${localFile.path}");
|
||
OpenFilex.open(localFile.path)
|
||
.then((result) {
|
||
debugPrint("Результат открытия файла: ${result.type}");
|
||
if (result.type != ResultType.done) {
|
||
debugPrint("Ошибка при открытии файла: ${result.message}");
|
||
}
|
||
})
|
||
.catchError((e) {
|
||
debugPrint("Ошибка при открытии файла: $e");
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<Directory> _getDownloadsDirectory() async {
|
||
try {
|
||
final downloads = await getDownloadsDirectory();
|
||
if (downloads != null) {
|
||
final dir = Directory(p.join(downloads.path, 'Chepuhagram'));
|
||
if (!await dir.exists()) {
|
||
await dir.create(recursive: true);
|
||
}
|
||
return dir;
|
||
}
|
||
} catch (e) {
|
||
debugPrint(
|
||
'Downloads directory unavailable, falling back to app documents: $e',
|
||
);
|
||
}
|
||
return await getApplicationDocumentsDirectory();
|
||
}
|
||
|
||
Future<File> _resolveUniqueFilePath(String fileName) async {
|
||
final safeName = p.basename(fileName);
|
||
final directory = await _getDownloadsDirectory();
|
||
var candidatePath = p.join(directory.path, safeName);
|
||
if (!await File(candidatePath).exists()) {
|
||
return File(candidatePath);
|
||
}
|
||
|
||
final nameWithoutExtension = p.basenameWithoutExtension(safeName);
|
||
final extension = p.extension(safeName);
|
||
var counter = 1;
|
||
while (true) {
|
||
final candidateName = '$nameWithoutExtension ($counter)$extension';
|
||
candidatePath = p.join(directory.path, candidateName);
|
||
if (!await File(candidatePath).exists()) {
|
||
return File(candidatePath);
|
||
}
|
||
counter++;
|
||
}
|
||
}
|
||
|
||
bool get _isDisplayableFileReady {
|
||
return widget.message.localFile != null &&
|
||
widget.message.localFile!.existsSync();
|
||
}
|
||
|
||
void _showContextMenu(TapDownDetails details) {
|
||
final RenderBox overlay =
|
||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||
final RelativeRect position = RelativeRect.fromRect(
|
||
Rect.fromLTWH(
|
||
details.globalPosition.dx,
|
||
details.globalPosition.dy,
|
||
30,
|
||
30,
|
||
),
|
||
Offset.zero & overlay.size,
|
||
);
|
||
final bool hasSelection =
|
||
_currentSelection != null && !_currentSelection!.isCollapsed;
|
||
showMenu<String>(
|
||
context: context,
|
||
position: position,
|
||
items: [
|
||
const PopupMenuItem<String>(value: 'reply', child: Text('Ответить')),
|
||
const PopupMenuItem<String>(value: 'forward', child: Text('Переслать')),
|
||
if (widget.message.text.isNotEmpty)
|
||
const PopupMenuItem<String>(
|
||
value: 'copy',
|
||
child: Text('Копировать текст'),
|
||
),
|
||
if (hasSelection)
|
||
const PopupMenuItem<String>(
|
||
value: 'copy_selected',
|
||
child: Text('Копировать выделенное'),
|
||
),
|
||
if (widget.message.isMe)
|
||
const PopupMenuItem<String>(
|
||
value: 'edit',
|
||
child: Text('Редактировать'),
|
||
),
|
||
// File/media specific actions
|
||
if ((widget.message.localFile != null || widget.message.fileId != null))
|
||
if (Platform.isWindows)
|
||
const PopupMenuItem<String>(
|
||
value: 'show_in_explorer',
|
||
child: Text('Показать в проводнике'),
|
||
),
|
||
if ((widget.message.localFile != null || widget.message.fileId != null))
|
||
if (Platform.isAndroid) ...[
|
||
if (widget.message.messageType == MessageType.image ||
|
||
widget.message.messageType == MessageType.video)
|
||
const PopupMenuItem<String>(
|
||
value: 'save_to_gallery',
|
||
child: Text('Сохранить в галерею'),
|
||
),
|
||
if (widget.message.messageType != MessageType.image &&
|
||
widget.message.messageType != MessageType.video)
|
||
const PopupMenuItem<String>(
|
||
value: 'save_to_downloads',
|
||
child: Text('Сохранить в загрузки'),
|
||
),
|
||
] else ...[
|
||
const PopupMenuItem<String>(
|
||
value: 'save_to_downloads',
|
||
child: Text('Сохранить в загрузки'),
|
||
),
|
||
],
|
||
if (widget.message.isMe)
|
||
const PopupMenuItem<String>(
|
||
value: 'delete',
|
||
child: Text('Удалить', style: TextStyle(color: Colors.red)),
|
||
),
|
||
],
|
||
).then((String? value) {
|
||
if (value != null) {
|
||
_handleMenuSelection(value);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _handleMenuSelection(String value) {
|
||
switch (value) {
|
||
case 'reply':
|
||
widget.onReplyToTap?.call();
|
||
break;
|
||
case 'forward':
|
||
widget.onForwardTap?.call();
|
||
break;
|
||
case 'copy':
|
||
Clipboard.setData(ClipboardData(text: widget.message.text));
|
||
break;
|
||
case 'copy_selected':
|
||
if (_currentSelection != null && !_currentSelection!.isCollapsed) {
|
||
final start = math.min(
|
||
_currentSelection!.start,
|
||
_currentSelection!.end,
|
||
);
|
||
final end = math.max(
|
||
_currentSelection!.start,
|
||
_currentSelection!.end,
|
||
);
|
||
if (start >= 0 && end <= widget.message.text.length) {
|
||
final selectedText = widget.message.text.substring(start, end);
|
||
Clipboard.setData(ClipboardData(text: selectedText));
|
||
}
|
||
}
|
||
break;
|
||
case 'edit':
|
||
widget.onEditTap?.call();
|
||
break;
|
||
case 'delete':
|
||
widget.onDeleteTap?.call();
|
||
break;
|
||
case 'show_in_explorer':
|
||
if (widget.onShowInExplorer != null)
|
||
widget.onShowInExplorer!.call(widget.message);
|
||
else {
|
||
// Fallback: try to open local file location
|
||
final local = widget.message.localFile;
|
||
if (local != null && local.existsSync() && Platform.isWindows) {
|
||
try {
|
||
Process.run('explorer.exe', ['/select,${local.path}']);
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
break;
|
||
case 'save_to_gallery':
|
||
if (widget.onSaveToGallery != null)
|
||
widget.onSaveToGallery!.call(widget.message);
|
||
break;
|
||
case 'save_to_downloads':
|
||
if (widget.onSaveToDownloads != null)
|
||
widget.onSaveToDownloads!.call(widget.message);
|
||
break;
|
||
}
|
||
}
|
||
|
||
TextSpan _buildTextSpan(
|
||
String text,
|
||
Color primaryColor,
|
||
Color linkColor,
|
||
double fontSize,
|
||
) {
|
||
final List<InlineSpan> children = [];
|
||
final RegExp linkRegExp = RegExp(
|
||
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+',
|
||
);
|
||
final matches = linkRegExp.allMatches(text);
|
||
|
||
int lastMatchEnd = 0;
|
||
for (final Match match in matches) {
|
||
if (match.start > lastMatchEnd) {
|
||
children.add(TextSpan(text: text.substring(lastMatchEnd, match.start)));
|
||
}
|
||
final String linkText = match.group(0)!;
|
||
children.add(
|
||
TextSpan(
|
||
text: linkText,
|
||
style: TextStyle(
|
||
color: linkColor,
|
||
fontWeight: FontWeight.bold,
|
||
decoration: TextDecoration.underline,
|
||
),
|
||
recognizer: TapGestureRecognizer()
|
||
..onTap = () async {
|
||
String url = linkText;
|
||
if (!url.startsWith('http')) {
|
||
url = 'https://$url';
|
||
}
|
||
final Uri uri = Uri.parse(url);
|
||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||
throw Exception('Could not launch $uri');
|
||
}
|
||
},
|
||
),
|
||
);
|
||
lastMatchEnd = match.end;
|
||
}
|
||
|
||
if (lastMatchEnd < text.length) {
|
||
children.add(TextSpan(text: text.substring(lastMatchEnd)));
|
||
}
|
||
|
||
return TextSpan(
|
||
style: TextStyle(color: primaryColor, fontSize: fontSize),
|
||
children: children,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isMe = widget.message.isMe;
|
||
final primaryTextColor = Colors.white;
|
||
final secondaryTextColor = Colors.white70;
|
||
final linkColor = const Color(0xFF81D4FA);
|
||
|
||
// ВЫЧИСЛЕНИЕ ДИНАМИЧЕСКИХ РАЗМЕРОВ ДЛЯ БОЛЬШИХ ЭКРАНОВ (PC / Web / Планшеты)
|
||
final double screenWidth = MediaQuery.of(context).size.width;
|
||
final bool isLargeScreen = screenWidth > 750;
|
||
|
||
// Адаптивные шрифты и паддинги
|
||
final double bodyFontSize = isLargeScreen ? 15.5 : 14.0;
|
||
final double timeFontSize = isLargeScreen ? 11.0 : 10.0;
|
||
final double replyFontSize = isLargeScreen ? 13.0 : 12.0;
|
||
|
||
final double paddingVertical = isLargeScreen ? 12.0 : 10.0;
|
||
final double paddingHorizontal = isLargeScreen ? 16.0 : 14.0;
|
||
|
||
final isUnread = widget.message.status != MessageStatus.read;
|
||
|
||
return Align(
|
||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: GestureDetector(
|
||
onSecondaryTapDown: _showContextMenu,
|
||
onLongPressStart: (details) {
|
||
final tapDownDetails = TapDownDetails(
|
||
globalPosition: details.globalPosition,
|
||
);
|
||
_showContextMenu(tapDownDetails);
|
||
},
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: widget.onTap,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: const Radius.circular(16),
|
||
topRight: const Radius.circular(16),
|
||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||
),
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||
padding: EdgeInsets.symmetric(
|
||
vertical: paddingVertical,
|
||
horizontal: paddingHorizontal,
|
||
),
|
||
constraints: BoxConstraints(
|
||
// Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop)
|
||
maxWidth: math.min(screenWidth * 0.75, 460.0),
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: widget.message.messageType == MessageType.videoNote
|
||
? Colors.transparent
|
||
: (isMe
|
||
? Theme.of(context).colorScheme.brightness ==
|
||
Brightness.dark
|
||
? Theme.of(context).colorScheme.primaryContainer
|
||
: Theme.of(context).colorScheme.primary
|
||
: Colors.grey[800]),
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: const Radius.circular(16),
|
||
topRight: const Radius.circular(16),
|
||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||
),
|
||
),
|
||
child: IntrinsicWidth(
|
||
child: Column(
|
||
crossAxisAlignment: isMe
|
||
? CrossAxisAlignment.end
|
||
: CrossAxisAlignment.start,
|
||
children: [
|
||
if (widget.message.replyToText != null) ...[
|
||
_buildReplyWidget(
|
||
isMe,
|
||
secondaryTextColor,
|
||
replyFontSize,
|
||
),
|
||
],
|
||
Align(
|
||
alignment: isMe
|
||
? Alignment.centerRight
|
||
: Alignment.centerLeft,
|
||
child: _buildMessageBody(
|
||
primaryTextColor,
|
||
secondaryTextColor,
|
||
isLargeScreen,
|
||
),
|
||
),
|
||
if (widget.message.text.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: SelectableText.rich(
|
||
_buildTextSpan(
|
||
widget.message.text,
|
||
primaryTextColor,
|
||
linkColor,
|
||
bodyFontSize,
|
||
),
|
||
onSelectionChanged: (selection, cause) {
|
||
_currentSelection = selection;
|
||
},
|
||
style: TextStyle(
|
||
color: primaryTextColor,
|
||
fontSize: bodyFontSize,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 4),
|
||
_buildTimeAndStatusRow(
|
||
isMe,
|
||
secondaryTextColor,
|
||
timeFontSize,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessageBody(
|
||
Color primaryColor,
|
||
Color secondaryColor,
|
||
bool isLargeScreen,
|
||
) {
|
||
switch (widget.message.messageType) {
|
||
case MessageType.image:
|
||
return _buildImagePreview(primaryColor, secondaryColor, isLargeScreen);
|
||
case MessageType.video:
|
||
return _buildVideoPreview(primaryColor, secondaryColor, isLargeScreen);
|
||
case MessageType.file:
|
||
return _buildFileBubble(primaryColor, secondaryColor, isLargeScreen);
|
||
case MessageType.videoNote:
|
||
return _buildVideoNotePreview(
|
||
primaryColor,
|
||
secondaryColor,
|
||
isLargeScreen,
|
||
);
|
||
case MessageType.voiceNote:
|
||
return _buildVoiceNoteBubble(
|
||
primaryColor,
|
||
secondaryColor,
|
||
isLargeScreen,
|
||
);
|
||
default:
|
||
// For text-only messages, we don't need a body here as it's handled outside.
|
||
if (widget.message.messageType == MessageType.text) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
// Fallback for any other case
|
||
return const SizedBox.shrink();
|
||
}
|
||
}
|
||
|
||
Widget _buildImagePreview(
|
||
Color textCol,
|
||
Color subTextCol,
|
||
bool isLargeScreen,
|
||
) {
|
||
_resolveFileSize();
|
||
final bool isDownloaded = _hasExistingLocalFile;
|
||
final bool isSending = widget.message.status == MessageStatus.sending;
|
||
final bool isEncrypting = widget.message.status == MessageStatus.encrypting;
|
||
final isTooLarge = _calculatedFileSize > _autoDownloadLimit;
|
||
final displaySize = formatBytes(_calculatedFileSize, 1);
|
||
|
||
double imgWidth = minWidth > 0 ? minWidth.toDouble() : 240.0;
|
||
double imgHeight = minHeight > 0 ? minHeight.toDouble() : 180.0;
|
||
double aspectRatio = imgWidth / imgHeight;
|
||
aspectRatio = aspectRatio.clamp(0.5, 2.0);
|
||
|
||
// На больших экранах пропорциональный бокс медиафайлов делается чуть крупнее для удобного просмотра
|
||
double finalWidth = isLargeScreen ? 320.0 : 260.0;
|
||
double finalHeight = finalWidth / aspectRatio;
|
||
|
||
double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0;
|
||
if (finalHeight > maxVerticalLimit) {
|
||
finalHeight = maxVerticalLimit;
|
||
finalWidth = finalHeight * aspectRatio;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded)
|
||
? () {
|
||
if (_isMediaLoading || isSending || isEncrypting) {
|
||
_handleStopDownload();
|
||
} else {
|
||
if (isDownloaded) {
|
||
widget.onImageTap?.call();
|
||
} else if (!isSending && !isEncrypting) {
|
||
_handleDownload();
|
||
}
|
||
}
|
||
}
|
||
: widget.onImageTap,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Container(
|
||
width: finalWidth,
|
||
height: finalHeight,
|
||
color: Colors.black.withOpacity(0.05),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (isDownloaded)
|
||
Image.file(
|
||
widget.message.localFile!,
|
||
fit: BoxFit.cover,
|
||
width: finalWidth,
|
||
height: finalHeight,
|
||
)
|
||
else
|
||
_buildMediaPlaceholder(
|
||
Icons.image,
|
||
"Фото",
|
||
isTooLarge,
|
||
textCol,
|
||
subTextCol,
|
||
isLargeScreen,
|
||
),
|
||
if (!isDownloaded &&
|
||
!_isDownloading &&
|
||
!isSending &&
|
||
!isEncrypting &&
|
||
_calculatedFileSize > 0)
|
||
Positioned(
|
||
bottom: 8,
|
||
left: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6,
|
||
vertical: 3,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.55),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.arrow_downward_rounded,
|
||
color: Colors.white,
|
||
size: 12,
|
||
),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
displaySize,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_isMediaLoading || isSending || isEncrypting)
|
||
Positioned.fill(
|
||
child: _buildProgressOverlay(
|
||
(isSending || isEncrypting),
|
||
isEncrypting,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVideoPreview(
|
||
Color textCol,
|
||
Color subTextCol,
|
||
bool isLargeScreen,
|
||
) {
|
||
final bool isDownloaded = _hasExistingLocalFile;
|
||
final bool isSending = widget.message.status == MessageStatus.sending;
|
||
final bool isEncrypting = widget.message.status == MessageStatus.encrypting;
|
||
final isTooLarge = _calculatedFileSize > _autoDownloadLimit;
|
||
final displaySize = formatBytes(_calculatedFileSize, 1);
|
||
final cachedSize = _mediaCache.getDimensions(_messageKeyId);
|
||
final cachedThumbPath = _mediaCache.getThumbnailPath(_messageKeyId);
|
||
|
||
double vidWidth = cachedSize != null && cachedSize.width > 0
|
||
? cachedSize.width
|
||
: 240.0;
|
||
double vidHeight = cachedSize != null && cachedSize.height > 0
|
||
? cachedSize.height
|
||
: 160.0;
|
||
double aspectRatio = vidWidth / vidHeight;
|
||
aspectRatio = aspectRatio.clamp(0.5, 2.0);
|
||
|
||
double finalWidth = isLargeScreen ? 320.0 : 260.0;
|
||
double finalHeight = finalWidth / aspectRatio;
|
||
|
||
double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0;
|
||
if (finalHeight > maxVerticalLimit) {
|
||
finalHeight = maxVerticalLimit;
|
||
finalWidth = finalHeight * aspectRatio;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
if (_isMediaLoading || isSending || isEncrypting) {
|
||
_handleStopDownload();
|
||
} else {
|
||
if (isDownloaded) {
|
||
widget.onImageTap?.call();
|
||
} else if (!_isDisplayableFileReady && !isSending && !isEncrypting) {
|
||
_handleDownload();
|
||
}
|
||
}
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Container(
|
||
width: finalWidth,
|
||
height: finalHeight,
|
||
color: Colors.black.withOpacity(0.1),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (isDownloaded && !isSending && !isEncrypting) ...[
|
||
if (cachedThumbPath != null)
|
||
Image.file(
|
||
File(cachedThumbPath),
|
||
fit: BoxFit.cover,
|
||
width: finalWidth,
|
||
height: finalHeight,
|
||
)
|
||
else
|
||
const Center(
|
||
child: CircularProgressIndicator(color: Colors.white),
|
||
),
|
||
Icon(
|
||
Icons.play_circle_fill,
|
||
color: Colors.white.withOpacity(0.9),
|
||
size: isLargeScreen ? 52 : 44,
|
||
),
|
||
] else ...[
|
||
if (!_isDisplayableFileReady &&
|
||
(isSending || isEncrypting) &&
|
||
_hasExistingLocalFile &&
|
||
cachedThumbPath != null)
|
||
Image.file(
|
||
File(cachedThumbPath),
|
||
fit: BoxFit.cover,
|
||
width: finalWidth,
|
||
height: finalHeight,
|
||
)
|
||
else
|
||
_buildMediaPlaceholder(
|
||
Icons.videocam,
|
||
"Видео",
|
||
isTooLarge,
|
||
textCol,
|
||
subTextCol,
|
||
isLargeScreen,
|
||
),
|
||
],
|
||
if (!isDownloaded &&
|
||
!_isDownloading &&
|
||
!isSending &&
|
||
!isEncrypting &&
|
||
_calculatedFileSize > 0)
|
||
Positioned(
|
||
bottom: 8,
|
||
left: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6,
|
||
vertical: 3,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.55),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.arrow_downward_rounded,
|
||
color: Colors.white,
|
||
size: 12,
|
||
),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
displaySize,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_isDownloading || isSending || isEncrypting)
|
||
Positioned.fill(
|
||
child: _buildProgressOverlay(
|
||
(isSending || isEncrypting),
|
||
isEncrypting,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMediaPlaceholder(
|
||
IconData icon,
|
||
String typeLabel,
|
||
bool isTooLarge,
|
||
Color textCol,
|
||
Color subTextCol,
|
||
bool isLargeScreen,
|
||
) {
|
||
_resolveFileSize();
|
||
final displaySize = formatBytes(_calculatedFileSize, 1);
|
||
final sizeString = _calculatedFileSize > 0
|
||
? " ($displaySize)"
|
||
: " (Загрузка...)";
|
||
|
||
if (_isMediaLoading) return const SizedBox.shrink();
|
||
return Container(
|
||
color: Colors.black.withOpacity(0.05),
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
isTooLarge ? Icons.download_for_offline : icon,
|
||
size: isLargeScreen ? 48 : 42,
|
||
color: subTextCol,
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
isTooLarge
|
||
? "Файл слишком большой$sizeString"
|
||
: "$typeLabel$sizeString",
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 13 : 12,
|
||
color: textCol,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
"Нажмите для загрузки",
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 11 : 10,
|
||
color: isTooLarge ? Colors.black54 : subTextCol,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProgressOverlay(bool isSending, bool isEncrypting) {
|
||
return GestureDetector(
|
||
onTap: () async => await _handleStopDownload(),
|
||
child: Container(
|
||
color: Colors.black.withOpacity(0.5),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
if (isSending) ...[
|
||
widget.downloadProgress != null
|
||
? ValueListenableBuilder<double?>(
|
||
valueListenable: widget.downloadProgress!,
|
||
builder: (context, value, _) {
|
||
final double currentProgress = value ?? 0.0;
|
||
return TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0.0, end: currentProgress),
|
||
duration: const Duration(milliseconds: 150),
|
||
builder: (context, val, _) {
|
||
return _buildCircularIndicator(
|
||
val,
|
||
isEncrypting ? "Шифрование" : "Отправка",
|
||
false,
|
||
_calculatedFileSize,
|
||
);
|
||
},
|
||
);
|
||
},
|
||
)
|
||
: TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0.0, end: 0.0),
|
||
duration: const Duration(milliseconds: 150),
|
||
builder: (context, val, _) {
|
||
return _buildCircularIndicator(
|
||
val,
|
||
isEncrypting ? "Шифрование" : "Отправка",
|
||
false,
|
||
_calculatedFileSize,
|
||
);
|
||
},
|
||
),
|
||
] else ...[
|
||
widget.downloadProgress != null
|
||
? (!_hasExistingLocalFile
|
||
? ValueListenableBuilder<double?>(
|
||
valueListenable: widget.downloadProgress!,
|
||
builder: (context, value, _) {
|
||
final bool isIndeterminate = value == null;
|
||
return TweenAnimationBuilder<double>(
|
||
tween: Tween(begin: 0.0, end: value ?? 0.0),
|
||
duration: const Duration(milliseconds: 150),
|
||
builder: (context, val, _) {
|
||
return _buildCircularIndicator(
|
||
val,
|
||
"Загрузка",
|
||
isIndeterminate,
|
||
_calculatedFileSize,
|
||
);
|
||
},
|
||
);
|
||
},
|
||
)
|
||
: const SizedBox())
|
||
: const CircularProgressIndicator(color: Colors.white),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCircularIndicator(
|
||
double val,
|
||
String label,
|
||
bool isIndeterminate,
|
||
int totalBytes,
|
||
) {
|
||
final currentBytes = (totalBytes * val).toInt();
|
||
final String progressText = totalBytes > 0
|
||
? "${formatBytes(currentBytes, 1)} / ${formatBytes(totalBytes, 1)}"
|
||
: "Передача...";
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 60,
|
||
height: 60,
|
||
child: CircularProgressIndicator(
|
||
value: isIndeterminate ? null : val,
|
||
strokeWidth: 4,
|
||
color: Colors.white,
|
||
backgroundColor: Colors.white24,
|
||
),
|
||
),
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!isIndeterminate)
|
||
Text(
|
||
"${(val * 100).toInt()}%",
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
color: Colors.white60,
|
||
fontSize: isIndeterminate ? 10 : 8,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black38,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
progressText,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildFileBubble(Color textCol, Color subTextCol, bool isLargeScreen) {
|
||
_resolveFileSize();
|
||
final bool isDownloaded = _hasExistingLocalFile;
|
||
final bool isSending = widget.message.status == MessageStatus.sending;
|
||
final bool isEncrypting = widget.message.status == MessageStatus.encrypting;
|
||
final status = isEncrypting
|
||
? 'Шифрование'
|
||
: isSending
|
||
? 'Отправка'
|
||
: _isMediaLoading
|
||
? 'Загрузка'
|
||
: '';
|
||
final displaySize = formatBytes(_calculatedFileSize, 1);
|
||
|
||
// Адаптивная ширина файловой плашки
|
||
final double bubbleWidth = isLargeScreen ? 340.0 : 260.0;
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
if (_hasExistingLocalFile) {
|
||
_openFile();
|
||
} else {
|
||
_handleDownload();
|
||
}
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.04),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
constraints: BoxConstraints(
|
||
minWidth: bubbleWidth,
|
||
maxWidth: bubbleWidth,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.insert_drive_file,
|
||
size: isLargeScreen ? 42 : 38,
|
||
color: subTextCol,
|
||
),
|
||
if (!_isMediaLoading &&
|
||
!isDownloaded &&
|
||
!isSending &&
|
||
!isEncrypting)
|
||
Icon(
|
||
Icons.download_rounded,
|
||
size: isLargeScreen ? 18 : 16,
|
||
color: Colors.white70,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.message.fileName ?? 'Файл',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: isLargeScreen ? 14.5 : 13.0,
|
||
color: textCol,
|
||
),
|
||
),
|
||
Row(
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
displaySize,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 12 : 11,
|
||
color: subTextCol,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Text(
|
||
status,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 12 : 11,
|
||
color: subTextCol,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_isMediaLoading || isSending || isEncrypting)
|
||
ValueListenableBuilder<double?>(
|
||
valueListenable:
|
||
widget.downloadProgress ??
|
||
ValueNotifier<double?>(0.0),
|
||
builder: (context, value, _) {
|
||
final progress = value ?? 0.0;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: LinearProgressIndicator(
|
||
value: progress > 0 ? progress : null,
|
||
minHeight: 3,
|
||
backgroundColor: Colors.white24,
|
||
color: Colors.white70,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!isDownloaded &&
|
||
!_isMediaLoading &&
|
||
!isSending &&
|
||
!isEncrypting)
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.download_rounded,
|
||
color: Colors.white70,
|
||
size: isLargeScreen ? 24 : 20,
|
||
),
|
||
onPressed: _handleDownload,
|
||
),
|
||
if (!isDownloaded && _isMediaLoading && !isSending && !isEncrypting)
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.cancel,
|
||
color: Colors.white70,
|
||
size: isLargeScreen ? 24 : 20,
|
||
),
|
||
onPressed: _handleStopDownload,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildReplyWidget(bool isMe, Color subTextCol, double replyFontSize) {
|
||
final isMedia =
|
||
widget.message.messageType == MessageType.image ||
|
||
widget.message.messageType == MessageType.video ||
|
||
widget.message.messageType == MessageType.videoNote;
|
||
return GestureDetector(
|
||
onTap: widget.onReplyTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
decoration: BoxDecoration(
|
||
color: (isMedia
|
||
? Colors.white24.withOpacity(0.5)
|
||
: isMe
|
||
? Colors.white.withOpacity(0.1)
|
||
: Colors.black.withOpacity(0.1)),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border(left: BorderSide(color: subTextCol, width: 2)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.reply, size: 14, color: subTextCol),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
widget.message.replyToText!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: subTextCol,
|
||
fontSize: replyFontSize,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVideoNotePreview(
|
||
Color primaryTextColor,
|
||
Color secondaryTextColor,
|
||
bool isLargeScreen,
|
||
) {
|
||
final file = widget.message.localFile;
|
||
final path = file?.path ?? "";
|
||
final id = widget.message.id ?? widget.message.tempId ?? "no_id";
|
||
final bool isDownloaded = file != null;
|
||
final bool isSending = widget.message.status == MessageStatus.sending;
|
||
final bool isEncrypting = widget.message.status == MessageStatus.encrypting;
|
||
|
||
/*debugPrint(
|
||
'==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path',
|
||
);*/
|
||
|
||
return ValueListenableBuilder<String?>(
|
||
valueListenable: InlineVideoNotePlayer.activeVideoPathNotifier,
|
||
builder: (context, activePath, _) {
|
||
final bool isActive = activePath == path;
|
||
// Масштабируем кружки-видеозаметки на десктопе
|
||
final double size = isActive
|
||
? (isLargeScreen ? 300 : 260)
|
||
: (isLargeScreen ? 190 : 160);
|
||
|
||
return GestureDetector(
|
||
onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded)
|
||
? () {
|
||
if (_isDownloading || isSending || isEncrypting) {
|
||
_handleStopDownload();
|
||
} else {
|
||
if (!isDownloaded) {
|
||
_handleDownload();
|
||
}
|
||
}
|
||
}
|
||
: widget.onTap,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.fastOutSlowIn,
|
||
width: size,
|
||
height: size,
|
||
decoration: const BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.black12,
|
||
),
|
||
child: ClipOval(
|
||
child: isDownloaded
|
||
? InlineVideoNotePlayer(videoPath: path)
|
||
: Container(
|
||
color: Colors.black54,
|
||
child: Icon(
|
||
Icons.play_arrow_rounded,
|
||
size: isLargeScreen ? 54 : 48,
|
||
color: primaryTextColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!isDownloaded &&
|
||
!_isDownloading &&
|
||
!isSending &&
|
||
!isEncrypting)
|
||
ClipOval(
|
||
child: Container(
|
||
width: size,
|
||
height: size,
|
||
color: Colors.black.withOpacity(0.4),
|
||
child: Icon(
|
||
Icons.arrow_downward_rounded,
|
||
color: Colors.white,
|
||
size: isLargeScreen ? 42 : 36,
|
||
),
|
||
),
|
||
),
|
||
if (_isDownloading || isSending || isEncrypting)
|
||
SizedBox(
|
||
width: size,
|
||
height: size,
|
||
child: _buildProgressOverlay(
|
||
(isSending || isEncrypting),
|
||
isEncrypting,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildVoiceNoteBubble(
|
||
Color textCol,
|
||
Color subTextCol,
|
||
bool isLargeScreen,
|
||
) {
|
||
final String path = widget.message.localFile?.path ?? '';
|
||
final bool isDownloaded = path.isNotEmpty && File(path).existsSync();
|
||
final bool isSendingNow = widget.message.status == MessageStatus.sending;
|
||
|
||
final double noteWidth = isLargeScreen ? 280.0 : 240.0;
|
||
|
||
if (!isDownloaded) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
constraints: BoxConstraints(minWidth: noteWidth, maxWidth: noteWidth),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 28,
|
||
height: 28,
|
||
child: _isMediaLoading
|
||
? CircularProgressIndicator(
|
||
valueColor: AlwaysStoppedAnimation<Color>(textCol),
|
||
strokeWidth: 2.5,
|
||
)
|
||
: Icon(Icons.download_rounded, color: textCol, size: 28),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
"Голосовое сообщение",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 15 : 14,
|
||
color: textCol,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
_isMediaLoading ? "Загрузка..." : "Нажмите для скачивания",
|
||
style: TextStyle(
|
||
fontSize: isLargeScreen ? 12 : 11,
|
||
color: subTextCol,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(4),
|
||
constraints: BoxConstraints(
|
||
minWidth: noteWidth + 10,
|
||
maxWidth: noteWidth + 10,
|
||
),
|
||
child: Opacity(
|
||
opacity: isSendingNow ? 0.5 : 1.0,
|
||
child: AbsorbPointer(
|
||
absorbing: isSendingNow,
|
||
child: InlineVoiceNotePlayer(
|
||
key: ValueKey(
|
||
'voice_note_${widget.message.fileId ?? widget.message.tempId}',
|
||
),
|
||
audioPath: path,
|
||
isLargeScreen: isLargeScreen,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTimeAndStatusRow(
|
||
bool isMe,
|
||
Color secondaryTextColor,
|
||
double timeFontSize,
|
||
) {
|
||
final timeStr =
|
||
"${widget.message.createdAt.hour.toString().padLeft(2, '0')}:${widget.message.createdAt.minute.toString().padLeft(2, '0')}";
|
||
return Row(
|
||
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
timeStr,
|
||
style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize),
|
||
),
|
||
|
||
if (kDebugMode)
|
||
Text(
|
||
" ID: ${widget.message.id}",
|
||
style: TextStyle(
|
||
color: secondaryTextColor,
|
||
fontSize: timeFontSize,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
if (widget.message.editedAt != null)
|
||
Text(
|
||
" (изменено)",
|
||
style: TextStyle(
|
||
color: secondaryTextColor,
|
||
fontSize: timeFontSize,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
if (isMe) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(
|
||
_statusIcon(widget.message.status),
|
||
color: secondaryTextColor,
|
||
size: timeFontSize + 4,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
IconData _statusIcon(MessageStatus status) {
|
||
switch (status) {
|
||
case MessageStatus.sending:
|
||
case MessageStatus.encrypting:
|
||
return Icons.access_time;
|
||
case MessageStatus.sent:
|
||
case MessageStatus.delivered:
|
||
return Icons.done;
|
||
case MessageStatus.read:
|
||
return Icons.done_all;
|
||
case MessageStatus.failed:
|
||
return Icons.error;
|
||
}
|
||
}
|
||
|
||
String formatBytes(int bytes, int decimals) {
|
||
if (bytes <= 0) return "0 B";
|
||
const suffixes = ["B", "KB", "MB", "GB", "TB"];
|
||
var i = (log(bytes) / log(1024)).floor();
|
||
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) +
|
||
" " +
|
||
suffixes[i];
|
||
}
|
||
}
|
||
|
||
class MediaCacheManager {
|
||
static final MediaCacheManager _instance = MediaCacheManager._internal();
|
||
factory MediaCacheManager() => _instance;
|
||
MediaCacheManager._internal();
|
||
|
||
final Map<String, ui.Size> _dimensionsCache = {};
|
||
final Map<String, String> _thumbnailPathCache = {};
|
||
|
||
void saveDimensions(String id, int width, int height) {
|
||
_dimensionsCache[id] = ui.Size(width.toDouble(), height.toDouble());
|
||
}
|
||
|
||
ui.Size? getDimensions(String id) => _dimensionsCache[id];
|
||
|
||
void saveThumbnailPath(String id, String path) {
|
||
_thumbnailPathCache[id] = path;
|
||
}
|
||
|
||
String? getThumbnailPath(String id) => _thumbnailPathCache[id];
|
||
}
|
||
|
||
class InlineVideoNotePlayer extends StatefulWidget {
|
||
final String videoPath;
|
||
const InlineVideoNotePlayer({super.key, required this.videoPath});
|
||
|
||
static final ValueNotifier<String?> activeVideoPathNotifier =
|
||
ValueNotifier<String?>(null);
|
||
|
||
@override
|
||
State<InlineVideoNotePlayer> createState() => _InlineVideoNotePlayerState();
|
||
}
|
||
|
||
class _InlineVideoNotePlayerState extends State<InlineVideoNotePlayer> {
|
||
VideoPlayerController? _controller;
|
||
bool _isExpanded = false;
|
||
Future<void>? _delayFuture;
|
||
String? _initError;
|
||
bool _isInitializing = false;
|
||
bool _wasPlaying = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_initVideoWithDelay();
|
||
});
|
||
InlineVideoNotePlayer.activeVideoPathNotifier.addListener(
|
||
_onActiveVideoChanged,
|
||
);
|
||
}
|
||
|
||
void _initVideoWithDelay() async {
|
||
if (widget.videoPath.isEmpty) return;
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
|
||
final int stableSalt = widget.videoPath.hashCode % 6;
|
||
final int delayMs = 150 * stableSalt;
|
||
|
||
_delayFuture = Future.delayed(Duration(milliseconds: delayMs)).then((
|
||
_,
|
||
) async {
|
||
if (!mounted) return;
|
||
_initVideo();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
InlineVideoNotePlayer.activeVideoPathNotifier.removeListener(
|
||
_onActiveVideoChanged,
|
||
);
|
||
_controller?.removeListener(_videoListener);
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant InlineVideoNotePlayer oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
if (widget.videoPath.isEmpty && oldWidget.videoPath.isNotEmpty) {
|
||
return;
|
||
}
|
||
|
||
if (oldWidget.videoPath != widget.videoPath) {
|
||
if (_controller != null && _controller!.value.isInitialized) {
|
||
final oldFile = File(oldWidget.videoPath);
|
||
final newFile = File(widget.videoPath);
|
||
|
||
if (oldFile.existsSync() &&
|
||
newFile.existsSync() &&
|
||
oldFile.lengthSync() == newFile.lengthSync()) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (_controller != null) {
|
||
_controller!.removeListener(_videoListener);
|
||
final oldController = _controller!;
|
||
_controller = null;
|
||
oldController.dispose().catchError(
|
||
(e) => debugPrint('Error disposing vc: $e'),
|
||
);
|
||
}
|
||
_initVideo();
|
||
}
|
||
}
|
||
|
||
void _initVideo() {
|
||
if (widget.videoPath.isEmpty) return;
|
||
|
||
final file = File(widget.videoPath);
|
||
if (!file.existsSync()) return;
|
||
|
||
if (_isInitializing) return;
|
||
_isInitializing = true;
|
||
_initError = null;
|
||
|
||
_controller = VideoPlayerController.file(file)
|
||
..initialize()
|
||
.then((_) {
|
||
_isInitializing = false;
|
||
_initError = null;
|
||
if (mounted) setState(() {});
|
||
})
|
||
.catchError((e) {
|
||
_isInitializing = false;
|
||
_initError = e.toString();
|
||
final oldController = _controller;
|
||
_controller = null;
|
||
oldController?.removeListener(_videoListener);
|
||
oldController?.dispose().catchError((_) {});
|
||
if (mounted) setState(() {});
|
||
});
|
||
|
||
_controller?.addListener(_videoListener);
|
||
}
|
||
|
||
void _videoListener() {
|
||
if (!mounted || _controller == null) return;
|
||
|
||
final isPlaying = _controller!.value.isPlaying;
|
||
final isInitialized = _controller!.value.isInitialized;
|
||
|
||
if (isInitialized && _wasPlaying && !isPlaying) {
|
||
_isExpanded = false;
|
||
if (InlineVideoNotePlayer.activeVideoPathNotifier.value ==
|
||
widget.videoPath) {
|
||
InlineVideoNotePlayer.activeVideoPathNotifier.value = null;
|
||
}
|
||
}
|
||
_wasPlaying = isPlaying;
|
||
setState(() {});
|
||
}
|
||
|
||
void _onActiveVideoChanged() {
|
||
if (!mounted || _controller == null) return;
|
||
final activePath = InlineVideoNotePlayer.activeVideoPathNotifier.value;
|
||
|
||
if (activePath != widget.videoPath) {
|
||
setState(() {
|
||
if (_controller!.value.isPlaying) {
|
||
_controller!.pause();
|
||
}
|
||
_isExpanded = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _togglePlay() {
|
||
if (_controller == null || !_controller!.value.isInitialized) return;
|
||
|
||
setState(() {
|
||
if (_controller!.value.isPlaying) {
|
||
_controller!.pause();
|
||
_isExpanded = false;
|
||
if (InlineVideoNotePlayer.activeVideoPathNotifier.value ==
|
||
widget.videoPath) {
|
||
InlineVideoNotePlayer.activeVideoPathNotifier.value = null;
|
||
}
|
||
} else {
|
||
InlineVideoNotePlayer.activeVideoPathNotifier.value = widget.videoPath;
|
||
_controller!.play();
|
||
_controller!.setLooping(true);
|
||
_isExpanded = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bool isLargeScreen = MediaQuery.of(context).size.width > 750;
|
||
final double size = _isExpanded
|
||
? (isLargeScreen ? 300.0 : 260.0)
|
||
: (isLargeScreen ? 190.0 : 160.0);
|
||
final bool isInitialized =
|
||
_controller != null && _controller!.value.isInitialized;
|
||
final bool hasInitError = _initError != null;
|
||
|
||
double progress = 0.0;
|
||
if (isInitialized) {
|
||
final duration = _controller!.value.duration.inMilliseconds;
|
||
final position = _controller!.value.position.inMilliseconds;
|
||
progress = duration > 0 ? (position / duration) : 0.0;
|
||
}
|
||
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 180),
|
||
curve: Curves.easeOut,
|
||
width: size,
|
||
height: size,
|
||
decoration: const BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.black12,
|
||
),
|
||
child: ClipOval(
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (hasInitError)
|
||
_InlineVideoInitErrorFallback(videoPath: widget.videoPath)
|
||
else if (isInitialized)
|
||
GestureDetector(
|
||
onTap: _togglePlay,
|
||
child: SizedBox(
|
||
width: size,
|
||
height: size,
|
||
child: FittedBox(
|
||
fit: BoxFit.cover,
|
||
child: SizedBox(
|
||
width: _controller!.value.size.width,
|
||
height: _controller!.value.size.height,
|
||
child: VideoPlayer(_controller!),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
const Center(
|
||
child: SizedBox(
|
||
width: 30,
|
||
height: 30,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white70),
|
||
),
|
||
),
|
||
),
|
||
|
||
if (isInitialized && !hasInitError)
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: CustomPaint(
|
||
painter: CircleProgressPainter(
|
||
progress: progress,
|
||
progressColor: Colors.white,
|
||
backgroundColor: Colors.white30,
|
||
strokeWidth: 4.0,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (isInitialized && !_controller!.value.isPlaying && !hasInitError)
|
||
IgnorePointer(
|
||
child: Container(
|
||
color: Colors.black26,
|
||
alignment: Alignment.center,
|
||
child: const Icon(
|
||
Icons.play_arrow,
|
||
size: 40,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InlineVideoInitErrorFallback extends StatelessWidget {
|
||
final String videoPath;
|
||
const _InlineVideoInitErrorFallback({required this.videoPath});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
color: Colors.black12,
|
||
child: InkWell(
|
||
onTap: () async {
|
||
try {
|
||
await OpenFilex.open(videoPath);
|
||
} catch (e) {
|
||
debugPrint('Fallback error: $e');
|
||
}
|
||
},
|
||
child: const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.play_disabled, color: Colors.white70, size: 40),
|
||
SizedBox(height: 8),
|
||
Text(
|
||
'Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CircleProgressPainter extends CustomPainter {
|
||
final double progress;
|
||
final Color progressColor;
|
||
final Color backgroundColor;
|
||
final double strokeWidth;
|
||
|
||
CircleProgressPainter({
|
||
required this.progress,
|
||
required this.progressColor,
|
||
required this.backgroundColor,
|
||
required this.strokeWidth,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final Offset center = Offset(size.width / 2, size.height / 2);
|
||
final double radius = (size.width - strokeWidth) / 2;
|
||
|
||
final Paint backgroundPaint = Paint()
|
||
..color = backgroundColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = strokeWidth;
|
||
|
||
canvas.drawCircle(center, radius, backgroundPaint);
|
||
|
||
final Paint progressPaint = Paint()
|
||
..color = progressColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeCap = StrokeCap.round
|
||
..strokeWidth = strokeWidth;
|
||
|
||
double startAngle = -math.pi / 2;
|
||
double sweepAngle = 2 * math.pi * progress;
|
||
|
||
canvas.drawArc(
|
||
Rect.fromCircle(center: center, radius: radius),
|
||
startAngle,
|
||
sweepAngle,
|
||
false,
|
||
progressPaint,
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CircleProgressPainter oldDelegate) {
|
||
return oldDelegate.progress != progress ||
|
||
oldDelegate.progressColor != progressColor ||
|
||
oldDelegate.backgroundColor != backgroundColor;
|
||
}
|
||
}
|
||
|
||
class InlineVoiceNotePlayer extends StatefulWidget {
|
||
final String audioPath;
|
||
final bool isLargeScreen;
|
||
const InlineVoiceNotePlayer({
|
||
super.key,
|
||
required this.audioPath,
|
||
required this.isLargeScreen,
|
||
});
|
||
|
||
@override
|
||
State<InlineVoiceNotePlayer> createState() => _InlineVoiceNotePlayerState();
|
||
}
|
||
|
||
class _InlineVoiceNotePlayerState extends State<InlineVoiceNotePlayer> {
|
||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||
bool _isPlaying = false;
|
||
bool _isInitializing = false;
|
||
Duration _duration = Duration.zero;
|
||
Duration _position = Duration.zero;
|
||
bool _sourceInitialized = false;
|
||
Timer? _fileWatchTimer;
|
||
|
||
bool get _hasValidDuration => _duration.inMilliseconds > 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initAudioListeners();
|
||
_checkAndSetupSource();
|
||
_startFileAvailabilityPolling();
|
||
}
|
||
|
||
void _checkAndSetupSource() {
|
||
if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing)
|
||
return;
|
||
|
||
final file = File(widget.audioPath);
|
||
if (!file.existsSync()) return;
|
||
|
||
_isInitializing = true;
|
||
if (mounted) setState(() {});
|
||
|
||
_setupSource(widget.audioPath).whenComplete(() {
|
||
if (!mounted) {
|
||
_isInitializing = false;
|
||
return;
|
||
}
|
||
setState(() {
|
||
_isInitializing = false;
|
||
});
|
||
});
|
||
}
|
||
|
||
void _startFileAvailabilityPolling() {
|
||
_fileWatchTimer?.cancel();
|
||
if (widget.audioPath.isEmpty) return;
|
||
|
||
final file = File(widget.audioPath);
|
||
if (file.existsSync()) {
|
||
if (!_sourceInitialized && !_isInitializing) _checkAndSetupSource();
|
||
return;
|
||
}
|
||
|
||
_fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), (
|
||
timer,
|
||
) {
|
||
if (!mounted || widget.audioPath.isEmpty) {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
final file = File(widget.audioPath);
|
||
if (file.existsSync()) {
|
||
timer.cancel();
|
||
if (mounted) {
|
||
setState(() {});
|
||
_checkAndSetupSource();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _setupSource(String path) async {
|
||
try {
|
||
await _audioPlayer.stop();
|
||
await _audioPlayer.setSource(DeviceFileSource(path));
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_sourceInitialized = true;
|
||
});
|
||
|
||
final d = await _audioPlayer.getDuration();
|
||
if (!mounted) return;
|
||
if (d != null && d.inMilliseconds > 0) {
|
||
setState(() {
|
||
_duration = d;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[AUDIO ERROR] Ошибка установки источника: $e');
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant InlineVoiceNotePlayer oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
final bool pathChanged = oldWidget.audioPath != widget.audioPath;
|
||
final bool fileJustAppeared =
|
||
widget.audioPath.isNotEmpty &&
|
||
!_sourceInitialized &&
|
||
File(widget.audioPath).existsSync();
|
||
|
||
if (pathChanged) {
|
||
_sourceInitialized = false;
|
||
_position = Duration.zero;
|
||
_duration = Duration.zero;
|
||
_fileWatchTimer?.cancel();
|
||
}
|
||
|
||
if (pathChanged || fileJustAppeared) {
|
||
_checkAndSetupSource();
|
||
_startFileAvailabilityPolling();
|
||
}
|
||
}
|
||
|
||
void _initAudioListeners() {
|
||
_audioPlayer.onPlayerStateChanged.listen((state) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isPlaying = state == PlayerState.playing;
|
||
if (state == PlayerState.stopped || state == PlayerState.completed) {
|
||
_position = Duration.zero;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
_audioPlayer.onDurationChanged.listen((newDuration) {
|
||
if (mounted && newDuration.inMilliseconds > 0) {
|
||
setState(() {
|
||
_duration = newDuration;
|
||
});
|
||
}
|
||
});
|
||
|
||
_audioPlayer.onPositionChanged.listen((newPosition) {
|
||
if (!mounted) return;
|
||
if (_isPlaying && newPosition.inMilliseconds < 150) return;
|
||
|
||
setState(() {
|
||
if (_duration.inMilliseconds > 0 && newPosition > _duration) {
|
||
_position = _duration;
|
||
} else {
|
||
_position = newPosition;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
void _togglePlay() async {
|
||
if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync())
|
||
return;
|
||
|
||
if (!_sourceInitialized) _checkAndSetupSource();
|
||
|
||
if (_isPlaying) {
|
||
await _audioPlayer.pause();
|
||
} else {
|
||
await _audioPlayer.play(DeviceFileSource(widget.audioPath));
|
||
}
|
||
}
|
||
|
||
bool get _isFileAvailable =>
|
||
widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync();
|
||
|
||
@override
|
||
void dispose() {
|
||
_fileWatchTimer?.cancel();
|
||
_audioPlayer.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String _formatDuration(Duration duration) {
|
||
final String minutes = duration.inMinutes.toString();
|
||
final String seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||
return "$minutes:$seconds";
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bool fileAvailable = _isFileAvailable;
|
||
final bool hasDuration = _hasValidDuration;
|
||
final bool isReady = fileAvailable && _sourceInitialized && hasDuration;
|
||
final String statusText;
|
||
|
||
if (!fileAvailable) {
|
||
statusText = 'Загрузка...';
|
||
} else if (!_sourceInitialized || !hasDuration) {
|
||
statusText = 'Подготовка...';
|
||
} else {
|
||
statusText =
|
||
"${_formatDuration(_position)} / ${_formatDuration(_duration)}";
|
||
}
|
||
|
||
final double durationMs = _duration.inMilliseconds.toDouble();
|
||
final double positionMs = _position.inMilliseconds.toDouble();
|
||
final bool canSeek = hasDuration;
|
||
final double safeMax = durationMs > 0 ? durationMs : 1.0;
|
||
final double safeValue = durationMs > 0
|
||
? positionMs.clamp(0.0, safeMax)
|
||
: 0.0;
|
||
|
||
final double playerWidth = widget.isLargeScreen ? 280.0 : 240.0;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||
width: playerWidth,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black12,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(
|
||
_isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||
),
|
||
iconSize: widget.isLargeScreen ? 40 : 36,
|
||
color: fileAvailable ? Colors.white : Colors.white38,
|
||
onPressed: fileAvailable ? _togglePlay : null,
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
trackHeight: 3,
|
||
padding: EdgeInsets.zero,
|
||
thumbShape: const RoundSliderThumbShape(
|
||
enabledThumbRadius: 5,
|
||
elevation: 0,
|
||
),
|
||
overlayShape: const RoundSliderOverlayShape(
|
||
overlayRadius: 8,
|
||
),
|
||
),
|
||
child: Container(
|
||
height: 20,
|
||
alignment: Alignment.center,
|
||
child: Slider(
|
||
activeColor: isReady ? Colors.white : Colors.white38,
|
||
inactiveColor: Colors.white60,
|
||
thumbColor: isReady ? Colors.white : Colors.white24,
|
||
min: 0.0,
|
||
max: safeMax,
|
||
value: safeValue,
|
||
onChanged: canSeek
|
||
? (value) async {
|
||
await _audioPlayer.seek(
|
||
Duration(milliseconds: value.toInt()),
|
||
);
|
||
}
|
||
: null,
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4,
|
||
vertical: 2,
|
||
),
|
||
child: Text(
|
||
statusText,
|
||
style: TextStyle(
|
||
fontSize: widget.isLargeScreen ? 12 : 11,
|
||
color: Colors.white70,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|