Files
Qlyra/lib/frontend/widgets/chat_info/shared_content_tabs.dart
T

1317 lines
39 KiB
Dart

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<String> 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<String> _ruMonthsFull = [
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь',
];
const List<String> _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<SharedMediaItem> items})> _groupByMonth(
List<SharedMediaItem> items,
) {
final groups = <({DateTime month, List<SharedMediaItem> 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<void> Function() onTap;
const _MenuAction(this.icon, this.label, this.onTap);
}
Future<void> _showItemMenu(BuildContext context, List<_MenuAction> actions) {
final cs = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
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<void> _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<CommonChatsTab> createState() => _CommonChatsTabState();
}
class _CommonChatsTabState extends State<CommonChatsTab>
with ReloadOnReconnect {
bool _loading = true;
List<CommonChatEntry> _chats = const [];
Map<int, int> _onlineByChat = const {};
@override
void initState() {
super.initState();
_load();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _load() async {
final chats = await sharedContentModule.fetchCommonChats(widget.userId);
final allIds = <int>{};
for (final c in chats) {
allIds.addAll(c.participantIds);
}
final onlineByChat = <int, int>{};
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<SharedMediaTab> createState() => _SharedMediaTabState();
}
class _SharedMediaTabState extends State<SharedMediaTab>
with ReloadOnReconnect {
static const int _pageSize = 60;
bool _loading = true;
bool _loadingMore = false;
bool _canLoadMore = false;
int _total = 0;
final List<SharedMediaItem> _items = [];
final Set<String> _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<void> _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<void> _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 = <Widget>[];
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<SharedMediaItem> 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<void> _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<double?>(
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<void> _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);
}),
]);
}
}