import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show CachedMessage, ContactCache; import '../../../backend/modules/shared_content.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/media/voice_audio_controller.dart'; import '../../../core/utils/download_history.dart'; import '../../../core/utils/download_progress.dart'; import '../../../core/utils/file_download.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/link_opener.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_saver.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/attachment.dart'; import '../../screens/chats/chat_screen.dart'; import '../custom_notification.dart'; import '../komet_avatar.dart'; import '../photo_viewer.dart'; import '../reload_on_reconnect.dart'; import '../small_spinner.dart'; import '../swipe_route.dart'; import '../sheet_helpers.dart'; enum SharedContentKind { media, files, voice, links } extension on SharedContentKind { List get attachTypes { switch (this) { case SharedContentKind.media: return const ['PHOTO', 'VIDEO']; case SharedContentKind.files: return const ['FILE']; case SharedContentKind.voice: return const ['AUDIO']; case SharedContentKind.links: return const ['SHARE']; } } } const List _ruMonthsFull = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь', ]; const List _enMonthsFull = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; String _monthHeader(String locale, DateTime date) { final months = locale.startsWith('ru') ? _ruMonthsFull : _enMonthsFull; final now = DateTime.now(); final name = months[date.month - 1]; final label = date.year == now.year ? name : '$name ${date.year}'; return label.toUpperCase(); } Widget _emptyState(ColorScheme cs, String label, IconData icon) { return Padding( padding: const EdgeInsets.symmetric(vertical: 48), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48, ), const SizedBox(height: 12), Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), ], ), ); } Widget _loadingState(ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(vertical: 56), child: Center(child: SmallSpinner(size: 26, color: cs.primary)), ); } Widget _sectionHeader(ColorScheme cs, String label) { return Padding( padding: const EdgeInsets.fromLTRB(4, 12, 4, 8), child: Text( label, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.6, ), ), ); } List<({DateTime month, List items})> _groupByMonth( List items, ) { final groups = <({DateTime month, List items})>[]; DateTime? current; for (final item in items) { final dt = DateTime.fromMillisecondsSinceEpoch(item.time); final monthStart = DateTime(dt.year, dt.month); if (current == null || current != monthStart) { current = monthStart; groups.add((month: monthStart, items: [item])); } else { groups.last.items.add(item); } } return groups; } class _MenuAction { final IconData icon; final String label; final Future Function() onTap; const _MenuAction(this.icon, this.label, this.onTap); } Future _showItemMenu(BuildContext context, List<_MenuAction> actions) { final cs = Theme.of(context).colorScheme; return showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, shape: kSheetShape, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 10), Container( width: 40, height: 4, decoration: BoxDecoration( color: cs.onSurfaceVariant.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 8), for (final action in actions) ListTile( leading: Icon(action.icon, color: cs.onSurface), title: Text( action.label, style: TextStyle(color: cs.onSurface, fontSize: 15), ), onTap: () { Navigator.pop(sheetContext); action.onTap(); }, ), const SizedBox(height: 8), ], ), ), ); } Widget _moreButton(ColorScheme cs, VoidCallback onTap, {bool overlay = false}) { if (overlay) { return GestureDetector( onTap: onTap, child: Container( width: 28, height: 28, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(8), ), child: const Icon(Symbols.more_horiz, color: Colors.white, size: 18), ), ); } return IconButton( onPressed: onTap, icon: Icon(Symbols.more_vert, color: cs.onSurfaceVariant, size: 22), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ); } void _notifySave(BuildContext context, MediaSaveResult result) { if (!context.mounted) return; if (result.ok) { showCustomNotification( context, result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', ); } else { showCustomNotification( context, 'Не удалось сохранить: ${result.error ?? ''}', ); } } Future _downloadAttachment( BuildContext context, SharedMediaItem item, String sourceName, ) async { final att = item.attachment; final now = DateTime.now().millisecondsSinceEpoch; if (att is PhotoAttachment) { final url = att.baseUrl ?? ''; if (url.isEmpty) return; final result = await saveMediaFile( cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', resolveUrl: () async => url, saveName: 'IMG_$now.jpg', kind: SaveMediaKind.image, download: DownloadMetadata( cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', kind: DownloadKind.photo, sourceName: sourceName, thumbnailUrl: url, expectedSize: att.size ?? 0, chatId: item.chatId, messageId: item.messageId, messageTime: item.time, ), ); if (context.mounted) _notifySave(context, result); return; } if (att is VideoAttachment) { final result = await saveMediaFile( cacheName: 'video_${att.videoId ?? item.messageId}.mp4', resolveUrl: () async { final sources = await messagesModule.getVideoSources( messageId: item.messageId, chatId: item.chatId, token: att.videoToken ?? '', videoId: att.videoId ?? 0, ); return sources.values.isEmpty ? null : sources.values.first; }, saveName: 'VID_$now.mp4', kind: SaveMediaKind.video, download: DownloadMetadata( cacheName: 'video_${att.videoId ?? item.messageId}.mp4', kind: DownloadKind.video, sourceName: sourceName, thumbnailUrl: att.thumbnail ?? att.baseUrl ?? att.previewData, expectedSize: att.size ?? 0, chatId: item.chatId, messageId: item.messageId, messageTime: item.time, ), ); if (context.mounted) _notifySave(context, result); return; } if (att is FileAttachment) { final fileId = att.fileId; if (fileId == null) return; final name = att.name ?? 'file_$now'; final downloadKind = downloadKindForName(name); final result = await saveMediaFile( cacheName: '${fileId}_$name', resolveUrl: () => messagesModule.getFileUrl( messageId: item.messageId, chatId: item.chatId, fileId: fileId, ), saveName: name, kind: SaveMediaKind.file, download: DownloadMetadata( cacheName: '${fileId}_$name', name: downloadKind == DownloadKind.file ? name : '', kind: downloadKind, sourceName: sourceName, thumbnailUrl: att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, expectedSize: att.size ?? 0, chatId: item.chatId, messageId: item.messageId, messageTime: item.time, ), ); if (context.mounted) _notifySave(context, result); return; } if (att is AudioAttachment) { final url = att.fileUrl ?? att.baseUrl ?? ''; if (url.isEmpty) return; final result = await saveMediaFile( cacheName: '${att.audioId ?? item.messageId}.ogg', resolveUrl: () async => url, saveName: 'AUD_$now.ogg', kind: SaveMediaKind.file, download: DownloadMetadata( cacheName: '${att.audioId ?? item.messageId}.ogg', kind: DownloadKind.audio, sourceName: sourceName, expectedSize: att.size ?? 0, chatId: item.chatId, messageId: item.messageId, messageTime: item.time, ), ); if (context.mounted) _notifySave(context, result); } } class CommonChatsTab extends StatefulWidget { final int userId; final String emptyLabel; const CommonChatsTab({ super.key, required this.userId, required this.emptyLabel, }); @override State createState() => _CommonChatsTabState(); } class _CommonChatsTabState extends State with ReloadOnReconnect { bool _loading = true; List _chats = const []; Map _onlineByChat = const {}; @override void initState() { super.initState(); _load(); } @override void reloadAfterReconnect() => _load(); Future _load() async { final chats = await sharedContentModule.fetchCommonChats(widget.userId); final allIds = {}; for (final c in chats) { allIds.addAll(c.participantIds); } final onlineByChat = {}; if (allIds.isNotEmpty) { try { final presence = await PresenceFetch.getMany(allIds.toList()); for (final c in chats) { var online = 0; for (final id in c.participantIds) { if ((presence[id]?['status'] as int?) == 1) online++; } onlineByChat[c.id] = online; } } catch (e) { logger.w('CommonChatsTab presence failed: $e'); } } if (!mounted) return; setState(() { _chats = chats; _onlineByChat = onlineByChat; _loading = false; }); } void _openChat(CommonChatEntry chat) { final type = chat.type == 'CHANNEL' ? 'CHANNEL' : 'CHAT'; pushSwipeable( context, (_) => ChatScreen( chatId: chat.id, name: chat.title, imageUrl: chat.iconUrl ?? '', chatType: type, ), ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (_loading) return _loadingState(cs); if (_chats.isEmpty) { return _emptyState(cs, widget.emptyLabel, Symbols.group); } return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), ), child: Column( children: [ for (int i = 0; i < _chats.length; i++) ...[ if (i > 0) Divider( height: 1, indent: 68, color: cs.outlineVariant.withValues(alpha: 0.3), ), _tile(cs, _chats[i]), ], ], ), ); } Widget _tile(ColorScheme cs, CommonChatEntry chat) { final l10n = AppLocalizations.of(context)!; final online = _onlineByChat[chat.id] ?? 0; final total = chat.participantsCount; final subtitle = online > 0 ? l10n.chatInfoOnlineOfTotal('$online', '$total') : l10n.sharedMembersCount(total); return InkWell( onTap: () => _openChat(chat), borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ KometAvatar( name: chat.title, imageUrl: chat.iconUrl, size: 46, fontSize: 18, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( chat.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( subtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), ], ), ), ); } } class SharedMediaTab extends StatefulWidget { final int chatId; final String anchorMessageId; final int myId; final String sourceName; final SharedContentKind kind; final String emptyLabel; final IconData emptyIcon; final void Function(String messageId, int time) onGoToMessage; final ScrollController? scrollController; const SharedMediaTab({ super.key, required this.chatId, required this.anchorMessageId, required this.myId, required this.sourceName, required this.kind, required this.emptyLabel, required this.emptyIcon, required this.onGoToMessage, this.scrollController, }); @override State createState() => _SharedMediaTabState(); } class _SharedMediaTabState extends State with ReloadOnReconnect { static const int _pageSize = 60; bool _loading = true; bool _loadingMore = false; bool _canLoadMore = false; int _total = 0; final List _items = []; final Set _seen = {}; @override void initState() { super.initState(); widget.scrollController?.addListener(_onScroll); _load(widget.anchorMessageId, initial: true); } @override void dispose() { widget.scrollController?.removeListener(_onScroll); super.dispose(); } void _onScroll() { if (!_hasMore || _loadingMore || _loading) return; final controller = widget.scrollController; if (controller == null || !controller.hasClients) return; final position = controller.position; if (position.pixels >= position.maxScrollExtent - 800) { _loadMore(); } } @override void reloadAfterReconnect() => _load(widget.anchorMessageId, initial: true); Future _load(String anchor, {required bool initial}) async { final page = await sharedContentModule.fetchMedia( chatId: widget.chatId, anchorMessageId: anchor, attachTypes: widget.kind.attachTypes, forward: initial ? _pageSize : 0, backward: _pageSize, ); if (!mounted) return; var added = 0; for (final item in page.items) { if (_seen.add(item.dedupKey)) { _items.add(item); added++; } } _items.sort((a, b) => b.time.compareTo(a.time)); _total = page.total > _total ? page.total : _total; setState(() { _canLoadMore = added > 0 && _items.length < _total; _loading = false; _loadingMore = false; }); WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoLoad()); } void _maybeAutoLoad() { if (!mounted || !_hasMore || _loadingMore) return; final controller = widget.scrollController; if (controller == null || !controller.hasClients) return; final position = controller.position; if (position.maxScrollExtent - position.pixels <= 800) { _loadMore(); } } Future _loadMore() async { if (_loadingMore || _loading || _items.isEmpty) return; setState(() => _loadingMore = true); await _load(_items.last.messageId, initial: false); } bool get _hasMore => _canLoadMore; String _resolveName(int senderId) { final l10n = AppLocalizations.of(context)!; if (senderId == widget.myId) return l10n.callParticipantYou; return ContactCache.get(senderId) ?? '#$senderId'; } void _goTo(SharedMediaItem item) => widget.onGoToMessage(item.messageId, item.time); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (_loading) return _loadingState(cs); if (_items.isEmpty) { return _emptyState(cs, widget.emptyLabel, widget.emptyIcon); } final l10n = AppLocalizations.of(context)!; final locale = l10n.localeName; final groups = _groupByMonth(_items); final children = []; for (final group in groups) { children.add(_sectionHeader(cs, _monthHeader(locale, group.month))); switch (widget.kind) { case SharedContentKind.media: children.add(_mediaGrid(cs, group.items)); case SharedContentKind.files: children.addAll( group.items.map( (i) => _FileRow( item: i, sourceName: widget.sourceName, onGoTo: () => _goTo(i), ), ), ); case SharedContentKind.voice: children.addAll( group.items.map( (i) => _ProfileVoiceTile( item: i, senderName: _resolveName(i.senderId), sourceName: widget.sourceName, onGoTo: () => _goTo(i), ), ), ); case SharedContentKind.links: children.addAll( group.items.map((i) => _LinkRow(item: i, onGoTo: () => _goTo(i))), ); } } if (_hasMore) { children.add( Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: _loadingMore ? SmallSpinner(size: 22, color: cs.primary) : TextButton( onPressed: _loadMore, child: Text(l10n.sharedLoadMore), ), ), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ); } Widget _mediaGrid(ColorScheme cs, List items) { return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 3, crossAxisSpacing: 3, ), itemCount: items.length, itemBuilder: (context, index) => _MediaTile( item: items[index], onGoTo: () => _goTo(items[index]), onGoToMessage: widget.onGoToMessage, sourceName: widget.sourceName, ), ); } } class _MediaTile extends StatelessWidget { final SharedMediaItem item; final VoidCallback onGoTo; final void Function(String messageId, int time) onGoToMessage; final String sourceName; const _MediaTile({ required this.item, required this.onGoTo, required this.onGoToMessage, required this.sourceName, }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; _showItemMenu(context, [ _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { await _downloadAttachment(context, item, sourceName); }), ]); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final att = item.attachment; final video = att is VideoAttachment ? att : null; final duration = video?.duration ?? 0; final thumb = att.baseUrl?.isNotEmpty == true ? att.baseUrl : att.previewData; return GestureDetector( onTap: () => _open(context), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack( fit: StackFit.expand, children: [ Container(color: cs.surfaceContainerHighest), if (thumb != null && thumb.isNotEmpty) CachedNetworkImage( imageUrl: thumb, fit: BoxFit.cover, memCacheWidth: 300, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => Icon( video != null ? Symbols.movie : Symbols.image, color: cs.onSurfaceVariant.withValues(alpha: 0.4), ), ), if (video != null) ...[ const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.center, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black54], ), ), ), const Center( child: Icon(Symbols.play_arrow, color: Colors.white, size: 34), ), if (duration > 0) Positioned( left: 6, bottom: 6, child: Text( formatSecondsMmSs((duration / 1000).round()), style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), ], Positioned( top: 4, right: 4, child: _moreButton(cs, () => _menu(context), overlay: true), ), ], ), ), ); } Future _open(BuildContext context) async { final att = item.attachment; if (att is VideoAttachment) { final sources = await messagesModule.getVideoSources( messageId: item.messageId, chatId: item.chatId, token: att.videoToken ?? '', videoId: att.videoId ?? 0, ); if (!context.mounted) return; if (sources.isEmpty) { showCustomNotification(context, 'Не удалось загрузить видео'); return; } pushSwipeable( context, (_) => PhotoViewerScreen.video( attachment: att, initialVideoSources: sources, chatId: item.chatId, message: CachedMessage( id: item.messageId, accountId: 0, chatId: item.chatId, senderId: item.senderId, text: item.text, time: item.time, ), actions: PhotoViewerActions(goToMessage: onGoToMessage), sourceName: sourceName, ), ); return; } final url = att.baseUrl ?? att.previewData ?? ''; if (url.isEmpty) return; final photo = att is PhotoAttachment && (att.baseUrl ?? '').isNotEmpty ? att : PhotoAttachment(baseUrl: url); pushSwipeable( context, (_) => PhotoViewerScreen( photos: [photo], chatId: item.chatId, message: CachedMessage( id: item.messageId, accountId: 0, chatId: item.chatId, senderId: item.senderId, text: item.text, time: item.time, ), actions: PhotoViewerActions(goToMessage: onGoToMessage), sourceName: sourceName, ), ); } } class _FileRow extends StatelessWidget { final SharedMediaItem item; final String sourceName; final VoidCallback onGoTo; const _FileRow({ required this.item, required this.sourceName, required this.onGoTo, }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; _showItemMenu(context, [ _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { await _downloadAttachment(context, item, sourceName); }), ]); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final att = item.attachment as FileAttachment; final fullName = att.name ?? 'file'; final dot = fullName.lastIndexOf('.'); final ext = dot > 0 && dot < fullName.length - 1 ? fullName.substring(dot + 1).toUpperCase() : ''; final displayName = dot > 0 ? fullName.substring(0, dot) : fullName; final size = att.size ?? 0; final cacheName = '${att.fileId}_$fullName'; return InkWell( onTap: () => _open(context, cacheName), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: Row( children: [ _badge(cs, ext, cacheName), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( ext.isEmpty ? formatBytes(size) : '$ext • ${formatBytes(size)}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), _moreButton(cs, () => _menu(context)), ], ), ), ); } Widget _badge(ColorScheme cs, String ext, String cacheName) { return SizedBox( width: 46, height: 46, child: ValueListenableBuilder( valueListenable: MediaDownloadProgress.notifier(cacheName), builder: (context, progress, _) { return Container( decoration: BoxDecoration( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), alignment: Alignment.center, child: progress != null ? SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.2, value: progress > 0 ? progress : null, color: cs.primary, ), ) : Stack( alignment: Alignment.center, children: [ Icon( Symbols.download, color: cs.onSurfaceVariant.withValues(alpha: 0.5), size: 26, ), if (ext.isNotEmpty) Positioned( bottom: 4, child: Text( ext, style: TextStyle( color: cs.onSurface, fontSize: 8, fontWeight: FontWeight.w700, ), ), ), ], ), ); }, ), ); } Future _open(BuildContext context, String cacheName) async { final att = item.attachment as FileAttachment; final fileId = att.fileId; if (fileId == null) return; if (MediaDownloadProgress.notifier(cacheName).value != null) return; MediaDownloadProgress.set(cacheName, 0); final result = await openCachedFile( cacheName, () => messagesModule.getFileUrl( messageId: item.messageId, chatId: item.chatId, fileId: fileId, ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), onReady: () => MediaDownloadProgress.set(cacheName, null), download: DownloadMetadata( cacheName: cacheName, name: downloadKindForName(att.name ?? '') == DownloadKind.file ? att.name ?? '' : '', kind: downloadKindForName(att.name ?? ''), sourceName: sourceName, thumbnailUrl: att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, expectedSize: att.size ?? 0, chatId: item.chatId, messageId: item.messageId, messageTime: item.time, ), ); if (!context.mounted) return; if (!result.ok) { showCustomNotification(context, 'Не удалось открыть файл'); } } } class _LinkRow extends StatelessWidget { final SharedMediaItem item; final VoidCallback onGoTo; const _LinkRow({required this.item, required this.onGoTo}); void _menu(BuildContext context, String url) { final l10n = AppLocalizations.of(context)!; _showItemMenu(context, [ _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { onGoTo(); }), if (url.isNotEmpty) _MenuAction(Symbols.content_copy, l10n.sharedCopyLink, () async { await Clipboard.setData(ClipboardData(text: url)); if (context.mounted) { showCustomNotification(context, l10n.sharedLinkCopied); } }), ]); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final att = item.attachment as ShareAttachment; final url = att.url ?? ''; final host = att.host ?? (url.isNotEmpty ? Uri.tryParse(url)?.host ?? '' : ''); final title = att.title ?? url; final image = att.image; final thumb = image?.baseUrl?.isNotEmpty == true ? image!.baseUrl : image?.previewData; return InkWell( onTap: url.isEmpty ? null : () => openExternalUrl(context, url), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(10), child: Container( width: 46, height: 46, color: cs.surfaceContainerHighest, child: (thumb != null && thumb.isNotEmpty) ? CachedNetworkImage( imageUrl: thumb, fit: BoxFit.cover, memCacheWidth: 120, errorWidget: (_, _, _) => Icon( Symbols.link, color: cs.onSurfaceVariant.withValues(alpha: 0.5), ), ) : Icon( Symbols.link, color: cs.onSurfaceVariant.withValues(alpha: 0.5), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (host.isNotEmpty) Text( host, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 11, ), ), if (title.isNotEmpty) Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600, ), ), if (att.description != null && att.description!.isNotEmpty) ...[ const SizedBox(height: 2), Text( att.description!, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, height: 1.25, ), ), ], if (url.isNotEmpty) ...[ const SizedBox(height: 2), Text( url, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.primary, fontSize: 13), ), ], ], ), ), _moreButton(cs, () => _menu(context, url)), ], ), ), ); } } class _ProfileVoiceTile extends StatefulWidget { final SharedMediaItem item; final String senderName; final String sourceName; final VoidCallback onGoTo; const _ProfileVoiceTile({ required this.item, required this.senderName, required this.sourceName, required this.onGoTo, }); @override State<_ProfileVoiceTile> createState() => _ProfileVoiceTileState(); } class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { late final VoiceAudioController _player; AudioAttachment get _audio => widget.item.attachment as AudioAttachment; int get _durationSec => ((_audio.duration ?? 0) / 1000).round(); @override void initState() { super.initState(); _player = VoiceAudioController( cacheName: '${_audio.audioId ?? widget.item.messageId}.ogg', resolveUrl: () async => _audio.fileUrl ?? _audio.baseUrl ?? '', fallbackDuration: Duration(milliseconds: _audio.duration ?? 0), ); _player.failure.addListener(_onFailure); } @override void dispose() { _player.failure.removeListener(_onFailure); _player.dispose(); super.dispose(); } void _onFailure() { if (!mounted) return; switch (_player.failure.value) { case VoiceAudioFailure.none: return; case VoiceAudioFailure.download: showCustomNotification(context, 'Не удалось загрузить аудио'); case VoiceAudioFailure.playback: showCustomNotification(context, 'Ошибка воспроизведения'); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final date = DateTime.fromMillisecondsSinceEpoch(widget.item.time); final subtitle = '${formatSecondsMmSs(_durationSec)} • ${formatDateTimeWords(date)}'; return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: Row( children: [ GestureDetector( onTap: _player.toggle, child: Container( width: 46, height: 46, decoration: BoxDecoration( color: cs.primary, shape: BoxShape.circle, ), child: AnimatedBuilder( animation: Listenable.merge([ _player.downloaded, _player.downloadProgress, _player.playing, _player.position, _player.duration, ]), builder: (context, _) { final download = _player.downloadProgress.value; if (download != null) { return Padding( padding: const EdgeInsets.all(11), child: CircularProgressIndicator( strokeWidth: 2, value: download > 0 ? download : null, color: cs.onPrimary, backgroundColor: cs.onPrimary.withValues(alpha: 0.25), ), ); } final total = _player.duration.value; final progress = total > 0 ? (_player.position.value / total).clamp(0.0, 1.0) : 0.0; final IconData icon; if (_player.playing.value) { icon = Symbols.pause; } else if (_player.downloaded.value) { icon = Symbols.play_arrow; } else { icon = Symbols.arrow_downward; } return Stack( alignment: Alignment.center, children: [ if (progress > 0 && progress < 1) SizedBox( width: 46, height: 46, child: CircularProgressIndicator( strokeWidth: 2, value: progress, color: cs.onPrimary.withValues(alpha: 0.5), backgroundColor: Colors.transparent, ), ), Icon(icon, color: cs.onPrimary, size: 24, fill: 1), ], ); }, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.senderName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( subtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), _moreButton(cs, () => _menu(context)), ], ), ); } void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; _showItemMenu(context, [ _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { widget.onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { await _downloadAttachment(context, widget.item, widget.sourceName); }), ]); } }