import 'package:flutter/material.dart'; import 'dart:io' show File; import 'dart:convert' show base64Decode, jsonDecode, jsonEncode; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:io' as io; import 'package:crypto/crypto.dart' as crypto; import 'package:intl/intl.dart'; import 'package:gwid/models/message.dart'; import 'package:gwid/models/contact.dart'; import 'package:gwid/utils/theme_provider.dart'; import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import 'dart:ui'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:gwid/screens/chat_screen.dart'; import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/api/api_service.dart'; import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:open_file/open_file.dart'; import 'package:gwid/widgets/full_screen_video_player.dart'; import 'package:just_audio/just_audio.dart'; import 'package:gwid/services/cache_service.dart'; import 'package:video_player/video_player.dart'; import 'package:gwid/services/music_player_service.dart'; import 'package:platform_info/platform_info.dart'; bool _currentIsDark = false; bool isMobile = Platform.instance.operatingSystem.iOS || Platform.instance.operatingSystem.android; enum MessageReadStatus { sending, // Отправляется (часы) sent, // Отправлено (1 галочка) read, // Прочитано (2 галочки) } // Service для отслеживания прогресса загрузки файлов class FileDownloadProgressService { static final FileDownloadProgressService _instance = FileDownloadProgressService._internal(); factory FileDownloadProgressService() => _instance; FileDownloadProgressService._internal(); final Map> _progressNotifiers = {}; bool _initialized = false; // Initialize on first access to load saved download status Future _ensureInitialized() async { if (_initialized) return; try { final prefs = await SharedPreferences.getInstance(); // Load fileId -> filePath mappings final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; // Mark all downloaded files as completed (progress = 1.0) for (final mapping in fileIdMap) { final parts = mapping.split(':'); if (parts.length >= 2) { final fileId = parts[0]; final filePath = parts.skip(1).join(':'); // In case path contains ':' final file = io.File(filePath); if (await file.exists()) { if (!_progressNotifiers.containsKey(fileId)) { _progressNotifiers[fileId] = ValueNotifier(1.0); } else { _progressNotifiers[fileId]!.value = 1.0; } } } } _initialized = true; } catch (e) { print('Error initializing download status: $e'); _initialized = true; // Mark as initialized to avoid retrying indefinitely } } ValueNotifier getProgress(String fileId) { _ensureInitialized(); // Ensure initialization if (!_progressNotifiers.containsKey(fileId)) { _progressNotifiers[fileId] = ValueNotifier(-1); } return _progressNotifiers[fileId]!; } void updateProgress(String fileId, double progress) { if (!_progressNotifiers.containsKey(fileId)) { _progressNotifiers[fileId] = ValueNotifier(progress); } else { _progressNotifiers[fileId]!.value = progress; } } void clearProgress(String fileId) { _progressNotifiers.remove(fileId); } } Color _getUserColor(int userId, BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; if (isDark != _currentIsDark) { _currentIsDark = isDark; } final List materialYouColors = isDark ? [ // Темная тема const Color(0xFFEF5350), // Красный const Color(0xFFEC407A), // Розовый const Color(0xFFAB47BC), // Фиолетовый const Color(0xFF7E57C2), // Глубокий фиолетовый const Color(0xFF5C6BC0), // Индиго const Color(0xFF42A5F5), // Синий const Color(0xFF29B6F6), // Голубой const Color(0xFF26C6DA), // Бирюзовый const Color(0xFF26A69A), // Теal const Color(0xFF66BB6A), // Зеленый const Color(0xFF9CCC65), // Светло-зеленый const Color(0xFFD4E157), // Лаймовый const Color(0xFFFFEB3B), // Желтый const Color(0xFFFFCA28), // Янтарный const Color(0xFFFFA726), // Оранжевый const Color(0xFFFF7043), // Глубокий оранжевый const Color(0xFF8D6E63), // Коричневый const Color(0xFF78909C), // Сине-серый const Color(0xFFB39DDB), // Лавандовый const Color(0xFF80CBC4), // Аквамариновый const Color(0xFFC5E1A5), // Светло-зеленый пастельный ] : [ // Светлая тема const Color(0xFFF44336), // Красный const Color(0xFFE91E63), // Розовый const Color(0xFF9C27B0), // Фиолетовый const Color(0xFF673AB7), // Глубокий фиолетовый const Color(0xFF3F51B5), // Индиго const Color(0xFF2196F3), // Синий const Color(0xFF03A9F4), // Голубой const Color(0xFF00BCD4), // Бирюзовый const Color(0xFF009688), // Теal const Color(0xFF4CAF50), // Зеленый const Color(0xFF8BC34A), // Светло-зеленый const Color(0xFFCDDC39), // Лаймовый const Color(0xFFFFEE58), // Желтый const Color(0xFFFFC107), // Янтарный const Color(0xFFFF9800), // Оранжевый const Color(0xFFFF5722), // Глубокий оранжевый const Color(0xFF795548), // Коричневый const Color(0xFF607D8B), // Сине-серый const Color(0xFF9575CD), // Лавандовый const Color(0xFF4DB6AC), // Бирюзовый светлый const Color(0xFFAED581), // Зеленый пастельный ]; final colorIndex = userId % materialYouColors.length; final color = materialYouColors[colorIndex]; return color; } class _KometColoredSegment { final String text; final Color? color; _KometColoredSegment(this.text, this.color); } class ChatMessageBubble extends StatelessWidget { final Message message; final bool isMe; final MessageReadStatus? readStatus; final bool deferImageLoading; final VoidCallback? onEdit; final VoidCallback? onDelete; final VoidCallback? onDeleteForMe; final VoidCallback? onDeleteForAll; final Function(String)? onReaction; final VoidCallback? onRemoveReaction; final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onComplain; final int? myUserId; final bool? canEditMessage; final bool isGroupChat; final bool isChannel; final String? senderName; final String? forwardedFrom; final String? forwardedFromAvatarUrl; final Map? contactDetailsCache; final Function(String)? onReplyTap; final bool useAutoReplyColor; final Color? customReplyColor; final bool isFirstInGroup; final bool isLastInGroup; final bool isGrouped; final double avatarVerticalOffset; final int? chatId; const ChatMessageBubble({ super.key, required this.message, required this.isMe, this.readStatus, this.deferImageLoading = false, this.onEdit, this.onDelete, this.onDeleteForMe, this.onDeleteForAll, this.onReaction, this.onRemoveReaction, this.onReply, this.onForward, this.onComplain, this.myUserId, this.canEditMessage, this.isGroupChat = false, this.isChannel = false, this.senderName, this.forwardedFrom, this.forwardedFromAvatarUrl, this.contactDetailsCache, this.onReplyTap, this.useAutoReplyColor = true, this.customReplyColor, this.isFirstInGroup = false, this.isLastInGroup = false, this.isGrouped = false, this.avatarVerticalOffset = -35.0, // выше ниже аватарку бля как хотите я жрать хочу this.chatId, }); String _formatMessageTime(BuildContext context, int timestamp) { final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); final showSeconds = Provider.of( context, listen: false, ).showSeconds; return DateFormat(showSeconds ? 'HH:mm:ss' : 'HH:mm').format(dt); } EdgeInsets _getMessageMargin(BuildContext context) { if (isLastInGroup) { return const EdgeInsets.only(bottom: 12); } if (isFirstInGroup) { return const EdgeInsets.only(bottom: 3); } return const EdgeInsets.only(bottom: 3); } Widget _buildForwardedMessage( BuildContext context, Map link, Color textColor, double messageTextOpacity, bool isUltraOptimized, ) { final forwardedMessage = link['message'] as Map?; if (forwardedMessage == null) return const SizedBox.shrink(); final text = forwardedMessage['text'] as String? ?? ''; final attaches = (forwardedMessage['attaches'] as List?) ?.map((e) => (e as Map).cast()) .toList() ?? []; String forwardedSenderName; String? forwardedSenderAvatarUrl = forwardedFromAvatarUrl; if (forwardedFrom != null) { forwardedSenderName = forwardedFrom!; } else { final chatName = link['chatName'] as String?; final chatIconUrl = link['chatIconUrl'] as String?; if (chatName != null) { forwardedSenderName = chatName; forwardedSenderAvatarUrl ??= chatIconUrl; } else { final originalSenderId = forwardedMessage['sender'] as int?; final cache = contactDetailsCache; if (originalSenderId != null && cache != null) { final originalSenderContact = cache[originalSenderId]; forwardedSenderName = originalSenderContact?.name ?? 'ID $originalSenderId'; forwardedSenderAvatarUrl ??= originalSenderContact?.photoBaseUrl; } else if (originalSenderId != null) { forwardedSenderName = 'ID $originalSenderId'; } else { forwardedSenderName = 'Пользователь'; } } } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: textColor.withOpacity(0.08 * messageTextOpacity), border: Border( left: BorderSide( color: textColor.withOpacity(0.3 * messageTextOpacity), width: 3, // Делаем рамку жирнее для отличия от ответа ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // "Заголовок" с именем автора и аватаркой Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.forward, size: 14, color: textColor.withOpacity(0.6 * messageTextOpacity), ), const SizedBox(width: 6), if (forwardedSenderAvatarUrl != null) Container( width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: textColor.withOpacity(0.2 * messageTextOpacity), width: 1, ), ), child: ClipOval( child: Image.network( forwardedSenderAvatarUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( color: textColor.withOpacity( 0.1 * messageTextOpacity, ), child: Icon( Icons.person, size: 12, color: textColor.withOpacity( 0.5 * messageTextOpacity, ), ), ); }, ), ), ) else Container( width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( shape: BoxShape.circle, color: textColor.withOpacity(0.1 * messageTextOpacity), border: Border.all( color: textColor.withOpacity(0.2 * messageTextOpacity), width: 1, ), ), child: Icon( Icons.person, size: 12, color: textColor.withOpacity(0.5 * messageTextOpacity), ), ), Flexible( child: Text( forwardedSenderName, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: textColor.withOpacity(0.9 * messageTextOpacity), ), overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 6), // Содержимое пересланного сообщения (фото и/или текст) if (attaches.isNotEmpty) ...[ ..._buildPhotosWithCaption( context, attaches, // Передаем вложения из вложенного сообщения textColor, isUltraOptimized, messageTextOpacity, ), const SizedBox(height: 6), ], if (text.isNotEmpty) Text( text, style: TextStyle( color: textColor.withOpacity(0.9 * messageTextOpacity), fontSize: 14, ), ), ], ), ); } Widget _buildVideoCirclePlayer({ required BuildContext context, required int videoId, required String messageId, String? highQualityUrl, Uint8List? lowQualityBytes, }) { return _VideoCirclePlayer( videoId: videoId, messageId: messageId, chatId: chatId!, highQualityUrl: highQualityUrl, lowQualityBytes: lowQualityBytes, ); } Widget _buildVideoPreview({ required BuildContext context, required int videoId, required String messageId, String? highQualityUrl, Uint8List? lowQualityBytes, int? videoType, }) { // Логика открытия плеера void openFullScreenVideo() async { // Показываем индикатор загрузки, пока получаем URL showDialog( context: context, barrierDismissible: false, builder: (context) => const Center(child: CircularProgressIndicator()), ); try { final videoUrl = await ApiService.instance.getVideoUrl( videoId, chatId!, // chatId из `build` messageId, ); if (!context.mounted) return; // [!code ++] Проверка правильным способом Navigator.pop(context); // Убираем индикатор Navigator.push( context, MaterialPageRoute( builder: (context) => FullScreenVideoPlayer(videoUrl: videoUrl), ), ); } catch (e) { if (!context.mounted) return; // [!code ++] Проверка правильным способом Navigator.pop(context); // Убираем индикатор ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось загрузить видео: $e'), backgroundColor: Colors.red, ), ); } } final isVideoCircle = videoType == 1; if (isVideoCircle) { return _buildVideoCirclePlayer( context: context, videoId: videoId, messageId: messageId, highQualityUrl: highQualityUrl, lowQualityBytes: lowQualityBytes, ); } return GestureDetector( onTap: openFullScreenVideo, child: AspectRatio( aspectRatio: 16 / 9, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Stack( alignment: Alignment.center, fit: StackFit.expand, children: [ // Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage (highQualityUrl != null && highQualityUrl.isNotEmpty) || (lowQualityBytes != null) ? _ProgressiveNetworkImage( url: highQualityUrl ?? '', previewBytes: lowQualityBytes, width: 220, height: 160, fit: BoxFit.cover, keepAlive: false, ) : Container( color: Colors.black26, child: const Center( child: Icon( Icons.video_library_outlined, color: Colors.white, size: 40, ), ), ), Container( decoration: BoxDecoration( color: Colors.black.withOpacity(0.15), ), child: Icon( Icons.play_circle_filled_outlined, color: Colors.white.withOpacity(0.95), size: 50, shadows: const [ Shadow( color: Colors.black38, blurRadius: 4, offset: Offset(0, 2), ), ], ), ), ], ), ), ), ); } Widget _buildReplyPreview( BuildContext context, Map link, Color textColor, double messageTextOpacity, bool isUltraOptimized, double messageBorderRadius, ) { final replyMessage = link['message'] as Map?; if (replyMessage == null) return const SizedBox.shrink(); final replyText = replyMessage['text'] as String? ?? ''; final replySenderId = replyMessage['sender'] as int?; final replyMessageId = replyMessage['id'] as String?; final isDarkMode = Theme.of(context).brightness == Brightness.dark; Color replyAccentColor; if (useAutoReplyColor) { replyAccentColor = _getUserColor(replySenderId ?? 0, context); } else { replyAccentColor = customReplyColor ?? (isDarkMode ? const Color(0xFF90CAF9) : const Color(0xFF1976D2)); } // Вычисляем оптимальную ширину на основе длины текста final textLength = replyText.length; final minWidth = 120.0; // Минимальная ширина для коротких сообщений // Адаптивная ширина: минимум 120px, растет в зависимости от длины текста double adaptiveWidth = minWidth; if (textLength > 0) { // Базовый расчет: примерно 8px на символ + отступы adaptiveWidth = (textLength * 8.0 + 32).clamp(minWidth, double.infinity); } return GestureDetector( onTap: () { // Вызываем callback для прокрутки к оригинальному сообщению if (replyMessageId != null && onReplyTap != null) { onReplyTap!(replyMessageId); } }, child: Container( constraints: BoxConstraints(minWidth: minWidth, minHeight: 40), width: adaptiveWidth, // Используем адаптивную ширину padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: isDarkMode ? replyAccentColor.withOpacity( 0.15, ) // Полупрозрачный фон для темной темы : replyAccentColor.withOpacity( 0.08, ), // Более прозрачный для светлой borderRadius: BorderRadius.circular( (isUltraOptimized ? 4 : messageBorderRadius) * 0.3, ), border: Border( left: BorderSide( color: replyAccentColor, // Цвет левой границы width: 2, ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ // Ник автора сообщения Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.reply, size: 12, color: replyAccentColor), const SizedBox(width: 4), Expanded( child: Text( replySenderId != null ? (contactDetailsCache?[replySenderId]?.name ?? 'ID $replySenderId') : 'Пользователь', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: replyAccentColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 2), // Текст сообщения Align( alignment: Alignment.centerLeft, child: Text( replyText.isNotEmpty ? replyText : 'Фото', style: const TextStyle(fontSize: 11, color: Colors.white), maxLines: 2, overflow: TextOverflow.ellipsis, softWrap: true, textAlign: TextAlign.left, ), ), ], ), ), ); } /* void _showMessageContextMenu(BuildContext context) { // Список реакций, отсортированный по популярности const reactions = [ '👍', '❤️', '😂', '🔥', '👏', '👌', '🎉', '🥰', '😍', '🙏', '🤔', '🤯', '💯', '⚡️', '🤟', '🌚', '🌝', '🥱', '🤣', '🫠', '🫡', '🐱', '💋', '😘', '🐶', '🤝', '⭐️', '🍷', '🍑', '😁', '🤷‍♀️', '🤷‍♂️', '👩‍❤️‍👨', '🦄', '👻', '🗿', '❤️‍🩹', '🛑', '⛄️', '❓', '🙄', '❗️', '😉', '😳', '🥳', '😎', '💪', '👀', '🤞', '🤤', '🤪', '🤩', '😴', '😐', '😇', '🖤', '👑', '👋', '👁️', ]; // Проверяем, есть ли уже реакция от пользователя final hasUserReaction = message.reactionInfo != null && message.reactionInfo!['yourReaction'] != null; showModalBottomSheet( context: context, backgroundColor: Colors .transparent, // Фон делаем прозрачным, чтобы скругление было видно builder: (context) => Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ // Реакции if (onReaction != null) ...[ // Контейнер для прокручиваемого списка эмодзи SizedBox( height: 80, // Задаем высоту для ряда с реакциями child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 12.0, ), child: Row( children: [ ...reactions.map( (emoji) => Padding( padding: const EdgeInsets.symmetric( horizontal: 4.0, ), child: GestureDetector( onTap: () { Navigator.pop(context); onReaction!(emoji); }, child: Text( emoji, style: const TextStyle(fontSize: 32), ), ), ), ), ], ), ), ), // Кнопка удаления реакции, если есть реакция от пользователя if (hasUserReaction && onRemoveReaction != null) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () { Navigator.pop(context); onRemoveReaction!(); }, icon: const Icon(Icons.remove_circle_outline), label: const Text('Убрать реакцию'), style: ElevatedButton.styleFrom( backgroundColor: Theme.of( context, ).colorScheme.errorContainer, foregroundColor: Theme.of( context, ).colorScheme.onErrorContainer, ), ), ), ), ], const Divider(height: 1), ], // Действия с сообщением (остаются без изменений) if (onReply != null && !isChannel) ListTile( leading: const Icon(Icons.reply), title: const Text('Ответить'), onTap: () { Navigator.pop(context); onReply!(); }, ), if (onEdit != null) ListTile( leading: Icon( canEditMessage == false ? Icons.edit_off : Icons.edit, color: canEditMessage == false ? Colors.grey : null, ), title: Text( canEditMessage == false ? 'Редактировать (недоступно)' : 'Редактировать', style: TextStyle( color: canEditMessage == false ? Colors.grey : null, ), ), onTap: canEditMessage == false ? null : () { Navigator.pop(context); onEdit!(); }, ), if (onDeleteForMe != null || onDeleteForAll != null || onDelete != null) ...[ if (onEdit != null) const Divider(height: 1), if (onDeleteForMe != null) ListTile( leading: const Icon( Icons.person_remove, color: Colors.redAccent, ), title: const Text( 'Удалить у меня', style: TextStyle(color: Colors.redAccent), ), onTap: () { Navigator.pop(context); onDeleteForMe?.call(); }, ), if (onDeleteForAll != null) ListTile( leading: const Icon( Icons.delete_forever, color: Colors.red, ), title: const Text( 'Удалить у всех', style: TextStyle(color: Colors.red), ), onTap: () { Navigator.pop(context); onDeleteForAll?.call(); }, ), if (onDelete != null && onDeleteForMe == null && onDeleteForAll == null) ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: const Text( 'Удалить', style: TextStyle(color: Colors.red), ), onTap: () { Navigator.pop(context); onDelete!.call(); }, ), ], ], ), ), ), ); } */ void _showMessageContextMenu(BuildContext context, Offset tapPosition) { final hasUserReaction = message.reactionInfo?['yourReaction'] != null; showDialog( context: context, barrierColor: Colors.transparent, // Делаем фон прозрачным builder: (context) { return _MessageContextMenu( message: message, position: tapPosition, onReply: onReply, onEdit: onEdit, onDeleteForMe: onDeleteForMe, onDeleteForAll: onDeleteForAll, onReaction: onReaction, onRemoveReaction: onRemoveReaction, onForward: onForward, onComplain: onComplain, canEditMessage: canEditMessage ?? false, hasUserReaction: hasUserReaction, isChannel: isChannel, ); }, ); } Widget _buildReactionsWidget(BuildContext context, Color textColor) { if (message.reactionInfo == null || message.reactionInfo!['counters'] == null) { return const SizedBox.shrink(); } final counters = message.reactionInfo!['counters'] as List; if (counters.isEmpty) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 8.0), child: Wrap( spacing: 4.0, runSpacing: 4.0, alignment: isMe ? WrapAlignment.end : WrapAlignment.start, children: counters.map((counter) { final emoji = counter['reaction'] as String; final count = counter['count'] as int; final isUserReaction = message.reactionInfo!['yourReaction'] == emoji; return GestureDetector( onTap: () { if (isUserReaction) { // Если это наша реакция - удаляем onRemoveReaction?.call(); } else { // Если это чужая реакция - добавляем такую же onReaction?.call(emoji); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: isUserReaction ? Theme.of(context).colorScheme.primary.withOpacity(0.3) : textColor.withOpacity(0.1), borderRadius: BorderRadius.circular(16), ), child: Text( '$emoji $count', style: TextStyle( fontSize: 12, fontWeight: isUserReaction ? FontWeight.w600 : FontWeight.w500, color: isUserReaction ? Theme.of(context).colorScheme.primary : textColor.withOpacity(0.9), ), ), ), ); }).toList(), ), ); } Widget build(BuildContext context) { final themeProvider = Provider.of(context); final isUltraOptimized = themeProvider.ultraOptimizeChats; final isStickerOnly = message.attaches.length == 1 && message.attaches.any((a) => a['_type'] == 'STICKER') && message.text.isEmpty; if (isStickerOnly) { return _buildStickerOnlyMessage(context); } final isVideoCircle = message.attaches.length == 1 && message.attaches.any( (a) => a['_type'] == 'VIDEO' && (a['videoType'] as int?) == 1, ) && message.text.isEmpty; if (isVideoCircle) { return _buildVideoCircleOnlyMessage(context); } final isPhotoOnly = message.attaches.isNotEmpty && message.attaches.every((a) => a['_type'] == 'PHOTO') && message.text.isEmpty && !message.isReply && !message.isForwarded; if (isPhotoOnly) { return _buildPhotoOnlyMessage(context); } final isVideoOnly = message.attaches.isNotEmpty && message.attaches.every((a) => a['_type'] == 'VIDEO') && message.attaches.every((a) => (a['videoType'] as int?) != 1) && message.text.isEmpty && !message.isReply && !message.isForwarded; if (isVideoOnly) { return _buildVideoOnlyMessage(context); } final hasUnsupportedContent = _hasUnsupportedMessageTypes(); final messageOpacity = themeProvider.messageBubbleOpacity; final messageTextOpacity = themeProvider.messageTextOpacity; final messageShadowIntensity = themeProvider.messageShadowIntensity; final messageBorderRadius = themeProvider.messageBorderRadius; // Сообщение только с файлами (без текста и без reply/forward) final isFileOnly = message.attaches.isNotEmpty && message.attaches.every((a) => a['_type'] == 'FILE') && message.text.isEmpty && !message.isReply && !message.isForwarded; final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity); final textColor = _getTextColor( isMe, bubbleColor, messageTextOpacity, context, ); // Обычный пузырь, но для сообщений только с файлами делаем фон прозрачным, // чтобы визуально не было "бабла" вокруг карточек файлов. BoxDecoration bubbleDecoration; if (isFileOnly) { bubbleDecoration = const BoxDecoration( color: Colors.transparent, ); } else { bubbleDecoration = _createBubbleDecoration( bubbleColor, messageBorderRadius, messageShadowIntensity, ); } if (hasUnsupportedContent) { return _buildUnsupportedMessage( context, bubbleColor, textColor, bubbleDecoration, ); } final baseTextStyle = Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); final defaultTextStyle = baseTextStyle.copyWith(color: textColor); final linkColor = _getLinkColor(bubbleColor, isMe); final linkStyle = baseTextStyle.copyWith( color: linkColor, decoration: TextDecoration.underline, decorationColor: linkColor, ); Future _onOpenLink(LinkableElement link) async { final uri = Uri.parse(link.url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Не удалось открыть ссылку: ${link.url}')), ); } } } void _onSenderNameTap() { openUserProfileById(context, message.senderId); } final messageContentChildren = _buildMessageContentChildren( context, textColor, messageTextOpacity, isUltraOptimized, linkStyle, defaultTextStyle, messageBorderRadius, _onOpenLink, _onSenderNameTap, ); Widget messageContent = _buildMessageContentInner( context, bubbleDecoration, messageContentChildren, ); if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { messageContent = GestureDetector( onTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: messageContent, ); } else { messageContent = GestureDetector( onSecondaryTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: messageContent, ); } } return Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ SizedBox( width: 40, child: isLastInGroup ? Transform.translate( offset: Offset(0, avatarVerticalOffset), child: _buildSenderAvatar(), ) : null, ), ], Flexible(child: messageContent), ], ), ], ); } List _buildInlineKeyboard( BuildContext context, List> attaches, Color textColor, ) { // 1. Ищем вложение с клавиатурой final keyboardAttach = attaches.firstWhere( (a) => a['_type'] == 'INLINE_KEYBOARD', orElse: () => {}, // Возвращаем пустую карту, если не найдено ); if (keyboardAttach.isEmpty) { return []; // Нет клавиатуры } // 2. Парсим структуру кнопок final keyboardData = keyboardAttach['keyboard'] as Map?; final buttonRows = keyboardData?['buttons'] as List?; if (buttonRows == null || buttonRows.isEmpty) { return []; // Нет кнопок } final List rows = []; // 3. Создаем виджеты для каждого ряда кнопок for (final row in buttonRows) { if (row is List && row.isNotEmpty) { final List buttonsInRow = []; // 4. Создаем виджеты для каждой кнопки в ряду for (final buttonData in row) { if (buttonData is Map) { final String? text = buttonData['text'] as String?; final String? type = buttonData['type'] as String?; final String? url = buttonData['url'] as String?; // Нас интересуют только кнопки-ссылки (как в вашем JSON) if (text != null && type == 'LINK' && url != null) { buttonsInRow.add( Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2.0), child: FilledButton( onPressed: () => _launchURL(context, url), // Открываем ссылку style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12, ), // Стилизуем под цвет сообщения backgroundColor: textColor.withOpacity(0.1), foregroundColor: textColor.withOpacity(0.9), ), child: Text( text, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), ), ), ), ); } } } // Добавляем готовый ряд кнопок if (buttonsInRow.isNotEmpty) { rows.add( Padding( padding: const EdgeInsets.only(top: 4.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: buttonsInRow, ), ), ); } } } // Возвращаем Column с рядами кнопок if (rows.isNotEmpty) { return [ Padding( padding: const EdgeInsets.only(top: 8.0), child: Column(children: rows), ), ]; } return []; } // Helper-метод для открытия ссылок Future _launchURL(BuildContext context, String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось открыть ссылку: $url'), backgroundColor: Colors.red, ), ); } } } bool _hasUnsupportedMessageTypes() { final hasUnsupportedAttachments = message.attaches.any((attach) { final type = attach['_type']?.toString().toUpperCase(); return type == 'VOICE' || type == 'GIF' || type == 'LOCATION' || type == 'CONTACT'; }); return hasUnsupportedAttachments; } Widget _buildUnsupportedMessage( BuildContext context, Color bubbleColor, Color textColor, BoxDecoration bubbleDecoration, ) { return Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ //шлем в пезду аватарку если это я, анал. SizedBox( width: 40, child: isLastInGroup //Если это соо в группе, и оно последнее в группе соо ? Transform.translate( offset: Offset(0, avatarVerticalOffset), child: _buildSenderAvatar(), ) : null, ), ], Flexible( child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, ), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), margin: _getMessageMargin(context), decoration: bubbleDecoration, child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Имя отправителя if (isGroupChat && !isMe && senderName != null) Padding( padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), child: Text( senderName!, style: TextStyle( fontWeight: FontWeight.bold, color: _getUserColor( message.senderId, context, ).withOpacity(0.8), fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isGroupChat && !isMe && senderName != null) const SizedBox(height: 4), Text( 'Это сообщение не поддерживается в Вашей версии Komet. ' 'Пожалуйста, обновитесь до последней версии. ' 'Если Вы уже используете свежую версию приложения, ' 'возможно, в сообщении используется нововведение, ' 'которое пока не поддерживается.', style: TextStyle( color: textColor, fontStyle: FontStyle.italic, fontSize: 14, ), textAlign: TextAlign.left, ), const SizedBox(height: 8.0), Row( mainAxisSize: MainAxisSize.min, children: [ if (isMe) ...[ if (message.isEdited) ...[ Text( '(изменено)', style: TextStyle( fontSize: 10, color: textColor.withOpacity(0.5), fontStyle: FontStyle.italic, ), ), const SizedBox(width: 6), ], Text( _formatMessageTime(context, message.time), style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280), ), ), ], if (!isMe) ...[ Text( _formatMessageTime(context, message.time), style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280), ), ), if (message.isEdited) ...[ const SizedBox(width: 6), Text( '(изменено)', style: TextStyle( fontSize: 10, color: textColor.withOpacity(0.5), fontStyle: FontStyle.italic, ), ), ], ], ], ), ], ), ), ), ], ), ], ); } Widget _buildStickerOnlyMessage(BuildContext context) { final sticker = message.attaches.firstWhere((a) => a['_type'] == 'STICKER'); final stickerSize = 170.0; final timeColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280); return Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ SizedBox( width: 40, child: isLastInGroup ? Transform.translate( offset: Offset(0, avatarVerticalOffset), child: _buildSenderAvatar(), ) : null, ), ], Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => _openPhotoViewer(context, sticker), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: stickerSize, maxHeight: stickerSize, ), child: _buildStickerImage(context, sticker), ), ), Padding( padding: const EdgeInsets.only(top: 4, right: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatMessageTime(context, message.time), style: TextStyle(fontSize: 12, color: timeColor), ), ], ), ), ], ), ], ), ], ); } Widget _buildVideoCircleOnlyMessage(BuildContext context) { final video = message.attaches.firstWhere((a) => a['_type'] == 'VIDEO'); final videoId = video['videoId'] as int?; final previewData = video['previewData'] as String?; final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; Uint8List? previewBytes; if (previewData != null && previewData.startsWith('data:')) { final idx = previewData.indexOf('base64,'); if (idx != -1) { final b64 = previewData.substring(idx + 7); try { previewBytes = base64Decode(b64); } catch (_) {} } } String? highQualityThumbnailUrl; if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { highQualityThumbnailUrl = thumbnailUrl; if (!thumbnailUrl.contains('?')) { highQualityThumbnailUrl = '$thumbnailUrl?size=medium&quality=high&format=jpeg'; } else { highQualityThumbnailUrl = '$thumbnailUrl&size=medium&quality=high&format=jpeg'; } } final timeColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280); Widget videoContent = Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (videoId != null && chatId != null) _buildVideoCirclePlayer( context: context, videoId: videoId, messageId: message.id, highQualityUrl: highQualityThumbnailUrl, lowQualityBytes: previewBytes, ), Padding( padding: const EdgeInsets.only(top: 4, right: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatMessageTime(context, message.time), style: TextStyle(fontSize: 12, color: timeColor), ), ], ), ), ], ), ], ), ], ), ); if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { videoContent = GestureDetector( onTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: videoContent, ); } else { videoContent = GestureDetector( onSecondaryTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: videoContent, ); } } return videoContent; } Widget _buildPhotoOnlyMessage(BuildContext context) { final photos = message.attaches .where((a) => a['_type'] == 'PHOTO') .toList(); final themeProvider = Provider.of(context); final isUltraOptimized = themeProvider.ultraOptimizeChats; final messageOpacity = themeProvider.messageBubbleOpacity; final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity); final textColor = _getTextColor( isMe, bubbleColor, themeProvider.messageTextOpacity, context, ); final timeColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280); Widget photoContent = Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ SizedBox( width: 40, child: isLastInGroup ? Transform.translate( offset: Offset(0, avatarVerticalOffset), child: _buildSenderAvatar(), ) : null, ), ], Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ _buildSmartPhotoGroup( context, photos, textColor, isUltraOptimized, ), Padding( padding: const EdgeInsets.only(top: 4, right: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatMessageTime(context, message.time), style: TextStyle(fontSize: 12, color: timeColor), ), ], ), ), ], ), ], ), ], ), ); if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { photoContent = GestureDetector( onTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: photoContent, ); } else { photoContent = GestureDetector( onTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: photoContent, ); } } return photoContent; } Widget _buildVideoOnlyMessage(BuildContext context) { final videos = message.attaches .where((a) => a['_type'] == 'VIDEO') .toList(); final timeColor = Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280); Widget videoContent = Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ ...videos.asMap().entries.map((entry) { final index = entry.key; final video = entry.value; final videoId = video['videoId'] as int?; final videoType = video['videoType'] as int?; final previewData = video['previewData'] as String?; final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; Uint8List? previewBytes; if (previewData != null && previewData.startsWith('data:')) { final idx = previewData.indexOf('base64,'); if (idx != -1) { final b64 = previewData.substring(idx + 7); try { previewBytes = base64Decode(b64); } catch (_) {} } } String? highQualityThumbnailUrl; if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { highQualityThumbnailUrl = thumbnailUrl; if (!thumbnailUrl.contains('?')) { highQualityThumbnailUrl = '$thumbnailUrl?size=medium&quality=high&format=jpeg'; } else { highQualityThumbnailUrl = '$thumbnailUrl&size=medium&quality=high&format=jpeg'; } } return Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel && index == 0) ...[ SizedBox( width: 40, child: isLastInGroup ? Transform.translate( offset: Offset(0, avatarVerticalOffset), child: _buildSenderAvatar(), ) : null, ), ], Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (videoId != null && chatId != null) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 300), child: _buildVideoPreview( context: context, videoId: videoId, messageId: message.id, highQualityUrl: highQualityThumbnailUrl, lowQualityBytes: previewBytes, videoType: videoType, ), ), ), if (index == videos.length - 1) Padding( padding: const EdgeInsets.only(top: 4, right: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _formatMessageTime(context, message.time), style: TextStyle( fontSize: 12, color: timeColor, ), ), ], ), ), ], ), ], ), ], ); }).toList(), ], ), ); if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { if (isMobile) { videoContent = GestureDetector( onTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: videoContent, ); } else { videoContent = GestureDetector( onSecondaryTapDown: (TapDownDetails details) { _showMessageContextMenu(context, details.globalPosition); }, child: videoContent, ); } } return videoContent; } Widget _buildStickerImage( BuildContext context, Map sticker, ) { final url = sticker['url'] ?? sticker['baseUrl']; if (url is String && url.isNotEmpty) { if (url.startsWith('file://')) { final path = url.replaceFirst('file://', ''); return Image.file( File(path), fit: BoxFit.contain, filterQuality: FilterQuality.high, ); } else { return Image.network( url, fit: BoxFit.contain, filterQuality: FilterQuality.high, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) { return child; } return const Center(child: CircularProgressIndicator()); }, errorBuilder: (context, error, stackTrace) { return _imagePlaceholder(); }, ); } } return _imagePlaceholder(); } String? _extractFirstPhotoUrl(List> attaches) { for (final a in attaches) { if (a['_type'] == 'PHOTO') { final dynamic maybe = a['url'] ?? a['baseUrl']; if (maybe is String && maybe.isNotEmpty) return maybe; } } return null; } List _buildPhotosWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final photos = attaches.where((a) => a['_type'] == 'PHOTO').toList(); final List widgets = []; if (photos.isEmpty) return widgets; // Умная группировка фотографий widgets.add( _buildSmartPhotoGroup(context, photos, textColor, isUltraOptimized), ); widgets.add(const SizedBox(height: 6)); return widgets; } List _buildVideosWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final videos = attaches.where((a) => a['_type'] == 'VIDEO').toList(); final List widgets = []; if (videos.isEmpty) return widgets; for (final video in videos) { // 1. Извлекаем все, что нам нужно final videoId = video['videoId'] as int?; final videoType = video['videoType'] as int?; final previewData = video['previewData'] as String?; // Блюр-превью final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL // 2. Декодируем блюр-превью Uint8List? previewBytes; if (previewData != null && previewData.startsWith('data:')) { final idx = previewData.indexOf('base64,'); if (idx != -1) { final b64 = previewData.substring(idx + 7); try { previewBytes = base64Decode(b64); } catch (_) {} } } // 3. Формируем URL для HQ-превью (как для фото) String? highQualityThumbnailUrl; if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { highQualityThumbnailUrl = thumbnailUrl; if (!thumbnailUrl.contains('?')) { highQualityThumbnailUrl = '$thumbnailUrl?size=medium&quality=high&format=jpeg'; } else { highQualityThumbnailUrl = '$thumbnailUrl&size=medium&quality=high&format=jpeg'; } } // 4. Создаем виджет if (videoId != null && chatId != null) { widgets.add( Padding( padding: const EdgeInsets.only(bottom: 4.0), child: _buildVideoPreview( context: context, videoId: videoId, messageId: message.id, highQualityUrl: highQualityThumbnailUrl, lowQualityBytes: previewBytes, videoType: videoType, ), ), ); } else { // Заглушка, если вложение есть, а ID не найдены widgets.add( Container( padding: const EdgeInsets.all(16), color: Colors.black12, child: Row( children: [ Icon(Icons.videocam_off, color: textColor), const SizedBox(width: 8), Text( 'Видео повреждено (нет ID)', style: TextStyle(color: textColor), ), ], ), ), ); } } widgets.add(const SizedBox(height: 6)); return widgets; } List _buildStickersWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final stickers = attaches.where((a) => a['_type'] == 'STICKER').toList(); final List widgets = []; if (stickers.isEmpty) return widgets; for (final sticker in stickers) { widgets.add( _buildStickerWidget(context, sticker, textColor, isUltraOptimized), ); widgets.add(const SizedBox(height: 6)); } return widgets; } Widget _buildStickerWidget( BuildContext context, Map sticker, Color textColor, bool isUltraOptimized, ) { // Стикеры обычно квадратные, около 200-250px final stickerSize = 170.0; return ConstrainedBox( constraints: BoxConstraints( maxWidth: stickerSize, maxHeight: stickerSize, ), child: GestureDetector( onTap: () => _openPhotoViewer(context, sticker), child: ClipRRect( borderRadius: BorderRadius.circular(isUltraOptimized ? 8 : 12), child: _buildPhotoWidget(context, sticker), ), ), ); } List _buildCallsWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final calls = attaches.where((a) { final type = a['_type']; return type == 'CALL' || type == 'call'; }).toList(); final List widgets = []; if (calls.isEmpty) return widgets; for (final call in calls) { widgets.add( _buildCallWidget( context, call, textColor, isUltraOptimized, messageTextOpacity, ), ); widgets.add(const SizedBox(height: 6)); } return widgets; } Widget _buildCallWidget( BuildContext context, Map callData, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final hangupType = callData['hangupType'] as String? ?? ''; final callType = callData['callType'] as String? ?? 'AUDIO'; final duration = callData['duration'] as int? ?? 0; final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); String callText; IconData callIcon; Color callColor; // Определяем текст, иконку и цвет в зависимости от типа завершения звонка switch (hangupType) { case 'HUNGUP': // Звонок был завершен успешно final minutes = duration ~/ 60000; final seconds = (duration % 60000) ~/ 1000; final durationText = minutes > 0 ? '$minutes:${seconds.toString().padLeft(2, '0')}' : '$seconds сек'; final callTypeText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callText = '$callTypeText, $durationText'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = Theme.of(context).colorScheme.primary; break; case 'MISSED': // Пропущенный звонок final callTypeText = callType == 'VIDEO' ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; callText = callTypeText; callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_missed; callColor = Theme.of(context).colorScheme.error; break; case 'CANCELED': // Звонок отменен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отменен' : 'Звонок отменен'; callText = callTypeText; callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; callColor = textColor.withOpacity(0.6); break; case 'REJECTED': // Звонок отклонен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отклонен' : 'Звонок отклонен'; callText = callTypeText; callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; callColor = textColor.withOpacity(0.6); break; default: // Неизвестный тип завершения callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = textColor.withOpacity(0.6); break; } return Container( decoration: BoxDecoration( color: callColor.withOpacity(0.1), borderRadius: borderRadius, border: Border.all(color: callColor.withOpacity(0.3), width: 1), ), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // Call icon Container( width: 48, height: 48, decoration: BoxDecoration( color: callColor.withOpacity(0.2), borderRadius: BorderRadius.circular(8), ), child: Icon(callIcon, color: callColor, size: 24), ), const SizedBox(width: 12), // Call info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( callText, style: TextStyle( color: callColor, fontSize: 14, fontWeight: FontWeight.w500, ), ), ], ), ), ], ), ), ); } List _buildFilesWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, int? chatId, ) { final files = attaches.where((a) => a['_type'] == 'FILE').toList(); final List widgets = []; if (files.isEmpty) return widgets; for (final file in files) { final fileName = file['name'] ?? 'Файл'; final fileSize = file['size'] as int? ?? 0; final preview = file['preview'] as Map?; final isMusic = preview != null && preview['_type'] == 'MUSIC'; if (isMusic) { widgets.add( _buildMusicFileWidget( context, fileName, fileSize, file, textColor, isUltraOptimized, chatId, ), ); } else { widgets.add( _buildFileWidget( context, fileName, fileSize, file, textColor, isUltraOptimized, chatId, ), ); } widgets.add(const SizedBox(height: 6)); } return widgets; } Widget _buildFileWidget( BuildContext context, String fileName, int fileSize, Map fileData, Color textColor, bool isUltraOptimized, int? chatId, ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); // Get file extension final extension = _getFileExtension(fileName); final iconData = _getFileIcon(extension); // Format file size final sizeStr = _formatFileSize(fileSize); // Extract file data final fileId = fileData['fileId'] as int?; final token = fileData['token'] as String?; return GestureDetector( onTap: () => _handleFileDownload(context, fileId, token, fileName, chatId), child: Container( decoration: BoxDecoration( color: textColor.withOpacity(0.05), borderRadius: borderRadius, border: Border.all(color: textColor.withOpacity(0.1), width: 1), ), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // File icon Container( width: 48, height: 48, decoration: BoxDecoration( color: textColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( iconData, color: textColor.withOpacity(0.8), size: 24, ), ), const SizedBox(width: 12), // File info with progress Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( fileName, style: TextStyle( color: textColor, fontSize: 14, fontWeight: FontWeight.w500, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService() .getProgress(fileId.toString()), builder: (context, progress, child) { if (progress < 0) { // Not downloading return Text( sizeStr, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 12, ), ); } else if (progress < 1.0) { // Downloading return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LinearProgressIndicator( value: progress, minHeight: 3, backgroundColor: textColor.withOpacity(0.1), ), const SizedBox(height: 4), Text( '${(progress * 100).toStringAsFixed(0)}%', style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), ], ); } else { // Completed return Row( children: [ Icon( Icons.check_circle, size: 12, color: Colors.green.withOpacity(0.8), ), const SizedBox(width: 4), Text( 'Загружено', style: TextStyle( color: Colors.green.withOpacity(0.8), fontSize: 11, ), ), ], ); } }, ) else Text( sizeStr, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 12, ), ), ], ), ), // Download icon if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService().getProgress( fileId.toString(), ), builder: (context, progress, child) { if (progress >= 0 && progress < 1.0) { return const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ); } return Icon( Icons.download_outlined, color: textColor.withOpacity(0.6), size: 20, ); }, ) else Icon( Icons.download_outlined, color: textColor.withOpacity(0.6), size: 20, ), ], ), ), ), ); } Widget _buildMusicFileWidget( BuildContext context, String fileName, int fileSize, Map fileData, Color textColor, bool isUltraOptimized, int? chatId, ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); final preview = fileData['preview'] as Map?; final fileId = fileData['fileId'] as int?; final token = fileData['token'] as String?; final title = preview?['title'] as String? ?? fileName; final artist = preview?['artistName'] as String? ?? 'Unknown Artist'; final album = preview?['albumName'] as String?; final albumArtUrl = preview?['baseUrl'] as String?; final duration = preview?['duration'] as int?; String durationText = ''; if (duration != null) { final durationSeconds = (duration / 1000).round(); final minutes = durationSeconds ~/ 60; final seconds = durationSeconds % 60; durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; } final sizeStr = _formatFileSize(fileSize); return GestureDetector( onTap: () async { final prefs = await SharedPreferences.getInstance(); final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final fileIdString = fileId?.toString(); bool isDownloaded = false; String? filePath; if (fileIdString != null) { for (final mapping in fileIdMap) { if (mapping.startsWith('$fileIdString:')) { filePath = mapping.substring(fileIdString.length + 1); final file = io.File(filePath); if (await file.exists()) { isDownloaded = true; break; } } } } if (!isDownloaded) { await _handleFileDownload(context, fileId, token, fileName, chatId); await Future.delayed(const Duration(seconds: 1)); if (fileIdString != null) { final updatedFileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; for (final mapping in updatedFileIdMap) { if (mapping.startsWith('$fileIdString:')) { filePath = mapping.substring(fileIdString.length + 1); final file = io.File(filePath); if (await file.exists()) { isDownloaded = true; break; } } } } } if (isDownloaded && filePath != null) { final track = MusicTrack( id: fileId?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), title: title, artist: artist, album: album, albumArtUrl: albumArtUrl, duration: duration, filePath: filePath, fileId: fileId, token: token, chatId: chatId, ); final musicMetadataJson = prefs.getString('music_metadata') ?? '{}'; final musicMetadata = jsonDecode(musicMetadataJson) as Map; musicMetadata[fileIdString ?? ''] = track.toJson(); await prefs.setString('music_metadata', jsonEncode(musicMetadata)); final musicPlayer = MusicPlayerService(); await musicPlayer.playTrack(track); } }, child: Container( decoration: BoxDecoration( color: textColor.withOpacity(0.05), borderRadius: borderRadius, border: Border.all(color: textColor.withOpacity(0.1), width: 1), ), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Container( width: 56, height: 56, color: textColor.withOpacity(0.1), child: albumArtUrl != null ? Image.network( albumArtUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( Icons.music_note, color: textColor.withOpacity(0.8), size: 24, ), ) : Icon( Icons.music_note, color: textColor.withOpacity(0.8), size: 24, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( title, style: TextStyle( color: textColor, fontSize: 14, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( artist, style: TextStyle( color: textColor.withOpacity(0.7), fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (album != null) ...[ const SizedBox(height: 2), Text( album, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 4), if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService() .getProgress(fileId.toString()), builder: (context, progress, child) { if (progress < 0) { return Row( children: [ if (durationText.isNotEmpty) ...[ Text( durationText, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), const SizedBox(width: 8), Text( '•', style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), const SizedBox(width: 8), ], Text( sizeStr, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), ], ); } else if (progress < 1.0) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LinearProgressIndicator( value: progress, minHeight: 3, backgroundColor: textColor.withOpacity(0.1), ), const SizedBox(height: 4), Text( '${(progress * 100).toStringAsFixed(0)}%', style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), ], ); } else { return Row( children: [ Icon( Icons.check_circle, size: 12, color: Colors.green.withOpacity(0.8), ), const SizedBox(width: 4), Text( 'Загружено', style: TextStyle( color: Colors.green.withOpacity(0.8), fontSize: 11, ), ), ], ); } }, ) else Row( children: [ if (durationText.isNotEmpty) ...[ Text( durationText, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), const SizedBox(width: 8), Text( '•', style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), const SizedBox(width: 8), ], Text( sizeStr, style: TextStyle( color: textColor.withOpacity(0.6), fontSize: 11, ), ), ], ), ], ), ), if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService().getProgress( fileId.toString(), ), builder: (context, progress, child) { if (progress >= 0 && progress < 1.0) { return const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ); } return IconButton( onPressed: () async { final prefs = await SharedPreferences.getInstance(); final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final fileIdString = fileId.toString(); bool isDownloaded = false; String? filePath; for (final mapping in fileIdMap) { if (mapping.startsWith('$fileIdString:')) { filePath = mapping.substring( fileIdString.length + 1, ); final file = io.File(filePath); if (await file.exists()) { isDownloaded = true; break; } } } if (!isDownloaded) { await _handleFileDownload( context, fileId, token, fileName, chatId, ); await Future.delayed(const Duration(seconds: 1)); final updatedFileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; for (final mapping in updatedFileIdMap) { if (mapping.startsWith('$fileIdString:')) { filePath = mapping.substring( fileIdString.length + 1, ); final file = io.File(filePath); if (await file.exists()) { isDownloaded = true; break; } } } } if (isDownloaded && filePath != null) { final track = MusicTrack( id: fileId.toString(), title: title, artist: artist, album: album, albumArtUrl: albumArtUrl, duration: duration, filePath: filePath, fileId: fileId, token: token, chatId: chatId, ); final musicMetadataJson = prefs.getString('music_metadata') ?? '{}'; final musicMetadata = jsonDecode(musicMetadataJson) as Map; musicMetadata[fileIdString] = track.toJson(); await prefs.setString( 'music_metadata', jsonEncode(musicMetadata), ); final musicPlayer = MusicPlayerService(); await musicPlayer.playTrack(track); } }, icon: const Icon(Icons.play_arrow), style: IconButton.styleFrom( backgroundColor: textColor.withOpacity(0.1), foregroundColor: textColor, ), ); }, ) else IconButton( onPressed: () async { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось загрузить файл'), backgroundColor: Colors.red, ), ); } }, icon: const Icon(Icons.play_arrow), style: IconButton.styleFrom( backgroundColor: textColor.withOpacity(0.1), foregroundColor: textColor, ), ), ], ), ), ), ); } String _getFileExtension(String fileName) { final parts = fileName.split('.'); if (parts.length > 1) { return parts.last.toLowerCase(); } return ''; } IconData _getFileIcon(String extension) { switch (extension) { case 'pdf': return Icons.picture_as_pdf; case 'doc': case 'docx': return Icons.description; case 'xls': case 'xlsx': return Icons.table_chart; case 'txt': return Icons.text_snippet; case 'zip': case 'rar': case '7z': return Icons.archive; case 'mp3': case 'wav': case 'flac': return Icons.audiotrack; case 'mp4': case 'avi': case 'mov': return Icons.video_file; case 'jpg': case 'jpeg': case 'png': case 'gif': return Icons.image; default: return Icons.insert_drive_file; } } String _formatFileSize(int bytes) { if (bytes < 1024) { return '$bytes B'; } else if (bytes < 1024 * 1024) { return '${(bytes / 1024).toStringAsFixed(1)} KB'; } else if (bytes < 1024 * 1024 * 1024) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } else { return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; } } List _buildAudioWithCaption( BuildContext context, List> attaches, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final audioMessages = attaches.where((a) => a['_type'] == 'AUDIO').toList(); final List widgets = []; if (audioMessages.isEmpty) return widgets; for (final audio in audioMessages) { widgets.add( _buildAudioWidget( context, audio, textColor, isUltraOptimized, messageTextOpacity, ), ); widgets.add(const SizedBox(height: 6)); } return widgets; } Widget _buildAudioWidget( BuildContext context, Map audioData, Color textColor, bool isUltraOptimized, double messageTextOpacity, ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); final url = audioData['url'] as String?; final duration = audioData['duration'] as int? ?? 0; final wave = audioData['wave'] as String?; final audioId = audioData['audioId'] as int?; // Format duration final durationSeconds = (duration / 1000).round(); final minutes = durationSeconds ~/ 60; final seconds = durationSeconds % 60; final durationText = '$minutes:${seconds.toString().padLeft(2, '0')}'; return _AudioPlayerWidget( url: url ?? '', duration: duration, durationText: durationText, wave: wave, audioId: audioId, textColor: textColor, borderRadius: borderRadius, messageTextOpacity: messageTextOpacity, ); } Future _handleFileDownload( BuildContext context, int? fileId, String? token, String fileName, int? chatId, ) async { // 1. Проверяем fileId, он нужен в любом случае if (fileId == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Не удалось загрузить информацию о файле (нет fileId)', ), backgroundColor: Colors.red, ), ); } return; } try { final prefs = await SharedPreferences.getInstance(); final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final fileIdString = fileId.toString(); // Ищем запись для нашего fileId final mapping = fileIdMap.firstWhere( (m) => m.startsWith('$fileIdString:'), orElse: () => '', // Возвращаем пустую строку, если не найдено ); if (mapping.isNotEmpty) { // Извлекаем путь из 'fileId:path/to/file' final filePath = mapping.substring(fileIdString.length + 1); final file = io.File(filePath); // Проверяем, существует ли файл физически if (await file.exists()) { print( 'Файл $fileName (ID: $fileId) найден локально: $filePath. Открываем...', ); // Файл существует, открываем его final result = await OpenFile.open(filePath); if (result.type != ResultType.done && context.mounted) { // Показываем ошибку, если не удалось открыть (например, нет приложения) ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось открыть файл: ${result.message}'), backgroundColor: Colors.red, ), ); } return; // Важно: выходим из функции, чтобы не скачивать заново } else { // Файл был в списке, но удален. Очистим некорректную запись. print( 'Файл $fileName (ID: $fileId) был в SharedPreferences, но удален. Начинаем загрузку.', ); fileIdMap.remove(mapping); await prefs.setStringList('file_id_to_path_map', fileIdMap); } } } catch (e) { print('Ошибка при проверке локального файла: $e. Продолжаем загрузку...'); // Если при проверке что-то пошло не так, просто продолжаем и скачиваем файл. } // Если файл не найден локально, продолжаем стандартную процедуру скачивания print( 'Файл $fileName (ID: $fileId) не найден. Запрашиваем URL у сервера...', ); if (token == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Не удалось загрузить информацию о файле (нет token)', ), backgroundColor: Colors.red, ), ); } return; } if (chatId == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось определить чат'), backgroundColor: Colors.red, ), ); } return; } try { // Request file URL from server using opcode 88 final messageId = message.id; // Send request for file URL via WebSocket final seq = ApiService.instance.sendRawRequest(88, { "fileId": fileId, "chatId": chatId, "messageId": messageId, }); if (seq == -1) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Не удалось отправить запрос на получение файла'), backgroundColor: Colors.red, ), ); } return; } // Wait for response with opcode 88 final response = await ApiService.instance.messages .firstWhere( (msg) => msg['seq'] == seq && msg['opcode'] == 88, orElse: () => {}, ) .timeout( const Duration(seconds: 10), onTimeout: () => throw TimeoutException( 'Превышено время ожидания ответа от сервера', ), ); if (response.isEmpty || response['payload'] == null) { throw Exception('Не получен ответ от сервера'); } final downloadUrl = response['payload']['url'] as String?; if (downloadUrl == null || downloadUrl.isEmpty) { throw Exception('Не получена ссылка на файл'); } // Download file to Downloads folder with progress await _downloadFile(downloadUrl, fileName, fileId.toString(), context); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка при скачивании файла: ${e.toString()}'), backgroundColor: Colors.red, ), ); } } } Future _downloadFile( String url, String fileName, String fileId, BuildContext context, ) async { // Download in background without blocking dialog _startBackgroundDownload(url, fileName, fileId, context); // Show immediate success snackbar if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Начато скачивание файла...'), duration: Duration(seconds: 2), ), ); } } void _startBackgroundDownload( String url, String fileName, String fileId, BuildContext context, ) async { // Initialize progress FileDownloadProgressService().updateProgress(fileId, 0.0); try { // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { downloadDir = await getExternalStorageDirectory(); } else if (io.Platform.isIOS) { final directory = await getApplicationDocumentsDirectory(); downloadDir = directory; } else if (io.Platform.isWindows || io.Platform.isLinux) { // For desktop platforms, use Downloads directory final homeDir = io.Platform.environment['HOME'] ?? io.Platform.environment['USERPROFILE'] ?? ''; downloadDir = io.Directory('$homeDir/Downloads'); } else { downloadDir = await getApplicationDocumentsDirectory(); } if (downloadDir == null || !await downloadDir.exists()) { throw Exception('Downloads directory not found'); } // Create the file path final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); // Download the file with progress tracking final request = http.Request('GET', Uri.parse(url)); final streamedResponse = await request.send(); if (streamedResponse.statusCode != 200) { throw Exception( 'Failed to download file: ${streamedResponse.statusCode}', ); } final contentLength = streamedResponse.contentLength ?? 0; final bytes = []; int received = 0; await for (final chunk in streamedResponse.stream) { bytes.addAll(chunk); received += chunk.length; // Update progress if content length is known if (contentLength > 0) { final progress = received / contentLength; FileDownloadProgressService().updateProgress(fileId, progress); } } // Write file to disk final data = Uint8List.fromList(bytes); await file.writeAsBytes(data); // Mark as completed FileDownloadProgressService().updateProgress(fileId, 1.0); // Save file path and fileId mapping to SharedPreferences for tracking final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; if (!downloadedFiles.contains(file.path)) { downloadedFiles.add(file.path); await prefs.setStringList('downloaded_files', downloadedFiles); } // Also save fileId -> filePath mapping to track downloaded files by fileId final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final mappingKey = '$fileId:${file.path}'; if (!fileIdMap.contains(mappingKey)) { fileIdMap.add(mappingKey); await prefs.setStringList('file_id_to_path_map', fileIdMap); } // Show success message if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Файл сохранен: $fileName'), duration: const Duration(seconds: 3), action: SnackBarAction(label: 'OK', onPressed: () {}), ), ); } } catch (e) { // Clear progress on error FileDownloadProgressService().clearProgress(fileId); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка при скачивании: ${e.toString()}'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } Widget _buildSmartPhotoGroup( BuildContext context, List> photos, Color textColor, bool isUltraOptimized, ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 4 : 12); switch (photos.length) { case 1: return _buildSinglePhoto(context, photos[0], borderRadius); case 2: return _buildTwoPhotos(context, photos, borderRadius); case 3: return _buildThreePhotos(context, photos, borderRadius); case 4: return _buildFourPhotos(context, photos, borderRadius); default: return _buildManyPhotos(context, photos, borderRadius); } } Widget _buildSinglePhoto( BuildContext context, Map photo, BorderRadius borderRadius, ) { return RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photo), child: ClipRRect( borderRadius: borderRadius, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180, maxWidth: 250), child: _buildPhotoWidget(context, photo), ), ), ), ); } Widget _buildTwoPhotos( BuildContext context, List> photos, BorderRadius borderRadius, ) { return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Row( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[0]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[0]), ), ), ), ), const SizedBox(width: 2), Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[1]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[1]), ), ), ), ), ], ), ); } Widget _buildThreePhotos( BuildContext context, List> photos, BorderRadius borderRadius, ) { return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Row( children: [ // Левая большая фотка Expanded( flex: 2, child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[0]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[0]), ), ), ), ), const SizedBox(width: 2), // Правая колонка с двумя маленькими Expanded( flex: 1, child: Column( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[1]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[1]), ), ), ), ), const SizedBox(height: 2), Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[2]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[2]), ), ), ), ), ], ), ), ], ), ); } Widget _buildFourPhotos( BuildContext context, List> photos, BorderRadius borderRadius, ) { return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Column( children: [ // Верхний ряд Expanded( child: Row( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[0]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[0]), ), ), ), ), const SizedBox(width: 2), Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[1]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[1]), ), ), ), ), ], ), ), const SizedBox(height: 2), // Нижний ряд Expanded( child: Row( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[2]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[2]), ), ), ), ), const SizedBox(width: 2), Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[3]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[3]), ), ), ), ), ], ), ), ], ), ); } Widget _buildManyPhotos( BuildContext context, List> photos, BorderRadius borderRadius, ) { // Для 5+ фотографий показываем сетку 2x2 + счетчик return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Column( children: [ Expanded( child: Row( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[0]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[0]), ), ), ), ), const SizedBox(width: 2), Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[1]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[1]), ), ), ), ), ], ), ), const SizedBox(height: 2), Expanded( child: Row( children: [ Expanded( child: RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[2]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[2]), ), ), ), ), const SizedBox(width: 2), Expanded( child: Stack( children: [ RepaintBoundary( child: GestureDetector( onTap: () => _openPhotoViewer(context, photos[3]), child: ClipRRect( borderRadius: borderRadius, child: _buildPhotoWidget(context, photos[3]), ), ), ), if (photos.length > 4) Positioned.fill( child: Container( decoration: BoxDecoration( color: Colors.black.withOpacity(0.6), borderRadius: borderRadius, ), child: Center( child: Text( '+${photos.length - 3}', style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), ), ), ), ], ), ), ], ), ), ], ), ); } void _openPhotoViewer(BuildContext context, Map attach) { final url = attach['url'] ?? attach['baseUrl']; final preview = attach['previewData']; Widget child; if (url is String && url.isNotEmpty) { if (url.startsWith('file://')) { final path = url.replaceFirst('file://', ''); child = Image.file( File(path), fit: BoxFit.contain, filterQuality: FilterQuality.high, ); } else { String fullQualityUrl = url; if (!url.contains('?')) { fullQualityUrl = '$url?size=original&quality=high&format=original'; } else { fullQualityUrl = '$url&size=original&quality=high&format=original'; } child = _ProgressiveNetworkImage( url: fullQualityUrl, previewBytes: null, width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, fit: BoxFit.contain, ); } } else if (preview is String && preview.startsWith('data:')) { final idx = preview.indexOf('base64,'); if (idx != -1) { final b64 = preview.substring(idx + 7); try { final bytes = base64Decode(b64); child = Image.memory(bytes, fit: BoxFit.contain); } catch (_) { child = _imagePlaceholder(); } } else { child = _imagePlaceholder(); } } else { child = _imagePlaceholder(); } // Используем навигатор для перехода на новый полноэкранный виджет Navigator.of(context).push( PageRouteBuilder( opaque: false, // Делаем страницу прозрачной для красивого перехода barrierColor: Colors.black, pageBuilder: (BuildContext context, _, __) { // Возвращаем наш новый экран просмотра return FullScreenPhotoViewer(imageChild: child, attach: attach); }, // Добавляем плавное появление transitionsBuilder: (_, animation, __, page) { return FadeTransition(opacity: animation, child: page); }, ), ); } Widget _buildPhotoWidget(BuildContext context, Map attach) { // Сначала обрабатываем локальные данные (base64), если они есть. // Это обеспечивает мгновенный показ размытого превью. Uint8List? previewBytes; final preview = attach['previewData']; if (preview is String && preview.startsWith('data:')) { final idx = preview.indexOf('base64,'); if (idx != -1) { final b64 = preview.substring(idx + 7); try { previewBytes = base64Decode(b64); } catch (_) { // Ошибка декодирования, ничего страшного } } } final url = attach['url'] ?? attach['baseUrl']; if (url is String && url.isNotEmpty) { // Обработка локальных файлов (если фото отправляется с устройства) if (url.startsWith('file://')) { final path = url.replaceFirst('file://', ''); return Image.file( File(path), fit: BoxFit.cover, width: 220, filterQuality: FilterQuality.medium, // Используем среднее качество для превью gaplessPlayback: true, errorBuilder: (context, _, __) => _imagePlaceholder(), ); } // Формируем специальный URL для предпросмотра в чате: // средний размер, высокое качество, формат JPEG для эффективности. String previewQualityUrl = url; if (!url.contains('?')) { previewQualityUrl = '$url?size=medium&quality=high&format=jpeg'; } else { previewQualityUrl = '$url&size=medium&quality=high&format=jpeg'; } final themeProvider = Provider.of(context, listen: false); final optimize = themeProvider.optimizeChats || themeProvider.ultraOptimizeChats; // Используем наш новый URL для загрузки качественного превью return _ProgressiveNetworkImage( key: ValueKey(previewQualityUrl), // Ключ по новому URL url: previewQualityUrl, // Передаем новый URL previewBytes: previewBytes, // Передаем размытую заглушку для мгновенного отображения width: 220, height: 160, fit: BoxFit.cover, keepAlive: !optimize, startDownloadNextFrame: deferImageLoading, ); } // Если URL нет, но есть base64 данные, покажем их if (previewBytes != null) { return Image.memory(previewBytes, fit: BoxFit.cover, width: 180); } // В самом крайнем случае показываем стандартный плейсхолдер return _imagePlaceholder(); } Widget _imagePlaceholder() { return Container( width: 220, height: 160, color: Colors.black12, alignment: Alignment.center, child: const Icon(Icons.image_outlined, color: Colors.black38), ); } // Лёгкий прогрессивный загрузчик: показывает превью, тянет оригинал с прогрессом и кэширует в памяти процесса Color _getBubbleColor( bool isMe, ThemeProvider themeProvider, double messageOpacity, ) { final baseColor = isMe ? (themeProvider.myBubbleColor ?? const Color(0xFF2b5278)) : (themeProvider.theirBubbleColor ?? const Color(0xFF182533)); return baseColor.withOpacity(1.0 - messageOpacity); } Color _getTextColor( bool isMe, Color bubbleColor, double messageTextOpacity, BuildContext context, ) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; if (isDarkMode) { return Colors.white; } else { return Colors.black; } } List _buildMessageContentChildren( BuildContext context, Color textColor, double messageTextOpacity, bool isUltraOptimized, TextStyle linkStyle, TextStyle defaultTextStyle, double messageBorderRadius, Future Function(LinkableElement) onOpenLink, VoidCallback onSenderNameTap, ) { return [ if (isGroupChat && !isMe && senderName != null) MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: onSenderNameTap, child: Padding( padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), child: Text( senderName!, style: TextStyle( fontWeight: FontWeight.bold, color: _getUserColor( message.senderId, context, ).withOpacity(0.8), fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ), if (isGroupChat && !isMe && senderName != null) const SizedBox(height: 4), if (message.isForwarded && message.link != null) ...[ if (message.link is Map) _buildForwardedMessage( context, message.link as Map, textColor, messageTextOpacity, isUltraOptimized, ), ] else ...[ if (message.isReply && message.link != null) ...[ if (message.link is Map) _buildReplyPreview( context, message.link as Map, textColor, messageTextOpacity, isUltraOptimized, messageBorderRadius, ), const SizedBox(height: 8), ], if (message.attaches.isNotEmpty) ...[ ..._buildCallsWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, ), ..._buildAudioWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, ), ..._buildPhotosWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, ), ..._buildVideosWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, ), ..._buildStickersWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, ), ..._buildFilesWithCaption( context, message.attaches, textColor, isUltraOptimized, messageTextOpacity, chatId, ), const SizedBox(height: 6), ], if (message.text.isNotEmpty) ...[ if (message.text.contains("welcome.saved.dialog.message")) Linkify( text: 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', style: TextStyle(color: textColor, fontStyle: FontStyle.italic), linkStyle: linkStyle, onOpen: onOpenLink, options: const LinkifyOptions(humanize: false), textAlign: TextAlign.left, ) else if (message.text.contains("komet.color_")) _buildKometColorRichText( message.text, defaultTextStyle, ) else Linkify( text: message.text, style: defaultTextStyle, linkStyle: linkStyle, onOpen: onOpenLink, options: const LinkifyOptions(humanize: false), textAlign: TextAlign.left, ), if (message.reactionInfo != null) const SizedBox(height: 4), ], ], ..._buildInlineKeyboard(context, message.attaches, textColor), _buildReactionsWidget(context, textColor), const SizedBox(height: 8.0), Row( mainAxisSize: MainAxisSize.min, children: [ if (isMe) ...[ if (message.attaches.any((a) => a['_type'] == 'PHOTO')) ...[ Builder( builder: (context) { final url = _extractFirstPhotoUrl(message.attaches); if (url == null || url.startsWith('file://')) { return const SizedBox.shrink(); } final notifier = GlobalImageStore.progressFor(url); return ValueListenableBuilder( valueListenable: notifier, builder: (context, value, _) { if (value == null || value <= 0 || value >= 1) { return const SizedBox.shrink(); } return SizedBox( width: 24, height: 12, child: LinearProgressIndicator( value: value, backgroundColor: Colors.transparent, color: textColor.withOpacity( 0.7 * messageTextOpacity, ), minHeight: 3, ), ); }, ); }, ), const SizedBox(width: 8), ], if (message.isEdited) ...[ Text( '(изменено)', style: TextStyle( fontSize: 10, color: textColor.withOpacity(0.5 * messageTextOpacity), fontStyle: FontStyle.italic, ), ), const SizedBox(width: 6), ], Text( _formatMessageTime(context, message.time), style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280), ), ), if (readStatus != null) ...[ const SizedBox(width: 4), Builder( builder: (context) { final bool isRead = readStatus == MessageReadStatus.read; final Color iconColor = isRead ? (Theme.of(context).brightness == Brightness.dark ? Colors.lightBlueAccent[100]! : Colors.blue[600]!) : (Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280)); if (readStatus == MessageReadStatus.sending) { return _RotatingIcon( icon: Icons.watch_later_outlined, size: 16, color: iconColor, ); } else { return Icon( isRead ? Icons.done_all : Icons.done, size: 16, color: iconColor, ); } }, ), ], ], if (!isMe) ...[ Text( _formatMessageTime(context, message.time), style: TextStyle( fontSize: 12, color: Theme.of(context).brightness == Brightness.dark ? const Color(0xFF9bb5c7) : const Color(0xFF6b7280), ), ), if (message.isEdited) ...[ const SizedBox(width: 6), Text( '(изменено)', style: TextStyle( fontSize: 10, color: textColor.withOpacity(0.5 * messageTextOpacity), fontStyle: FontStyle.italic, ), ), ], ], ], ), ]; } /// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'. /// Если цвет некорректный, используется красный. Widget _buildKometColorRichText( String rawText, TextStyle baseStyle, ) { final segments = _parseKometColorSegments(rawText, baseStyle.color); return RichText( textAlign: TextAlign.left, text: TextSpan( style: baseStyle, children: segments .map( (seg) => TextSpan( text: seg.text, style: seg.color != null ? baseStyle.copyWith(color: seg.color) : baseStyle, ), ) .toList(), ), ); } List<_KometColoredSegment> _parseKometColorSegments( String text, Color? fallbackColor, ) { const marker = 'komet.color_'; final segments = <_KometColoredSegment>[]; int index = 0; while (index < text.length) { final start = text.indexOf(marker, index); if (start == -1) { segments.add( _KometColoredSegment(text.substring(index), null), ); break; } if (start > index) { segments.add( _KometColoredSegment(text.substring(index, start), null), ); } final colorStart = start + marker.length; final firstQuote = text.indexOf("'", colorStart); if (firstQuote == -1) { // Кривой синтаксис — считаем всё остальное обычным текстом. segments.add( _KometColoredSegment(text.substring(start), null), ); break; } final colorStr = text.substring(colorStart, firstQuote); final textStart = firstQuote + 1; final secondQuote = text.indexOf("'", textStart); if (secondQuote == -1) { segments.add( _KometColoredSegment(text.substring(start), null), ); break; } final segmentText = text.substring(textStart, secondQuote); final color = _parseKometHexColor(colorStr, fallbackColor); segments.add(_KometColoredSegment(segmentText, color)); index = secondQuote + 1; } return segments; } Color _parseKometHexColor(String raw, Color? fallbackColor) { String hex = raw.trim(); if (hex.startsWith('#')) { hex = hex.substring(1); } // Ожидаем 6 или 8 символов; всё остальное считаем "херовым" цветом. final isValidLength = hex.length == 6 || hex.length == 8; final isValidChars = RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex); if (!isValidLength || !isValidChars) { return const Color(0xFFFF0000); // дефолт – красный } if (hex.length == 6) { hex = 'FF$hex'; // добавляем альфу } try { final value = int.parse(hex, radix: 16); return Color(value); } catch (_) { return const Color(0xFFFF0000); } } BoxDecoration _createBubbleDecoration( Color bubbleColor, double messageBorderRadius, double messageShadowIntensity, ) { return BoxDecoration( color: bubbleColor, borderRadius: BorderRadius.circular(messageBorderRadius), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(messageShadowIntensity), blurRadius: 8, spreadRadius: 0, offset: const Offset(0, 2), ), ], ); } Widget _buildMessageContentInner( BuildContext context, BoxDecoration? decoration, List children, ) { return Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), margin: _getMessageMargin(context), decoration: decoration, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: children, ), ); } Color _getLinkColor(Color bubbleColor, bool isMe) { final isDark = BrightnessExtension( ThemeData.estimateBrightnessForColor(bubbleColor), ).isDark; if (isMe) { return isDark ? Colors.white : Colors.blue[700]!; } return Colors.blue[700]!; } Widget _buildSenderAvatar() { final senderContact = contactDetailsCache?[message.senderId]; final avatarUrl = senderContact?.photoBaseUrl; final contactName = senderContact?.name ?? 'Участник ${message.senderId}'; return Builder( builder: (context) => MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () => openUserProfileById(context, message.senderId), child: AvatarCacheService().getAvatarWidget( avatarUrl, userId: message.senderId, size: 32, fallbackText: contactName, backgroundColor: _getUserColor(message.senderId, context), textColor: Colors.white, ), ), ), ); } } class GlobalImageStore { static final Map _memory = {}; static final Map> _progress = {}; static Uint8List? getData(String url) => _memory[url]; static void setData(String url, Uint8List bytes) { _memory[url] = bytes; progressFor(url).value = null; } static ValueNotifier progressFor(String url) { return _progress.putIfAbsent(url, () => ValueNotifier(null)); } static void setProgress(String url, double? value) { progressFor(url).value = value; } } class _ProgressiveNetworkImage extends StatefulWidget { final String url; final Uint8List? previewBytes; final double width; final double height; final BoxFit fit; final bool startDownloadNextFrame; final bool keepAlive; const _ProgressiveNetworkImage({ super.key, required this.url, required this.previewBytes, required this.width, required this.height, required this.fit, this.startDownloadNextFrame = false, this.keepAlive = true, }); @override State<_ProgressiveNetworkImage> createState() => _ProgressiveNetworkImageState(); } class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> with AutomaticKeepAliveClientMixin { static final Map _memoryCache = {}; Uint8List? _fullBytes; double _progress = 0.0; bool _error = false; String? _diskPath; @override void initState() { super.initState(); // [!code ++] (НОВЫЙ БЛОК) // Если URL пустой, нечего загружать. // Полагаемся только на previewBytes. if (widget.url.isEmpty) { return; } // [!code ++] (КОНЕЦ НОВОГО БЛОКА) // Если есть в глобальном кэше — используем сразу final cached = GlobalImageStore.getData(widget.url); if (cached != null) { _fullBytes = cached; // no return, продолжаем проверить диск на всякий } // Если есть в кэше — используем if (_memoryCache.containsKey(widget.url)) { _fullBytes = _memoryCache[widget.url]; } if (widget.startDownloadNextFrame) { // Загружаем в следующем кадре WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tryLoadFromDiskThenDownload(); }); } else { _tryLoadFromDiskThenDownload(); } } Future _tryLoadFromDiskThenDownload() async { // [!code ++] (НОВЫЙ БЛОК) // Не пытаемся грузить, если URL пустой if (widget.url.isEmpty) { return; } // [!code ++] (КОНЕЦ НОВОГО БЛОКА) // Попытка прочитать из дискового кэша try { final dir = await getTemporaryDirectory(); final name = crypto.md5.convert(widget.url.codeUnits).toString(); final filePath = '${dir.path}/imgcache_$name'; _diskPath = filePath; final f = io.File(filePath); if (await f.exists()) { final data = await f.readAsBytes(); _memoryCache[widget.url] = data; GlobalImageStore.setData(widget.url, data); if (mounted) setState(() => _fullBytes = data); return; // нашли на диске, скачивать не надо } } catch (_) {} await _download(); } Future _download() async { try { final req = http.Request('GET', Uri.parse(widget.url)); req.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; final resp = await req.send(); if (resp.statusCode != 200) { setState(() => _error = true); return; } final contentLength = resp.contentLength ?? 0; final bytes = []; int received = 0; await for (final chunk in resp.stream) { bytes.addAll(chunk); received += chunk.length; if (contentLength > 0) { final p = received / contentLength; _progress = p; // не дергаем setState, чтобы не создавать лишние перерисовки при slide GlobalImageStore.setProgress(widget.url, _progress); } } final data = Uint8List.fromList(bytes); _memoryCache[widget.url] = data; GlobalImageStore.setData(widget.url, data); // Пишем на диск try { final path = _diskPath; if (path != null) { final f = io.File(path); await f.writeAsBytes(data, flush: true); } } catch (_) {} if (mounted) setState(() => _fullBytes = data); } catch (_) { if (mounted) setState(() => _error = true); } } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { super.build(context); final width = widget.width; final height = widget.height; if (_error) { return Container( width: width, height: height, color: Colors.black12, alignment: Alignment.center, child: const Icon(Icons.broken_image_outlined, color: Colors.black38), ); } // Полное качество есть — показываем return RepaintBoundary( child: SizedBox( width: width, height: height, child: ClipRRect( borderRadius: BorderRadius.circular( 0, ), // Упрощено для производительности child: Stack( fit: StackFit.expand, children: [ // 1) Стабильный нижний слой — превью или нейтральный фон if (widget.previewBytes != null) Image.memory( widget.previewBytes!, fit: widget.fit, filterQuality: FilterQuality.none, ) else Container(color: Colors.black12), // 2) Верхний слой — оригинал. Он появляется, но не убирает превью, чтобы не мигать if (_fullBytes != null) Image.memory( _fullBytes!, fit: widget.fit, filterQuality: FilterQuality.high, ), // нижний прогресс убран, чтобы не перерисовывать слой картинки во время slide; // прогресс выводится рядом со временем сообщения ], ), ), ), ); } @override bool get wantKeepAlive => true; @override void didUpdateWidget(covariant _ProgressiveNetworkImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.keepAlive != widget.keepAlive) { // Пересоберём keepAlive флаг updateKeepAlive(); } } } extension BrightnessExtension on Brightness { bool get isDark => this == Brightness.dark; } class _CustomEmojiButton extends StatefulWidget { final Function(String) onCustomEmoji; const _CustomEmojiButton({required this.onCustomEmoji}); @override State<_CustomEmojiButton> createState() => _CustomEmojiButtonState(); } class _CustomEmojiButtonState extends State<_CustomEmojiButton> with TickerProviderStateMixin { late AnimationController _scaleController; late Animation _scaleAnimation; @override void initState() { super.initState(); _scaleController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, ); _scaleAnimation = Tween(begin: 1.0, end: 1.15).animate( CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut), ); } @override void dispose() { _scaleController.dispose(); super.dispose(); } // Логика нажатия упрощена void _handleTap() { // Анимация масштабирования для обратной связи _scaleController.forward().then((_) { _scaleController.reverse(); }); // Сразу открываем диалог _showCustomEmojiDialog(); } void _showCustomEmojiDialog() { showDialog( context: context, builder: (context) => _CustomEmojiDialog( onEmojiSelected: (emoji) { widget.onCustomEmoji(emoji); }, ), ); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: AnimatedBuilder( animation: _scaleController, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), // Стрелка заменена на иконку "добавить" child: Icon( Icons.add_reaction_outlined, size: 24, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); }, ), ); } } class _CustomEmojiDialog extends StatefulWidget { final Function(String) onEmojiSelected; const _CustomEmojiDialog({required this.onEmojiSelected}); @override State<_CustomEmojiDialog> createState() => _CustomEmojiDialogState(); } class _CustomEmojiDialogState extends State<_CustomEmojiDialog> { final TextEditingController _controller = TextEditingController(); String _selectedEmoji = ''; @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Row( children: [ Icon( Icons.emoji_emotions, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), const Text('Введите эмодзи'), ], ), content: Column( mainAxisSize: MainAxisSize.min, children: [ Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of(context).colorScheme.outline.withOpacity(0.3), ), ), child: TextField( controller: _controller, maxLength: 1, // Только один символ decoration: InputDecoration( hintText: 'Введите эмодзи...', border: InputBorder.none, contentPadding: const EdgeInsets.all(16), counterText: '', hintStyle: TextStyle( color: Theme.of( context, ).colorScheme.onSurface.withOpacity(0.6), ), ), onChanged: (value) { setState(() { _selectedEmoji = value; }); }, textAlign: TextAlign.center, style: const TextStyle(fontSize: 24), ), ), const SizedBox(height: 20), if (_selectedEmoji.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( gradient: LinearGradient( colors: [ Theme.of(context).colorScheme.primaryContainer, Theme.of( context, ).colorScheme.primaryContainer.withOpacity(0.7), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16), border: Border.all( color: Theme.of(context).colorScheme.primary.withOpacity(0.3), width: 2, ), boxShadow: [ BoxShadow( color: Theme.of( context, ).colorScheme.primary.withOpacity(0.2), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Text(_selectedEmoji, style: const TextStyle(fontSize: 48)), ), const SizedBox(height: 20), ], ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена'), ), FilledButton.icon( onPressed: _selectedEmoji.isNotEmpty ? () { widget.onEmojiSelected(_selectedEmoji); Navigator.of(context).pop(); } : null, icon: const Icon(Icons.add), label: const Text('Добавить'), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, ), ), ], ); } } class _MessageContextMenu extends StatefulWidget { final Message message; final Offset position; final VoidCallback? onReply; final VoidCallback? onEdit; final VoidCallback? onDeleteForMe; final VoidCallback? onDeleteForAll; final Function(String)? onReaction; final VoidCallback? onRemoveReaction; final VoidCallback? onForward; final VoidCallback? onComplain; final bool canEditMessage; final bool hasUserReaction; final bool isChannel; const _MessageContextMenu({ required this.message, required this.position, this.onReply, this.onEdit, this.onDeleteForMe, this.onDeleteForAll, this.onReaction, this.onRemoveReaction, this.onForward, this.onComplain, required this.canEditMessage, required this.hasUserReaction, this.isChannel = false, }); @override _MessageContextMenuState createState() => _MessageContextMenuState(); } class _MessageContextMenuState extends State<_MessageContextMenu> with SingleTickerProviderStateMixin { bool _isEmojiListExpanded = false; late AnimationController _animationController; late Animation _scaleAnimation; // Короткий список для быстрого доступа static const List _quickReactions = [ '👍', '❤️', '😂', '🔥', '👏', '🤔', ]; // Полный список всех реакций static const List _allReactions = [ '👍', '❤️', '😂', '🔥', '👏', '👌', '🎉', '🥰', '😍', '🙏', '🤔', '🤯', '💯', '⚡️', '🤟', '🌚', '🌝', '🥱', '🤣', '🫠', '🫡', '🐱', '💋', '😘', '🐶', '🤝', '⭐️', '🍷', '🍑', '😁', '🤷‍♀️', '🤷‍♂️', '👩‍❤️‍👨', '🦄', '👻', '🗿', '❤️‍🩹', '🛑', '⛄️', '❓', '🙄', '❗️', '😉', '😳', '🥳', '😎', '💪', '👀', '🤞', '🤤', '🤪', '🤩', '😴', '😐', '😇', '🖤', '👑', '👋', '👁️', ]; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 150), ); _scaleAnimation = CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, ); _animationController.forward(); } @override void dispose() { _animationController.dispose(); super.dispose(); } void _onCopy() { Clipboard.setData(ClipboardData(text: widget.message.text)); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Сообщение скопировано'), behavior: SnackBarBehavior.floating, duration: Duration(seconds: 2), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final themeProvider = Provider.of(context); final screenSize = MediaQuery.of(context).size; const menuWidth = 250.0; final double estimatedMenuHeight = _isEmojiListExpanded ? 320.0 : 250.0; double left = widget.position.dx - (menuWidth / 4); if (left + menuWidth > screenSize.width) { left = screenSize.width - menuWidth - 16; } if (left < 16) { left = 16; } double top = widget.position.dy; if (top + estimatedMenuHeight > screenSize.height) { top = widget.position.dy - estimatedMenuHeight - 10; } return Scaffold( backgroundColor: Colors.black.withOpacity(0.1), body: Stack( children: [ Positioned.fill( child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container(color: Colors.transparent), ), ), Positioned( top: top, left: left, child: ScaleTransition( scale: _scaleAnimation, alignment: Alignment.topCenter, child: ClipRRect( borderRadius: BorderRadius.circular(16), child: BackdropFilter( filter: ImageFilter.blur( sigmaX: themeProvider.messageMenuBlur, sigmaY: themeProvider.messageMenuBlur, ), child: Card( elevation: 8, color: theme.colorScheme.surface.withOpacity( themeProvider.messageMenuOpacity, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Container( width: menuWidth, padding: const EdgeInsets.all(8.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeInOut, child: _buildEmojiSection(), ), const Divider(height: 12), _buildActionsSection(theme), ], ), ), ), ), ), ), ), ], ), ); } Widget _buildEmojiSection() { if (_isEmojiListExpanded) { return SizedBox( height: 150, child: GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 5, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: _allReactions.length, itemBuilder: (context, index) { final emoji = _allReactions[index]; return GestureDetector( onTap: () { Navigator.pop(context); widget.onReaction?.call(emoji); }, child: Center( child: Text(emoji, style: const TextStyle(fontSize: 28)), ), ); }, ), ); } else { return Wrap( spacing: 8, runSpacing: 4, alignment: WrapAlignment.center, children: [ ..._quickReactions.map( (emoji) => GestureDetector( onTap: () { Navigator.pop(context); widget.onReaction?.call(emoji); }, child: Padding( padding: const EdgeInsets.all(4.0), child: Text(emoji, style: const TextStyle(fontSize: 28)), ), ), ), IconButton( icon: const Icon(Icons.add_circle_outline, size: 28), onPressed: () => setState(() => _isEmojiListExpanded = true), tooltip: 'Больше реакций', ), ], ); } } Widget _buildActionsSection(ThemeData theme) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.message.text.isNotEmpty) _buildActionButton( icon: Icons.copy_rounded, text: 'Копировать', onTap: _onCopy, ), if (widget.onReply != null && !widget.isChannel) _buildActionButton( icon: Icons.reply_rounded, text: 'Ответить', onTap: () { Navigator.pop(context); widget.onReply!(); }, ), if (widget.onForward != null) _buildActionButton( icon: Icons.forward_rounded, text: 'Переслать', onTap: () { Navigator.pop(context); widget.onForward!(); }, ), if (widget.onEdit != null) _buildActionButton( icon: widget.canEditMessage ? Icons.edit_rounded : Icons.edit_off_rounded, text: 'Редактировать', onTap: widget.canEditMessage ? () { Navigator.pop(context); widget.onEdit!(); } : null, color: widget.canEditMessage ? null : Colors.grey, ), if (widget.hasUserReaction && widget.onRemoveReaction != null) _buildActionButton( icon: Icons.remove_circle_outline_rounded, text: 'Убрать реакцию', color: theme.colorScheme.error, onTap: () { Navigator.pop(context); widget.onRemoveReaction!(); }, ), if (widget.onDeleteForMe != null) _buildActionButton( icon: Icons.person_remove_rounded, text: 'Удалить у меня', color: theme.colorScheme.error, onTap: () { Navigator.pop(context); widget.onDeleteForMe!(); }, ), if (widget.onDeleteForAll != null) _buildActionButton( icon: Icons.delete_forever_rounded, text: 'Удалить у всех', color: theme.colorScheme.error, onTap: () { Navigator.pop(context); widget.onDeleteForAll!(); }, ), if (widget.onComplain != null) _buildActionButton( icon: Icons.report_rounded, text: 'Пожаловаться', color: theme.colorScheme.error, onTap: () { Navigator.pop(context); widget.onComplain!(); }, ), ], ); } Widget _buildActionButton({ required IconData icon, required String text, required VoidCallback? onTap, Color? color, }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 10.0), child: Row( children: [ Icon( icon, size: 20, color: color ?? Theme.of(context).iconTheme.color, ), const SizedBox(width: 12), Text( text, style: TextStyle( color: color, fontSize: 15, fontWeight: onTap == null ? FontWeight.normal : FontWeight.w500, ), ), ], ), ), ); } } class FullScreenPhotoViewer extends StatefulWidget { final Widget imageChild; final Map? attach; const FullScreenPhotoViewer({ super.key, required this.imageChild, this.attach, }); @override State createState() => _FullScreenPhotoViewerState(); } class _FullScreenPhotoViewerState extends State { late final TransformationController _transformationController; // Переменная для контроля, можно ли двигать изображение bool _isPanEnabled = false; @override void initState() { super.initState(); _transformationController = TransformationController(); // "Слушаем" изменения зума _transformationController.addListener(_onTransformChanged); } @override void dispose() { _transformationController.removeListener(_onTransformChanged); _transformationController.dispose(); super.dispose(); } void _onTransformChanged() { // Получаем текущий масштаб final currentScale = _transformationController.value.getMaxScaleOnAxis(); // Разрешаем двигать, только если масштаб больше 1 final shouldPan = currentScale > 1.0; // Обновляем состояние, только если оно изменилось if (shouldPan != _isPanEnabled) { setState(() { _isPanEnabled = shouldPan; }); } } Future _downloadPhoto() async { if (widget.attach == null) return; try { // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { final directory = await getExternalStorageDirectory(); if (directory != null) { downloadDir = io.Directory( '${directory.path.split('Android')[0]}Download', ); if (!await downloadDir.exists()) { downloadDir = io.Directory( '${directory.path.split('Android')[0]}Downloads', ); } } } else if (io.Platform.isIOS) { final directory = await getApplicationDocumentsDirectory(); downloadDir = directory; } else if (io.Platform.isWindows || io.Platform.isLinux) { final homeDir = io.Platform.environment['HOME'] ?? io.Platform.environment['USERPROFILE'] ?? ''; downloadDir = io.Directory('$homeDir/Downloads'); } else { downloadDir = await getApplicationDocumentsDirectory(); } if (downloadDir == null || !await downloadDir.exists()) { throw Exception('Downloads directory not found'); } // Get photo URL final url = widget.attach!['url'] ?? widget.attach!['baseUrl']; if (url == null || url.isEmpty) { throw Exception('Photo URL not found'); } // Extract file extension from URL or use .jpg as default String extension = 'jpg'; final uri = Uri.tryParse(url); if (uri != null && uri.pathSegments.isNotEmpty) { final lastSegment = uri.pathSegments.last; final extMatch = RegExp(r'\.([a-zA-Z0-9]+)$').firstMatch(lastSegment); if (extMatch != null) { extension = extMatch.group(1)!; } } // Generate filename with timestamp final timestamp = DateTime.now().millisecondsSinceEpoch; final fileName = 'photo_$timestamp.$extension'; final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); // Download the image final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { await file.writeAsBytes(response.bodyBytes); // Save to SharedPreferences final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; if (!downloadedFiles.contains(filePath)) { downloadedFiles.add(filePath); await prefs.setStringList('downloaded_files', downloadedFiles); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Фото сохранено: $fileName'), duration: const Duration(seconds: 3), ), ); } } else { throw Exception('Failed to download photo: ${response.statusCode}'); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка при скачивании фото: ${e.toString()}'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ GestureDetector( onTap: () { Navigator.of(context).pop(); }, child: InteractiveViewer( transformationController: _transformationController, panEnabled: _isPanEnabled, boundaryMargin: EdgeInsets.zero, minScale: 1.0, maxScale: 5.0, child: Center(child: widget.imageChild), ), ), // Top bar with close button and download button SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.of(context).pop(), ), if (widget.attach != null) IconButton( icon: const Icon(Icons.download, color: Colors.white), onPressed: _downloadPhoto, tooltip: 'Скачать фото', ), ], ), ), ), ], ), ); } } class _RotatingIcon extends StatefulWidget { final IconData icon; final double size; final Color color; const _RotatingIcon({ required this.icon, required this.size, required this.color, }); @override State<_RotatingIcon> createState() => _RotatingIconState(); } class _RotatingIconState extends State<_RotatingIcon> with SingleTickerProviderStateMixin { // Важно добавить 'with SingleTickerProviderStateMixin' late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( // Длительность одного оборота (2 секунды) duration: const Duration(seconds: 2), vsync: this, )..repeat(); // Запускаем анимацию на бесконечное повторение } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // RotationTransition - это виджет, который вращает своего "ребенка" return RotationTransition( turns: _controller, // Анимация вращения child: Icon(widget.icon, size: widget.size, color: widget.color), ); } } class _AudioPlayerWidget extends StatefulWidget { final String url; final int duration; final String durationText; final String? wave; final int? audioId; final Color textColor; final BorderRadius borderRadius; final double messageTextOpacity; const _AudioPlayerWidget({ required this.url, required this.duration, required this.durationText, this.wave, this.audioId, required this.textColor, required this.borderRadius, required this.messageTextOpacity, }); @override State<_AudioPlayerWidget> createState() => _AudioPlayerWidgetState(); } class _AudioPlayerWidgetState extends State<_AudioPlayerWidget> { late AudioPlayer _audioPlayer; bool _isPlaying = false; bool _isLoading = false; bool _isCompleted = false; Duration _position = Duration.zero; Duration _totalDuration = Duration.zero; List? _waveformData; @override void initState() { super.initState(); _audioPlayer = AudioPlayer(); _totalDuration = Duration(milliseconds: widget.duration); if (widget.wave != null && widget.wave!.isNotEmpty) { _decodeWaveform(widget.wave!); } if (widget.url.isNotEmpty) { _preCacheAudio(); } _audioPlayer.playerStateStream.listen((state) { if (mounted) { final wasCompleted = _isCompleted; setState(() { _isPlaying = state.playing; _isLoading = state.processingState == ProcessingState.loading || state.processingState == ProcessingState.buffering; _isCompleted = state.processingState == ProcessingState.completed; }); if (state.processingState == ProcessingState.completed && !wasCompleted) { _audioPlayer.pause(); } } }); _audioPlayer.positionStream.listen((position) { if (mounted) { final reachedEnd = _totalDuration.inMilliseconds > 0 && position.inMilliseconds >= _totalDuration.inMilliseconds - 50 && _isPlaying; if (reachedEnd) { _audioPlayer.pause(); } setState(() { _position = position; if (reachedEnd) { _isPlaying = false; _isCompleted = true; } }); } }); _audioPlayer.durationStream.listen((duration) { if (mounted && duration != null) { setState(() { _totalDuration = duration; }); } }); } void _decodeWaveform(String waveBase64) { try { String base64Data = waveBase64; if (waveBase64.contains(',')) { base64Data = waveBase64.split(',')[1]; } final bytes = base64Decode(base64Data); _waveformData = bytes.toList(); } catch (e) { print('Error decoding waveform: $e'); _waveformData = null; } } Future _preCacheAudio() async { try { final cacheService = CacheService(); final hasCached = await cacheService.hasCachedAudioFile( widget.url, customKey: widget.audioId?.toString(), ); if (!hasCached) { print('Pre-caching audio: ${widget.url}'); final cachedPath = await cacheService.cacheAudioFile( widget.url, customKey: widget.audioId?.toString(), ); if (cachedPath != null) { print('Audio pre-cached successfully: $cachedPath'); } else { print('Failed to pre-cache audio (no internet?): ${widget.url}'); } } else { print('Audio already cached: ${widget.url}'); } } catch (e) { print('Error pre-caching audio: $e'); } } Future _togglePlayPause() async { if (_isLoading) return; try { if (_isPlaying) { await _audioPlayer.pause(); } else { if (_isCompleted || (_totalDuration.inMilliseconds > 0 && _position.inMilliseconds >= _totalDuration.inMilliseconds - 100)) { await _audioPlayer.stop(); await _audioPlayer.seek(Duration.zero); if (mounted) { setState(() { _isCompleted = false; _isPlaying = false; _position = Duration.zero; }); } await Future.delayed(const Duration(milliseconds: 150)); } if (_audioPlayer.processingState == ProcessingState.idle) { if (widget.url.isNotEmpty) { final cacheService = CacheService(); var cachedFile = await cacheService.getCachedAudioFile( widget.url, customKey: widget.audioId?.toString(), ); if (cachedFile != null && await cachedFile.exists()) { print('Using cached audio file: ${cachedFile.path}'); await _audioPlayer.setFilePath(cachedFile.path); } else { print('Audio not cached, playing from URL: ${widget.url}'); try { await _audioPlayer.setUrl(widget.url); cacheService .cacheAudioFile( widget.url, customKey: widget.audioId?.toString(), ) .then((cachedPath) { if (cachedPath != null) { print('Audio cached in background: $cachedPath'); } else { print('Failed to cache audio in background'); } }) .catchError((e) { print('Error caching audio in background: $e'); }); } catch (e) { print('Error setting audio URL: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Не удалось загрузить аудио: ${e.toString()}', ), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } return; } } } } await _audioPlayer.play(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Ошибка воспроизведения: $e'), backgroundColor: Colors.red, ), ); } } } Future _seek(Duration position) async { await _audioPlayer.seek(position); if (mounted) { setState(() { _isCompleted = false; }); } } String _formatDuration(Duration duration) { final minutes = duration.inMinutes; final seconds = duration.inSeconds % 60; return '$minutes:${seconds.toString().padLeft(2, '0')}'; } @override void dispose() { _audioPlayer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final progress = _totalDuration.inMilliseconds > 0 ? _position.inMilliseconds / _totalDuration.inMilliseconds : 0.0; return Container( decoration: BoxDecoration( color: widget.textColor.withOpacity(0.05), borderRadius: widget.borderRadius, border: Border.all(color: widget.textColor.withOpacity(0.1), width: 1), ), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ GestureDetector( onTap: _togglePlayPause, child: Container( width: 40, height: 40, decoration: BoxDecoration( color: widget.textColor.withOpacity(0.1), shape: BoxShape.circle, ), child: _isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( _isPlaying ? Icons.pause : Icons.play_arrow, color: widget.textColor.withOpacity( 0.8 * widget.messageTextOpacity, ), size: 24, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (_waveformData != null && _waveformData!.isNotEmpty) SizedBox( height: 30, child: CustomPaint( painter: _WaveformPainter( waveform: _waveformData!, progress: progress, color: widget.textColor.withOpacity( 0.6 * widget.messageTextOpacity, ), progressColor: widget.textColor.withOpacity( 0.9 * widget.messageTextOpacity, ), ), child: GestureDetector( onTapDown: (details) { final RenderBox box = context.findRenderObject() as RenderBox; final localPosition = details.localPosition; final tapProgress = localPosition.dx / box.size.width; final newPosition = Duration( milliseconds: (_totalDuration.inMilliseconds * tapProgress) .round(), ); _seek(newPosition); }, ), ), ) else ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: progress, backgroundColor: widget.textColor.withOpacity(0.1), valueColor: AlwaysStoppedAnimation( widget.textColor.withOpacity( 0.6 * widget.messageTextOpacity, ), ), minHeight: 3, ), ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration(_position), style: TextStyle( color: widget.textColor.withOpacity( 0.7 * widget.messageTextOpacity, ), fontSize: 12, ), ), Text( widget.durationText, style: TextStyle( color: widget.textColor.withOpacity( 0.7 * widget.messageTextOpacity, ), fontSize: 12, ), ), ], ), ], ), ), ], ), ), ); } } class _WaveformPainter extends CustomPainter { final List waveform; final double progress; final Color color; final Color progressColor; _WaveformPainter({ required this.waveform, required this.progress, required this.color, required this.progressColor, }); @override void paint(Canvas canvas, Size size) { if (waveform.isEmpty) return; final paint = Paint() ..strokeWidth = 2 ..strokeCap = StrokeCap.round; final barWidth = size.width / waveform.length; final maxAmplitude = waveform.reduce((a, b) => a > b ? a : b).toDouble(); for (int i = 0; i < waveform.length; i++) { final amplitude = waveform[i].toDouble(); final normalizedAmplitude = maxAmplitude > 0 ? amplitude / maxAmplitude : 0.0; final barHeight = normalizedAmplitude * size.height * 0.8; final x = i * barWidth + barWidth / 2; final isPlayed = i / waveform.length < progress; paint.color = isPlayed ? progressColor : color; canvas.drawLine( Offset(x, size.height / 2 - barHeight / 2), Offset(x, size.height / 2 + barHeight / 2), paint, ); } } @override bool shouldRepaint(_WaveformPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.waveform != waveform; } } class _VideoCirclePlayer extends StatefulWidget { final int videoId; final String messageId; final int chatId; final String? highQualityUrl; final Uint8List? lowQualityBytes; const _VideoCirclePlayer({ required this.videoId, required this.messageId, required this.chatId, this.highQualityUrl, this.lowQualityBytes, }); @override State<_VideoCirclePlayer> createState() => _VideoCirclePlayerState(); } class _VideoCirclePlayerState extends State<_VideoCirclePlayer> { VideoPlayerController? _controller; bool _isLoading = true; bool _hasError = false; bool _isPlaying = false; bool _isUserTapped = false; @override void initState() { super.initState(); _loadVideo(); } Future _loadVideo() async { try { final videoUrl = await ApiService.instance.getVideoUrl( widget.videoId, widget.chatId, widget.messageId, ); if (!mounted) return; _controller = VideoPlayerController.networkUrl( Uri.parse(videoUrl), httpHeaders: const { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }, ); await _controller!.initialize(); if (!mounted) return; _controller!.setLooping(true); _controller!.setVolume(0.0); _controller!.play(); setState(() { _isLoading = false; _isPlaying = true; _isUserTapped = false; }); } catch (e) { print('❌ [VideoCirclePlayer] Error loading video: $e'); if (mounted) { setState(() { _hasError = true; _isLoading = false; }); } } } void _videoListener() { if (_controller == null || !_isUserTapped) return; if (_controller!.value.position >= _controller!.value.duration && _controller!.value.duration > Duration.zero) { _controller!.pause(); _controller!.seekTo(Duration.zero); if (mounted) { setState(() { _isPlaying = false; }); } } } void _togglePlayPause() { if (_controller == null) return; if (!_isUserTapped) { _controller!.addListener(_videoListener); _controller!.setLooping(false); _controller!.setVolume(1.0); _controller!.seekTo(Duration.zero); setState(() { _isUserTapped = true; _isPlaying = true; }); _controller!.play(); return; } if (_isPlaying) { _controller!.pause(); setState(() { _isPlaying = false; }); } else { if (_controller!.value.position >= _controller!.value.duration) { _controller!.seekTo(Duration.zero); } _controller!.play(); setState(() { _isPlaying = true; }); } } @override void dispose() { _controller?.removeListener(_videoListener); _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _togglePlayPause, child: SizedBox( width: 200, height: 200, child: ClipOval( child: Stack( alignment: Alignment.center, fit: StackFit.expand, children: [ if (_isLoading || _hasError || _controller == null || !_controller!.value.isInitialized) (widget.highQualityUrl != null && widget.highQualityUrl!.isNotEmpty) || (widget.lowQualityBytes != null) ? _ProgressiveNetworkImage( url: widget.highQualityUrl ?? '', previewBytes: widget.lowQualityBytes, width: 200, height: 200, fit: BoxFit.cover, keepAlive: false, ) : Container( color: Colors.black26, child: const Center( child: Icon( Icons.video_library_outlined, color: Colors.white, size: 40, ), ), ) else VideoPlayer(_controller!), if (_isLoading) Container( color: Colors.black.withOpacity(0.3), child: const Center( child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2, ), ), ), if (!_isLoading && !_hasError && _controller != null && _controller!.value.isInitialized) AnimatedOpacity( opacity: _isPlaying ? 0.0 : 0.8, duration: const Duration(milliseconds: 200), child: Container( decoration: BoxDecoration( color: Colors.black.withOpacity(0.3), shape: BoxShape.circle, ), child: Icon( _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, color: Colors.white, size: 50, ), ), ), ], ), ), ), ); } }