Переход к медиа на экране профиля, часть issue #45

This commit is contained in:
Jganenokk
2026-07-08 21:33:03 +07:00
parent eb2f108d2a
commit f68d97f022
11 changed files with 2040 additions and 172 deletions
+192
View File
@@ -0,0 +1,192 @@
import '../../core/protocol/opcode_map.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../api.dart';
const Map<String, AttachmentType> _attachTypeByName = {
'PHOTO': AttachmentType.photo,
'VIDEO': AttachmentType.video,
'AUDIO': AttachmentType.audio,
'FILE': AttachmentType.file,
'SHARE': AttachmentType.share,
};
class SharedMediaItem {
final String messageId;
final int chatId;
final int senderId;
final int time;
final MessageAttachment attachment;
const SharedMediaItem({
required this.messageId,
required this.chatId,
required this.senderId,
required this.time,
required this.attachment,
});
String get dedupKey {
final a = attachment;
final String tail;
if (a is PhotoAttachment) {
tail = 'p${a.photoId ?? a.baseUrl}';
} else if (a is VideoAttachment) {
tail = 'v${a.videoId ?? a.baseUrl}';
} else if (a is FileAttachment) {
tail = 'f${a.fileId ?? a.name}';
} else if (a is AudioAttachment) {
tail = 'a${a.audioId ?? a.fileUrl}';
} else if (a is ShareAttachment) {
tail = 's${a.shareId ?? a.url}';
} else {
tail = a.hashCode.toString();
}
return '$messageId:$tail';
}
}
class SharedMediaPage {
final List<SharedMediaItem> items;
final int total;
const SharedMediaPage({required this.items, required this.total});
static const empty = SharedMediaPage(items: [], total: 0);
}
class CommonChatEntry {
final int id;
final String type;
final String title;
final String? iconUrl;
final int participantsCount;
final List<int> participantIds;
const CommonChatEntry({
required this.id,
required this.type,
required this.title,
required this.iconUrl,
required this.participantsCount,
required this.participantIds,
});
factory CommonChatEntry.fromMap(Map<String, dynamic> map) {
final participants = map['participants'];
final ids = <int>[];
if (participants is Map) {
for (final key in participants.keys) {
final id = key is int ? key : int.tryParse(key.toString());
if (id != null) ids.add(id);
}
}
return CommonChatEntry(
id: (map['id'] as num?)?.toInt() ?? 0,
type: map['type']?.toString() ?? 'CHAT',
title: map['title']?.toString() ?? '',
iconUrl: map['baseIconUrl'] as String?,
participantsCount:
(map['participantsCount'] as num?)?.toInt() ?? ids.length,
participantIds: ids,
);
}
}
class SharedContentModule {
final Api _api;
SharedContentModule(this._api);
Future<SharedMediaPage> fetchMedia({
required int chatId,
required String anchorMessageId,
required List<String> attachTypes,
int forward = 0,
int backward = 60,
}) async {
try {
final response = await _api.sendRequest(Opcode.chatMedia, {
'chatId': chatId,
'messageId': int.tryParse(anchorMessageId) ?? 0,
'attachTypes': attachTypes,
'forward': forward,
'backward': backward,
});
if (!response.isOk) return SharedMediaPage.empty;
final data = response.payload;
if (data is! Map) return SharedMediaPage.empty;
final messages = data['messages'];
if (messages is! List) return SharedMediaPage.empty;
final wanted = attachTypes
.map((t) => _attachTypeByName[t])
.whereType<AttachmentType>()
.toSet();
final out = <SharedMediaItem>[];
for (final m in messages) {
if (m is! Map) continue;
final map = Map<String, dynamic>.from(m);
final id = map['id']?.toString();
if (id == null) continue;
final sender = (map['sender'] as num?)?.toInt() ?? 0;
final time = (map['time'] as num?)?.toInt() ?? 0;
final attaches = map['attaches'];
if (attaches is! List) continue;
for (final a in attaches) {
if (a is! Map) continue;
final att = MessageAttachment.fromMap(Map<String, dynamic>.from(a));
if (!wanted.contains(att.type)) continue;
out.add(
SharedMediaItem(
messageId: id,
chatId: chatId,
senderId: sender,
time: time,
attachment: att,
),
);
}
}
out.sort((a, b) => b.time.compareTo(a.time));
final total = (data['total'] as num?)?.toInt() ?? out.length;
return SharedMediaPage(items: out, total: total);
} catch (e) {
logger.w('SharedContent.fetchMedia failed: $e');
return SharedMediaPage.empty;
}
}
Future<List<CommonChatEntry>> fetchCommonChats(int userId) async {
try {
final response = await _api.sendRequest(
Opcode.chatSearchCommonParticipants,
{
'userIds': [userId],
},
);
if (!response.isOk) return const [];
final data = response.payload;
if (data is! Map) return const [];
final chats = data['commonChats'];
if (chats is! List) return const [];
final out = <CommonChatEntry>[];
for (final c in chats) {
if (c is Map) {
out.add(CommonChatEntry.fromMap(Map<String, dynamic>.from(c)));
}
}
return out;
} catch (e) {
logger.w('SharedContent.fetchCommonChats failed: $e');
return const [];
}
}
}
+46
View File
@@ -51,6 +51,52 @@ Future<MediaSaveResult> saveImageFromUrl(String url) async {
}
}
enum SaveMediaKind { image, video, file }
Future<MediaSaveResult> saveMediaFile({
required String cacheName,
required Future<String?> Function() resolveUrl,
required String saveName,
required SaveMediaKind kind,
}) async {
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await resolveUrl();
if (url == null || url.isEmpty) {
return const MediaSaveResult(ok: false, error: 'нет ссылки');
}
file = await MediaCache.getOrDownload(cacheName, url);
}
if (file == null) {
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
}
final toGallery =
kind == SaveMediaKind.image || kind == SaveMediaKind.video;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) {
final state = await PhotoManager.requestPermissionExtend();
if (!state.isAuth && !state.hasAccess) {
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
}
if (kind == SaveMediaKind.video) {
await PhotoManager.editor.saveVideo(file, title: saveName);
} else {
final bytes = await file.readAsBytes();
await PhotoManager.editor.saveImage(bytes, filename: saveName);
}
return const MediaSaveResult(ok: true, toGallery: true);
}
final dir = await _targetDirectory();
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
await file.copy(target.path);
return MediaSaveResult(ok: true, location: target.path);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
Future<Directory> _targetDirectory() async {
try {
final downloads = await getDownloadsDirectory();
@@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/cache/info_cache.dart';
@@ -11,6 +12,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart';
import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/chat_info/shared_content_tabs.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
@@ -43,6 +45,8 @@ class ChatInfoScreen extends StatefulWidget {
final int? dialogPeerId;
final void Function(String messageId, int time)? onJumpToMessage;
const ChatInfoScreen({
super.key,
required this.chatId,
@@ -50,6 +54,7 @@ class ChatInfoScreen extends StatefulWidget {
required this.imageUrl,
required this.chatType,
this.dialogPeerId,
this.onJumpToMessage,
});
@override
@@ -76,6 +81,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
List<_MemberInfo> _members = [];
int _onlineCount = 0;
int _mediaChatId = 0;
String? _anchorMsgId;
@override
void initState() {
super.initState();
@@ -140,6 +148,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (!mounted) return;
_chatInfo = info;
_mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId;
final lastMessage = info?.raw['lastMessage'];
if (lastMessage is Map) {
_anchorMsgId = lastMessage['id']?.toString();
}
if (_anchorMsgId == null && info != null) {
try {
final recent = await messagesModule.fetchHistory(
_myId,
_mediaChatId,
count: 1,
);
if (recent.isNotEmpty) _anchorMsgId = recent.first.id;
} catch (_) {}
if (!mounted) return;
}
if (widget.chatType == 'DIALOG') {
_otherId = widget.dialogPeerId;
if (_otherId == null && info != null) {
@@ -671,27 +696,94 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
return _buildMembersTabContent(cs);
}
if (_selectedTab == l10n.chatInfoTabGeneralChats) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyGeneralChats, Icons.group);
final peerId = _otherId;
if (peerId == null) {
return _buildPlaceholder(
cs,
l10n.chatInfoEmptyGeneralChats,
Icons.group,
);
}
return CommonChatsTab(
key: const ValueKey('tab-common-chats'),
userId: peerId,
emptyLabel: l10n.chatInfoEmptyGeneralChats,
);
}
if (_selectedTab == l10n.chatInfoTabMedia) {
return _buildPlaceholder(
return _sharedTab(
cs,
SharedContentKind.media,
l10n.chatInfoEmptyMedia,
Icons.photo_library,
);
}
if (_selectedTab == l10n.chatInfoTabFiles) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyFiles, Icons.description);
return _sharedTab(
cs,
SharedContentKind.files,
l10n.chatInfoEmptyFiles,
Icons.description,
);
}
if (_selectedTab == l10n.chatInfoTabVoice) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyVoice, Icons.mic);
return _sharedTab(
cs,
SharedContentKind.voice,
l10n.chatInfoEmptyVoice,
Icons.mic,
);
}
if (_selectedTab == l10n.chatInfoTabLinks) {
return _buildPlaceholder(cs, l10n.chatInfoEmptyLinks, Icons.link);
return _sharedTab(
cs,
SharedContentKind.links,
l10n.chatInfoEmptyLinks,
Icons.link,
);
}
return const SizedBox.shrink();
}
Widget _sharedTab(
ColorScheme cs,
SharedContentKind kind,
String emptyLabel,
IconData emptyIcon,
) {
final anchor = _anchorMsgId;
if (anchor == null) return _buildPlaceholder(cs, emptyLabel, emptyIcon);
return SharedMediaTab(
key: ValueKey('tab-shared-$kind'),
chatId: _mediaChatId,
anchorMessageId: anchor,
myId: _myId,
kind: kind,
emptyLabel: emptyLabel,
emptyIcon: emptyIcon,
onGoToMessage: _goToMessage,
);
}
void _goToMessage(String messageId, int time) {
final jumpInParent = widget.onJumpToMessage;
if (jumpInParent != null && _mediaChatId == widget.chatId) {
jumpInParent(messageId, time);
return;
}
pushSwipeable(
context,
(_) => ChatScreen(
chatId: _mediaChatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType,
initialMessageId: messageId,
initialMessageTime: time,
),
);
}
Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
+420 -167
View File
@@ -180,6 +180,8 @@ class ChatScreen extends StatefulWidget {
final bool embedded;
final VoidCallback? onClose;
final ForwardRequest? forwardRequest;
final String? initialMessageId;
final int? initialMessageTime;
const ChatScreen({
super.key,
@@ -190,6 +192,8 @@ class ChatScreen extends StatefulWidget {
this.embedded = false,
this.onClose,
this.forwardRequest,
this.initialMessageId,
this.initialMessageTime,
});
@override
@@ -208,8 +212,11 @@ class _ChatScreenState extends State<ChatScreen>
double _pinnedAlignment = 0;
int? _unreadAnchorTime;
bool _awaitingPosition = false;
bool _navigatingToTarget = false;
bool _initialPositionDone = false;
bool _positioningInFlight = false;
bool _initialTargetHandled = false;
bool _suppressHistoryAutoload = false;
int _readMarkTime = 0;
Timer? _readMarkTimer;
final GlobalKey _listKey = GlobalKey();
@@ -275,6 +282,9 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
Timer? _highlightTimer;
final ValueNotifier<double?> _jumpCacheExtent = ValueNotifier<double?>(null);
Timer? _goToMessageSettleTimer;
static const double _jumpCacheExtentPx = 800.0;
late final ChatSearchController _search;
late final AnimationController _searchAnim;
@@ -690,6 +700,46 @@ class _ChatScreenState extends State<ChatScreen>
_isLoading = false;
if (_shimmerController.isAnimating) _shimmerController.stop();
_scheduleReadMarker();
_maybeRunInitialTarget();
}
void _maybeRunInitialTarget() {
if (_initialTargetHandled || widget.initialMessageId == null) return;
_initialTargetHandled = true;
_beginTargetNavigation();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_navigateToInitialMessage());
});
}
void _beginTargetNavigation() {
_navigatingToTarget = true;
_jumpCacheExtent.value = _jumpCacheExtentPx;
_goToMessageSettleTimer?.cancel();
if (!_shimmerController.isAnimating) _shimmerController.repeat();
}
void _finishTargetNavigation() {
_goToMessageSettleTimer?.cancel();
if (!mounted) {
_navigatingToTarget = false;
return;
}
if (_navigatingToTarget) {
setState(() => _navigatingToTarget = false);
}
if (_shimmerController.isAnimating) _shimmerController.stop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _jumpCacheExtent.value = null;
});
}
void _requestGoToMessage(String id, int time) {
if (!mounted) return;
setState(_beginTargetNavigation);
_goToMessageSettleTimer = Timer(const Duration(milliseconds: 340), () {
if (mounted) unawaited(_runGoToMessage(id, time));
});
}
Future<void> _loadUntilUnreadReady() async {
@@ -754,7 +804,12 @@ class _ChatScreenState extends State<ChatScreen>
final atBottom = candidate.id == _messages.last.id;
if (_unreadAnchorTime != null &&
_unreadSeparatorScrolledPast(atBottom, topIndex, listBox, viewportBottom)) {
_unreadSeparatorScrolledPast(
atBottom,
topIndex,
listBox,
viewportBottom,
)) {
_unreadAnchorTime = null;
_bumpMessages();
}
@@ -1014,6 +1069,7 @@ class _ChatScreenState extends State<ChatScreen>
void _maybeLoadMoreHistory() {
if (!_scrollController.hasClients) return;
if (_suppressHistoryAutoload) return;
if (_isLoading || _isLoadingMore || !_hasMoreHistory) return;
if (_messages.isEmpty) return;
final pos = _scrollController.position;
@@ -1186,6 +1242,8 @@ class _ChatScreenState extends State<ChatScreen>
_replyTo.dispose();
_highlightTimer?.cancel();
_highlightMessageId.dispose();
_goToMessageSettleTimer?.cancel();
_jumpCacheExtent.dispose();
_messageKeys.clear();
super.dispose();
}
@@ -2158,17 +2216,29 @@ class _ChatScreenState extends State<ChatScreen>
showCall:
widget.chatType == 'DIALOG' && !_peerIsBot,
onClose: widget.onClose,
onOpenInfo: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType,
onOpenInfo: () {
final navigator = Navigator.of(context);
final chatRoute = ModalRoute.of(context);
navigator.push(
MaterialPageRoute(
builder: (context) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType,
onJumpToMessage:
(chatRoute == null || widget.embedded)
? null
: (messageId, time) {
navigator.popUntil(
(r) => r == chatRoute,
);
_requestGoToMessage(messageId, time);
},
),
),
),
),
);
},
onOpenScheduled: _openScheduledMessages,
onCall: _startCall,
onMenu: _openChatMenu,
@@ -3193,6 +3263,163 @@ class _ChatScreenState extends State<ChatScreen>
_search.reset();
}
Future<void> _navigateToInitialMessage() async {
final id = widget.initialMessageId;
if (id == null) {
_finishTargetNavigation();
return;
}
await _runGoToMessage(id, widget.initialMessageTime ?? 0);
}
Future<void> _runGoToMessage(String id, int targetTime) async {
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
if (!_messages.any((m) => m.id == id)) {
var guard = 0;
while (mounted &&
guard < 80 &&
_hasMoreHistory &&
!_messages.any((m) => m.id == id) &&
(_messages.isEmpty || _messages.first.time > targetTime)) {
guard++;
final before = _messages.isEmpty ? 0 : _messages.first.time;
await _loadMoreHistory();
if (!mounted) return;
final after = _messages.isEmpty ? 0 : _messages.first.time;
if (after == before) break;
}
if (!mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
}
if (!_messages.any((m) => m.id == id)) {
if (mounted) showCustomNotification(context, 'Сообщение не загружено');
_finishTargetNavigation();
return;
}
_highlightTimer?.cancel();
_highlightMessageId.value = id;
_highlightTimer = Timer(const Duration(milliseconds: 2200), () {
if (!mounted) return;
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
});
await _scrollToMessagePrecise(id);
_finishTargetNavigation();
}
Future<void> _scrollToMessagePrecise(
String id, {
double alignment = 0.32,
}) async {
if (!mounted || !_scrollController.hasClients) return;
if (_messages.indexWhere((m) => m.id == id) == -1) return;
_suppressHistoryAutoload = true;
try {
var stable = 0;
for (var iter = 0; iter < 48; iter++) {
if (!mounted || !_scrollController.hasClients) return;
final listObj = _listKey.currentContext?.findRenderObject();
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
if (boxObj is RenderBox && boxObj.attached && listObj is RenderBox) {
final viewportH = listObj.size.height;
final actualTop = boxObj
.localToGlobal(Offset.zero, ancestor: listObj)
.dy;
final desiredTop = alignment * viewportH;
final delta = desiredTop - actualTop;
final p = _scrollController.position;
final target = (p.pixels + delta).clamp(
p.minScrollExtent,
p.maxScrollExtent,
);
if (delta.abs() <= 4.0 || (target - p.pixels).abs() <= 1.0) {
stable++;
if (stable >= 3) return;
await Future.delayed(const Duration(milliseconds: 130));
continue;
}
stable = 0;
_scrollController.jumpTo(target);
await WidgetsBinding.instance.endOfFrame;
continue;
}
stable = 0;
final items = _buildCombinedItems();
final pos = items.indexWhere(
(it) => it is _MessageItem && it.message.id == id,
);
if (pos == -1) return;
var below = 0.0;
for (var i = pos + 1; i < items.length; i++) {
below += _estimatedItemExtent(items[i]);
}
final p = _scrollController.position;
final maxExtent = p.maxScrollExtent;
final viewportH = listObj is RenderBox ? listObj.size.height : 500.0;
var targetOffset = below.clamp(0.0, maxExtent).toDouble();
if ((targetOffset - p.pixels).abs() < 4.0) {
targetOffset = (p.pixels + viewportH * 0.8).clamp(0.0, maxExtent);
if ((targetOffset - p.pixels).abs() < 4.0) return;
}
_scrollController.jumpTo(targetOffset);
await WidgetsBinding.instance.endOfFrame;
}
} finally {
_suppressHistoryAutoload = false;
}
}
double _estimatedItemExtent(Object item) {
if (item is! _MessageItem) return 44.0;
final msg = item.message;
var base = 0.0;
final atts = msg.attachments;
if (atts != null && atts.isNotEmpty) {
for (final a in atts) {
switch (a.type) {
case AttachmentType.photo:
case AttachmentType.video:
int? w;
int? h;
if (a is PhotoAttachment) {
w = a.width;
h = a.height;
} else if (a is VideoAttachment) {
w = a.width;
h = a.height;
}
base += (w != null && h != null && w > 0)
? (236.0 * h / w).clamp(120.0, 360.0)
: 260.0;
base += 12;
case AttachmentType.sticker:
base += 160;
case AttachmentType.audio:
base += 72;
case AttachmentType.file:
base += 80;
case AttachmentType.share:
base += 96;
default:
base += 60;
}
}
}
final textLen = msg.text?.length ?? 0;
if (textLen > 0) base += 24.0 + (textLen ~/ 34) * 20.0;
if (base <= 0) base = _avgMessageHeight;
return base.clamp(44.0, 1200.0).toDouble();
}
Future<void> _openSearchResult(MessageSearchResult result) async {
_closeSearch();
await WidgetsBinding.instance.endOfFrame;
@@ -3243,7 +3470,9 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
final laidOut = _keyForMessage(messageId).currentContext?.findRenderObject();
final laidOut = _keyForMessage(
messageId,
).currentContext?.findRenderObject();
if (laidOut is! RenderBox || !laidOut.attached) {
var below = 0.0;
for (var i = pos + 1; i < items.length; i++) {
@@ -3685,9 +3914,7 @@ class _ChatScreenState extends State<ChatScreen>
children: [
if (_wallpaper != null)
Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)),
Positioned.fill(
child: _buildMessagesArea(),
),
Positioned.fill(child: _buildMessagesArea()),
SearchOverlay(
cs: cs,
searchAnim: _searchAnim,
@@ -3772,7 +3999,9 @@ class _ChatScreenState extends State<ChatScreen>
}
Widget _buildMessagesArea() {
final showShimmer = _messages.isEmpty ? _isLoading : _awaitingPosition;
final showShimmer = _messages.isEmpty
? _isLoading
: (_awaitingPosition || _navigatingToTarget);
return Stack(
fit: StackFit.expand,
children: [
@@ -3849,160 +4078,184 @@ class _ChatScreenState extends State<ChatScreen>
children: [
ValueListenableBuilder<double>(
valueListenable: AppCacheExtent.current,
builder: (context, cacheExtent, _) => ListView.builder(
controller: _scrollController,
reverse: true,
padding: _messagesListPadding(context),
cacheExtent: cacheExtent,
itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => SizedBox(
height: AppChatChrome.current.value == ChatChromeStyle.color
? 0
: height,
),
);
}
if (index > items.length) {
return _buildLoadMoreIndicator();
}
final item = items[items.length - index];
builder: (context, userCacheExtent, _) =>
ValueListenableBuilder<double?>(
valueListenable: _jumpCacheExtent,
builder: (context, jumpExtent, _) {
final cacheExtent =
jumpExtent != null && jumpExtent < userCacheExtent
? jumpExtent
: userCacheExtent;
return ListView.builder(
controller: _scrollController,
reverse: true,
padding: _messagesListPadding(context),
cacheExtent: cacheExtent,
itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return ValueListenableBuilder<double>(
valueListenable: _composerHeight,
builder: (context, height, _) => SizedBox(
height:
AppChatChrome.current.value ==
ChatChromeStyle.color
? 0
: height,
),
);
}
if (index > items.length) {
return _buildLoadMoreIndicator();
}
final item = items[items.length - index];
if (item is _DateSeparatorItem) {
return _buildDateSeparatorWidget(
context,
item.date,
key: item.key,
);
}
if (item is _UnreadSeparatorItem) {
return _buildUnreadSeparatorWidget(context);
}
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;
final isMe = message.senderId == _myId;
final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null;
final nextMessage = msgIndex < _messages.length - 1
? _messages[msgIndex + 1]
: null;
final bubble = MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message),
otherReadTime: _otherReadTime,
reactionsListenable: _reactionNotifierFor(message),
uploadProgress: _photoProgressFor(message),
onReplyTap: _jumpToMessage,
onAvatarTap: _openSenderProfile,
onStickerTap: _openStickerPack,
);
final canReport = !isMe && !message.isControl;
final reportTypeId = _complaintTypeId(
chat?.type ?? widget.chatType,
);
final pressable = _SelectableMessageRow(
message: message,
isMe: isMe,
selectedIds: _selectedIds,
selectionAnim: _selectionAnim,
isSelectionActive: () => _selectionMode,
onToggleSelection: () => _toggleSelection(message),
onEnterSelection: () => _enterSelection(message),
onDelete: () => _confirmDeleteMessage(message, isMe),
onEdit: _canEditMessage(message)
? () => _startEditMessage(message)
: null,
onReply: message.isControl ? null : () => _startReply(message),
onForward: message.isControl
? null
: () => _forwardMessages([message]),
onMarkUnread: message.isControl
? null
: () => _markMessageUnread(message),
onPin: _canPinMessage(message)
? () => _togglePinMessage(message)
: null,
isPinned: () => chat?.pinnedMsgId == int.tryParse(message.id),
loadReportReasons: canReport
? () => _loadReportReasons(reportTypeId)
: null,
onReport: canReport
? (reasonId) =>
_reportMessage(message, reportTypeId, reasonId)
: null,
child: bubble,
);
final isChannel = (chat?.type ?? widget.chatType) == 'CHANNEL';
final swipeable = (message.isControl || isChannel)
? pressable
: _SwipeToReply(
isMe: isMe,
onReply: () => _startReply(message),
child: pressable,
);
final Widget child;
if (_deletingIds.contains(message.id)) {
child = _DeletingMessageAnimation(
key: ValueKey('del_${message.id}'),
onComplete: () => _finalizeDelete(message.id),
child: IgnorePointer(child: swipeable),
);
} else if (message.id == _lastSentId) {
child = _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) {
_lastSentId = null;
_bumpMessages();
}
},
child: swipeable,
);
} else {
child = swipeable;
}
final highlightable = ValueListenableBuilder<String?>(
valueListenable: _highlightMessageId,
builder: (context, hl, c) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: hl == message.id
? Theme.of(
if (item is _DateSeparatorItem) {
return _buildDateSeparatorWidget(
context,
).colorScheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
child: c,
),
child: child,
);
item.date,
key: item.key,
);
}
final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'),
child: KeyedSubtree(
key: _keyForMessage(message.id),
child: highlightable,
),
);
return message.id == _prank.bubbleId
? KeyedSubtree(key: _prank.bubbleKey, child: builtItem)
: builtItem;
},
),
if (item is _UnreadSeparatorItem) {
return _buildUnreadSeparatorWidget(context);
}
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;
final isMe = message.senderId == _myId;
final prevMessage = msgIndex > 0
? _messages[msgIndex - 1]
: null;
final nextMessage = msgIndex < _messages.length - 1
? _messages[msgIndex + 1]
: null;
final bubble = MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message),
otherReadTime: _otherReadTime,
reactionsListenable: _reactionNotifierFor(message),
uploadProgress: _photoProgressFor(message),
onReplyTap: _jumpToMessage,
onAvatarTap: _openSenderProfile,
onStickerTap: _openStickerPack,
);
final canReport = !isMe && !message.isControl;
final reportTypeId = _complaintTypeId(
chat?.type ?? widget.chatType,
);
final pressable = _SelectableMessageRow(
message: message,
isMe: isMe,
selectedIds: _selectedIds,
selectionAnim: _selectionAnim,
isSelectionActive: () => _selectionMode,
onToggleSelection: () => _toggleSelection(message),
onEnterSelection: () => _enterSelection(message),
onDelete: () => _confirmDeleteMessage(message, isMe),
onEdit: _canEditMessage(message)
? () => _startEditMessage(message)
: null,
onReply: message.isControl
? null
: () => _startReply(message),
onForward: message.isControl
? null
: () => _forwardMessages([message]),
onMarkUnread: message.isControl
? null
: () => _markMessageUnread(message),
onPin: _canPinMessage(message)
? () => _togglePinMessage(message)
: null,
isPinned: () =>
chat?.pinnedMsgId == int.tryParse(message.id),
loadReportReasons: canReport
? () => _loadReportReasons(reportTypeId)
: null,
onReport: canReport
? (reasonId) => _reportMessage(
message,
reportTypeId,
reasonId,
)
: null,
child: bubble,
);
final isChannel =
(chat?.type ?? widget.chatType) == 'CHANNEL';
final swipeable = (message.isControl || isChannel)
? pressable
: _SwipeToReply(
isMe: isMe,
onReply: () => _startReply(message),
child: pressable,
);
final Widget child;
if (_deletingIds.contains(message.id)) {
child = _DeletingMessageAnimation(
key: ValueKey('del_${message.id}'),
onComplete: () => _finalizeDelete(message.id),
child: IgnorePointer(child: swipeable),
);
} else if (message.id == _lastSentId) {
child = _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) {
_lastSentId = null;
_bumpMessages();
}
},
child: swipeable,
);
} else {
child = swipeable;
}
final highlightable = ValueListenableBuilder<String?>(
valueListenable: _highlightMessageId,
builder: (context, hl, c) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: hl == message.id
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
child: c,
),
child: child,
);
final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'),
child: KeyedSubtree(
key: _keyForMessage(message.id),
child: highlightable,
),
);
return message.id == _prank.bubbleId
? KeyedSubtree(
key: _prank.bubbleKey,
child: builtItem,
)
: builtItem;
},
);
},
),
),
Positioned(
top: _floatingDateTop(context),
File diff suppressed because it is too large Load Diff
+13
View File
@@ -497,6 +497,19 @@
}
}
},
"sharedMembersCount": "{count, plural, =1{1 member} other{{count} members}}",
"@sharedMembersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"sharedLoadMore": "Show more",
"sharedGoToMessage": "Go to message",
"sharedDownload": "Download",
"sharedCopyLink": "Copy link",
"sharedLinkCopied": "Link copied",
"chatInfoActionLeave": "Leave",
"chatInfoBio": "About",
"chatInfoInviteLink": "Invite link",
+36
View File
@@ -2462,6 +2462,42 @@ abstract class AppLocalizations {
/// **'{online} of {total} online'**
String chatInfoOnlineOfTotal(String online, String total);
/// No description provided for @sharedMembersCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 member} other{{count} members}}'**
String sharedMembersCount(int count);
/// No description provided for @sharedLoadMore.
///
/// In en, this message translates to:
/// **'Show more'**
String get sharedLoadMore;
/// No description provided for @sharedGoToMessage.
///
/// In en, this message translates to:
/// **'Go to message'**
String get sharedGoToMessage;
/// No description provided for @sharedDownload.
///
/// In en, this message translates to:
/// **'Download'**
String get sharedDownload;
/// No description provided for @sharedCopyLink.
///
/// In en, this message translates to:
/// **'Copy link'**
String get sharedCopyLink;
/// No description provided for @sharedLinkCopied.
///
/// In en, this message translates to:
/// **'Link copied'**
String get sharedLinkCopied;
/// No description provided for @chatInfoActionLeave.
///
/// In en, this message translates to:
+26
View File
@@ -1244,6 +1244,32 @@ class AppLocalizationsEn extends AppLocalizations {
return '$online of $total online';
}
@override
String sharedMembersCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count members',
one: '1 member',
);
return '$_temp0';
}
@override
String get sharedLoadMore => 'Show more';
@override
String get sharedGoToMessage => 'Go to message';
@override
String get sharedDownload => 'Download';
@override
String get sharedCopyLink => 'Copy link';
@override
String get sharedLinkCopied => 'Link copied';
@override
String get chatInfoActionLeave => 'Leave';
+28
View File
@@ -1249,6 +1249,34 @@ class AppLocalizationsRu extends AppLocalizations {
return '$online из $total в сети';
}
@override
String sharedMembersCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count участника',
many: '$count участников',
few: '$count участника',
one: '1 участник',
);
return '$_temp0';
}
@override
String get sharedLoadMore => 'Показать ещё';
@override
String get sharedGoToMessage => 'Перейти к сообщению';
@override
String get sharedDownload => 'Скачать';
@override
String get sharedCopyLink => 'Копировать ссылку';
@override
String get sharedLinkCopied => 'Ссылка скопирована';
@override
String get chatInfoActionLeave => 'Покинуть';
+6
View File
@@ -417,6 +417,12 @@
"chatInfoEmptyVoice": "Нет голосовых",
"chatInfoEmptyLinks": "Нет ссылок",
"chatInfoOnlineOfTotal": "{online} из {total} в сети",
"sharedMembersCount": "{count, plural, =1{1 участник} few{{count} участника} many{{count} участников} other{{count} участника}}",
"sharedLoadMore": "Показать ещё",
"sharedGoToMessage": "Перейти к сообщению",
"sharedDownload": "Скачать",
"sharedCopyLink": "Копировать ссылку",
"sharedLinkCopied": "Ссылка скопирована",
"chatInfoActionLeave": "Покинуть",
"chatInfoBio": "О себе",
"chatInfoInviteLink": "Ссылка-приглашение",
+2
View File
@@ -49,6 +49,7 @@ import 'backend/modules/outbox.dart';
import 'backend/modules/polls.dart';
import 'backend/modules/stickers.dart';
import 'backend/modules/self_check.dart';
import 'backend/modules/shared_content.dart';
import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart';
import 'core/calls/call_bridge.dart';
@@ -73,6 +74,7 @@ import 'frontend/widgets/theme_reveal.dart';
final api = Api();
final accountModule = AccountModule(api);
final messagesModule = MessagesModule(api);
final sharedContentModule = SharedContentModule(api);
final pollsModule = PollsModule(api);
final stickersModule = StickersModule(api);
final webAppModule = WebAppModule(api);