import 'dart:async'; import 'package:flutter/services.dart'; import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:qlyra/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../contacts/edit_contact_sheet.dart'; import '../../../backend/modules/complaints.dart'; import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; import '../../../core/calls/call_controller.dart'; import '../../../core/config/app_show_extra_info.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_members_store.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/route_settle.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; import '../../../models/contact_info.dart'; import '../../../models/story.dart'; import '../../widgets/animated_slash_icon.dart'; import '../../widgets/animated_text_swap.dart'; import '../../widgets/avatar_history_screen.dart'; import '../../widgets/chat_info/shared_content_tabs.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/formatted_message_text.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/qlyra_avatar.dart'; import '../../widgets/profile_header_scroll.dart'; import '../../widgets/profile_hero.dart'; import '../../widgets/swipe_route.dart'; import '../../../backend/modules/chats.dart'; import '../calls/call_screen.dart'; import '../contacts/open_contact_profile.dart'; import '../stories/story_owner_info.dart'; import '../stories/story_peanut.dart'; import '../stories/story_ring.dart'; import '../stories/story_viewer_screen.dart'; import 'chat_screen.dart'; import 'group_invite_sheets.dart'; import 'profile_action_sheets.dart'; import '../../../core/config/app_fonts.dart'; part 'chat/chat_info_support.dart'; enum ChatInfoTab { media } class ChatInfoScreen extends StatefulWidget { final int chatId; final String name; final String imageUrl; final String chatType; final int? dialogPeerId; final ChatInfoTab? initialTab; final Object? heroTag; final bool openedFromChat; final void Function(String messageId, int time)? onJumpToMessage; const ChatInfoScreen({ super.key, required this.chatId, required this.name, required this.imageUrl, required this.chatType, this.dialogPeerId, this.initialTab, this.heroTag, this.openedFromChat = false, this.onJumpToMessage, }); @override State createState() => _ChatInfoScreenState(); } class _ChatInfoScreenState extends State with ReloadOnReconnect { final _tabScrollController = ScrollController(); ScrollController? _bodyScrollController; int _myId = 0; bool _isLoading = true; bool _extraContactExpanded = false; ChatInfo? _chatInfo; String _selectedTab = ''; bool _descExpanded = false; bool _showRealName = false; int? _otherId; ContactInfo? _contactData; CachedContact? _localContact; int? _seenTime; bool _isOnline = false; int _presenceStatus = 0; bool _isBot = false; final List<_MemberInfo> _members = []; final List<_MemberInfo> _owners = []; final List<_MemberInfo> _admins = []; final List<_MemberInfo> _contactMembers = []; final List<_MemberInfo> _otherMembers = []; final Set _seenMemberIds = {}; Set _contactIds = {}; int _memberMarker = 0; bool _membersLoading = false; bool _membersEnd = false; static const int _memberRenderChunk = 24; int _memberRenderLimit = _memberRenderChunk; bool _memberFillScheduled = false; int _mediaChatId = 0; String? _anchorMsgId; int _dontDisturbUntil = 0; int _lastEventTime = 0; bool _blocked = false; bool _muteBusy = false; bool _addContactBusy = false; StoryPreview? _storyPreview; List _unreadStories = const []; final GlobalKey _avatarKey = GlobalKey(); final PageController _avatarPageController = PageController(); List _avatarPages = const []; int _avatarIndex = 0; int _avatarTotal = 0; bool _avatarHover = false; bool _avatarHistoryBusy = false; bool _avatarHistoryLoaded = false; late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted); bool _rebuildQueued = false; double _headerDelta = 0; bool _expandArmed = false; bool _headerDragging = false; bool _headerEverExpanded = false; @override void initState() { super.initState(); storiesModule.storiesChanged.addListener(_onStoriesChanged); ChatMembersStore.instance .listenable(widget.chatId) .addListener(_onMemberCountChanged); _load(); } @override void didChangeDependencies() { super.didChangeDependencies(); _routeSettle.bind(context); } int? get _memberCount => ChatMembersStore.instance.count(widget.chatId); void _onMemberCountChanged() => _loadedRebuild(); void _onStoriesChanged() { if (!mounted) return; _loadedUpdate(_refreshUnreadStories); } void _loadedRebuild() { if (_routeSettle.settled) { if (mounted) setState(() {}); return; } if (_rebuildQueued) return; _rebuildQueued = true; _routeSettle.run(() { _rebuildQueued = false; if (mounted) setState(() {}); }); } void _loadedUpdate(VoidCallback mutation) { mutation(); _loadedRebuild(); } @override void dispose() { _routeSettle.dispose(); storiesModule.storiesChanged.removeListener(_onStoriesChanged); ChatMembersStore.instance .listenable(widget.chatId) .removeListener(_onMemberCountChanged); _tabScrollController.dispose(); _bodyScrollController?.dispose(); _avatarPageController.dispose(); super.dispose(); } AppLocalizations get l10n => AppLocalizations.of(context)!; List get _tabs { final showInfo = AppShowExtraInfo.current.value; switch (widget.chatType) { case 'DIALOG': if (_isBot) { return [ if (showInfo) 'Info', l10n.chatInfoTabMedia, l10n.chatInfoTabFiles, l10n.chatInfoTabVoice, l10n.chatInfoTabLinks, ]; } return [ l10n.chatInfoTabGeneralChats, l10n.chatInfoTabMedia, if (showInfo) 'Info', l10n.chatInfoTabFiles, l10n.chatInfoTabVoice, l10n.chatInfoTabLinks, ]; case 'CHAT': return [ l10n.chatInfoTabMembers, if (showInfo) 'Info', l10n.chatInfoTabMedia, l10n.chatInfoTabFiles, l10n.chatInfoTabVoice, l10n.chatInfoTabLinks, ]; case 'CHANNEL': return [ if (showInfo) 'Info', l10n.chatInfoTabMedia, l10n.chatInfoTabFiles, l10n.chatInfoTabVoice, l10n.chatInfoTabLinks, ]; default: return [if (showInfo) 'Info']; } } @override void reloadAfterReconnect() => _load(); Future _load() async { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; final info = await ChatInfoFetch.get(widget.chatId); if (!mounted) return; _chatInfo = info; _mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId; final cached = await chats.getChat(_myId, _mediaChatId); if (!mounted) return; if (cached.isNotEmpty) { _dontDisturbUntil = cached.first.dontDisturbUntil; _lastEventTime = cached.first.lastEventTime; } final serverEventTime = (info?.raw['lastEventTime'] as int?) ?? 0; if (serverEventTime > _lastEventTime) _lastEventTime = serverEventTime; 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) { for (final id in info.participantIds) { if (id != _myId) { _otherId = id; break; } } } if (_otherId != null) { _localContact = await ContactsModule.getContact(_myId, _otherId!); final contact = await ContactInfoFetch.get(_otherId!); if (contact != null) { _contactData = contact; _isBot = _contactData!.options.contains('BOT'); } final presence = await PresenceFetch.get(_otherId!); if (presence != null) { _seenTime = presence['seen'] as int?; final st = (presence['status'] as int?) ?? 0; _presenceStatus = st; _isOnline = st == 1; } if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!); if (!_isBot) unawaited(_loadStories(_otherId!)); unawaited(_loadAvatarHistory(_otherId!)); } } else if (info == null) { _loadedUpdate(() => _isLoading = false); return; } else if (widget.chatType == 'CHAT') { _contactIds = (await AppDatabase.loadContactIds(_myId)).toSet(); await _loadLeaders(); await _fetchMembersPage(initial: true); } if (mounted) { _loadedUpdate(() { _isLoading = false; if (_selectedTab.isEmpty && _tabs.isNotEmpty) { _selectedTab = _initialTabLabel() ?? _tabs.first; } }); } } Future _loadBlockedState(int peerId) async { final blocked = await ContactsModule.isBlocked(api, peerId); if (!mounted || blocked == _blocked) return; _loadedUpdate(() => _blocked = blocked); } String? _initialTabLabel() { if (widget.initialTab != ChatInfoTab.media) return null; final media = AppLocalizations.of(context)!.chatInfoTabMedia; return _tabs.contains(media) ? media : null; } _MemberInfo _memberFrom(ChatMemberEntry e) => _MemberInfo( id: e.id, name: e.name, avatarUrl: e.avatarUrl, isAdmin: _chatInfo?.isAdmin(e.id) ?? false, isOwner: _chatInfo?.isOwner(e.id) ?? false, isMe: e.id == _myId, alias: _chatInfo?.adminAlias(e.id), seenTime: e.seenTime, presenceStatus: e.presenceStatus, blocked: e.blocked, isContact: _contactIds.contains(e.id), ); Future _loadLeaders() async { final info = _chatInfo; if (info == null) return; final owner = info.owner; final leaderIds = [ if (owner != null && owner != 0) owner, for (final a in info.adminIds) if (a != owner) a, ]; if (leaderIds.isEmpty) return; final contacts = await ContactInfoFetch.getMany(leaderIds); final presence = await PresenceFetch.getMany(leaderIds); if (!mounted) return; for (final id in leaderIds) { if (!_seenMemberIds.add(id)) continue; final c = contacts[id]; final pres = presence[id]; _addMember( _MemberInfo( id: id, name: c?.displayName ?? ContactCache.get(id), avatarUrl: c?.avatarUrl ?? ContactCache.getAvatar(id), isAdmin: info.isAdmin(id), isOwner: info.isOwner(id), isMe: id == _myId, alias: info.adminAlias(id), seenTime: pres?['seen'] as int?, presenceStatus: (pres?['status'] as int?) ?? 0, blocked: c?.isDeleted ?? false, isContact: _contactIds.contains(id), ), ); } _rebuildMembers(); } int _memberRank(_MemberInfo m) { if (m.isOwner) return 0; if (m.isAdmin) return 1; if (m.isContact) return 2; return 3; } void _addMember(_MemberInfo m) { switch (_memberRank(m)) { case 0: _owners.add(m); case 1: _admins.add(m); case 2: _contactMembers.add(m); default: _otherMembers.add(m); } } void _rebuildMembers() { _members ..clear() ..addAll(_owners) ..addAll(_admins) ..addAll(_contactMembers) ..addAll(_otherMembers); } Future _fetchMembersPage({bool initial = false}) async { if (_membersLoading || _membersEnd) return; _membersLoading = true; if (!initial) _loadedRebuild(); final page = await chats.getChatMembers( api, widget.chatId, marker: _memberMarker, ); _membersLoading = false; if (!mounted) return; if (page == null) { if (!initial) _loadedRebuild(); return; } var added = 0; final fresh = []; for (final e in page.members) { if (_seenMemberIds.add(e.id)) { _addMember(_memberFrom(e)); fresh.add(e.id); added++; } } if (added > 0) { _rebuildMembers(); _scheduleMemberFillCheck(); } if (fresh.isNotEmpty && AppStories.current.value) { unawaited(storiesModule.loadOwnersPreviews(fresh)); } final total = _memberCount; if (page.members.isEmpty || added == 0 || page.marker == _memberMarker || (total != null && _members.length >= total)) { _membersEnd = true; } _memberMarker = page.marker; if (!initial) _loadedRebuild(); } bool _revealMoreMembers() { if (_memberRenderLimit >= _members.length) return false; setState(() => _memberRenderLimit += _memberRenderChunk); _scheduleMemberFillCheck(); return true; } void _scheduleMemberFillCheck() { if (_memberFillScheduled) return; _memberFillScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _memberFillScheduled = false; if (!mounted) return; if (_memberRenderLimit >= _members.length) return; final controller = _bodyScrollController; if (controller == null || !controller.hasClients) return; if (controller.position.maxScrollExtent > 0) return; _revealMoreMembers(); }); } void _onBodyScroll() { if (!mounted || widget.chatType != 'CHAT') return; if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) return; final controller = _bodyScrollController; if (controller == null || !controller.hasClients) return; final pos = controller.position; if (pos.pixels < pos.maxScrollExtent - 400) return; if (_revealMoreMembers()) return; if (_membersLoading || _membersEnd) return; _fetchMembersPage(); } String? get _inviteLink { final link = _chatInfo?.link; return (link != null && link.isNotEmpty) ? link : null; } Future _openAddMembers() async { final exclude = {_myId, ..._members.map((m) => m.id)}; final added = await showAddMembersSheet( context, chatId: widget.chatId, excludeIds: exclude, ); if (added == true && mounted) await _refreshMembers(); } void _openInviteLink(String link) { showInviteLinkSheet( context, link: link, title: widget.name, avatarUrl: widget.imageUrl, ); } Future _refreshMembers() async { final info = await ChatInfoFetch.get(widget.chatId, forceRefresh: true); if (!mounted) return; if (info != null) _chatInfo = info; _contactMembers.clear(); _otherMembers.clear(); _seenMemberIds ..clear() ..addAll(_owners.map((m) => m.id)) ..addAll(_admins.map((m) => m.id)); _memberMarker = 0; _membersEnd = false; _membersLoading = false; _memberRenderLimit = _memberRenderChunk; _rebuildMembers(); _loadedRebuild(); await _fetchMembersPage(initial: true); _loadedRebuild(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, floatingActionButton: const ConnectionSpinner(), body: _buildScrollBody(cs), ); } static const double _headerAvatarSize = 96; static const double _headerCollapsedBody = 232; static const double _headerVignette = 64; bool get _headerHasPhoto => widget.imageUrl.isNotEmpty && !_peerDeleted; Widget _buildScrollBody(ColorScheme cs) { return LayoutBuilder( builder: (context, viewport) { final media = MediaQuery.of(context); final topPad = media.padding.top; final collapsedH = topPad + _headerCollapsedBody; final expandedH = _headerHasPhoto ? math.max( collapsedH, math.min(media.size.width, viewport.maxHeight * 0.62), ) : collapsedH; final delta = expandedH - collapsedH; _syncHeaderDelta(delta); final controller = _bodyScrollController ??= (ScrollController( initialScrollOffset: delta, )..addListener(_onBodyScroll)); return NotificationListener( onNotification: (n) => _onHeaderScrollNotification(n, delta), child: CustomScrollView( key: ValueKey(delta), controller: controller, physics: HeaderPullScrollPhysics( delta: delta, isArmed: () => _expandArmed, parent: const BouncingScrollPhysics(), ), slivers: [ SliverPersistentHeader( delegate: MorphHeaderDelegate( collapsedExtent: collapsedH, expandedExtent: expandedH, headerBuilder: (ctx, t) => _buildMorphHeader(ctx, cs, _headerHasPhoto ? t : 0.0), ), ), SliverToBoxAdapter( child: ConstrainedBox( constraints: BoxConstraints( minHeight: math.max(0, viewport.maxHeight - collapsedH), ), child: _buildBody(cs), ), ), ], ), ); }, ); } void _syncHeaderDelta(double delta) { if (_headerDelta == delta) return; final prev = _headerDelta; _headerDelta = delta; if (_bodyScrollController == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { final c = _bodyScrollController; if (!mounted || c == null || !c.hasClients) return; final target = (c.offset + (delta - prev)).clamp( 0.0, c.position.maxScrollExtent, ); c.jumpTo(target); }); } bool _onHeaderScrollNotification(ScrollNotification n, double delta) { if (n.depth != 0) return false; if (n is ScrollStartNotification) { _headerDragging = n.dragDetails != null; if (n.dragDetails != null) { _expandArmed = delta > 0 && n.metrics.pixels <= delta + 8; } } else if (n is ScrollEndNotification) { if (_headerDragging) { _headerDragging = false; _snapHeader(delta); } } return false; } void _snapHeader(double delta) { final c = _bodyScrollController; if (c == null || !c.hasClients || delta <= 0) return; final collapsed = math.min(delta, c.position.maxScrollExtent); final offset = c.offset; if (offset <= 0 || offset >= collapsed) return; final target = offset < collapsed / 2 ? 0.0 : collapsed; if ((target - offset).abs() < 1) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !c.hasClients) return; c.animateTo( target, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ); }); } Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) { final topPad = MediaQuery.paddingOf(context).top; if (t > 0) { _headerEverExpanded = true; final peerId = _otherId; if (!_avatarHistoryLoaded && peerId != null) { unawaited(_loadAvatarHistory(peerId)); } } final iconColor = Color.lerp(cs.onSurface, Colors.white, t)!; final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!; final subColor = Color.lerp( cs.onSurfaceVariant, Colors.white.withValues(alpha: 0.85), t, )!; final ringOpacity = (1 - t * 3).clamp(0.0, 1.0); final chipOpacity = ((t - 0.3) / 0.5).clamp(0.0, 1.0); final unread = _unreadStories; final totalPhotos = math.max(_avatarTotal, _avatarPages.length); return ClipRect( child: LayoutBuilder( builder: (context, constraints) { final w = constraints.maxWidth; final h = constraints.maxHeight; const size = _headerAvatarSize; final avatarRect = Rect.lerp( Rect.fromLTWH((w - size) / 2, topPad + 52, size, size), Rect.fromLTWH(0, 0, w, h), t, )!; final radius = lerpDouble(size / 2, 0, t)!; return Stack( clipBehavior: Clip.hardEdge, children: [ Positioned.fromRect( rect: avatarRect, child: _headerAvatar(cs, radius, t), ), if (_headerHasPhoto) ...[ Positioned( left: 0, right: 0, top: 0, height: topPad + 72, child: IgnorePointer( child: Opacity( opacity: t, child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black45, Colors.transparent], ), ), ), ), ), ), Positioned( left: 0, right: 0, bottom: 0, height: 170, child: IgnorePointer( child: Opacity( opacity: t, child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black54], stops: [0.0, 0.62], ), ), ), ), ), ), Positioned( left: 0, right: 0, bottom: 0, height: _headerVignette, child: IgnorePointer( child: Opacity( opacity: t, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ cs.surface.withValues(alpha: 0), cs.surface.withValues(alpha: 0.55), cs.surface, ], stops: const [0.0, 0.55, 1.0], ), ), ), ), ), ), ], Positioned.fromRect( rect: avatarRect.inflate(7 * (1 - t)), child: IgnorePointer( child: Opacity( opacity: ringOpacity, child: CustomPaint( painter: _storyPreview == null ? null : SegmentedRingPainter( total: _storyPreview!.totalCount, read: _storyPreview!.readCount, unreadColors: [ cs.primary, cs.tertiary, cs.primary, ], readColor: cs.outlineVariant, strokeWidth: 3.4, ), ), ), ), ), Positioned( left: 4, right: 4, top: topPad + 4, child: Row( children: [ IconButton( icon: Icon(Symbols.arrow_back, color: iconColor), onPressed: () => Navigator.pop(context), ), Expanded( child: chipOpacity > 0 && unread.isNotEmpty ? Align( alignment: Alignment.centerLeft, child: Opacity( opacity: chipOpacity, child: _storyChip(unread), ), ) : const SizedBox.shrink(), ), if (chipOpacity > 0 && totalPhotos > 1) Opacity( opacity: chipOpacity, child: Padding( padding: const EdgeInsets.only(right: 4), child: Text( '${_avatarIndex + 1}/$totalPhotos', style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, ), ), ), ), _buildMoreButton(cs, iconColor), ], ), ), Positioned( left: 0, right: 0, bottom: lerpDouble(16, 18, t)!, child: Column( mainAxisSize: MainAxisSize.min, children: [ _headerAligned(t, _buildNameRow(cs, nameColor, t)), const SizedBox(height: 2), _headerAligned( t, SelectionArea( child: Text( _subtitle(), style: TextStyle(color: subColor, fontSize: 14), ), ), ), ], ), ), ], ); }, ), ); } Widget _headerAligned(double t, Widget child) { return Align( alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!, child: Padding( padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!), child: child, ), ); } Widget _headerAvatar(ColorScheme cs, double radius, double t) { final expanded = t > 0.5; final openHistory = _headerHasPhoto ? () => AvatarHistoryScreen.open( context, contactId: _otherId ?? widget.dialogPeerId ?? 0, name: widget.name, currentAvatarUrl: _avatarPages.isEmpty ? widget.imageUrl : _avatarPages[_avatarIndex.clamp(0, _avatarPages.length - 1)], ) : null; final openStories = _storyPreview == null ? null : _openStories; return KeyedSubtree( key: _avatarKey, child: GestureDetector( onTap: expanded ? openHistory : (openStories ?? openHistory), onLongPress: expanded ? null : (openStories == null ? null : openHistory), child: Stack( fit: StackFit.expand, children: [ ProfileHeroAvatar( tag: widget.heroTag, size: _headerAvatarSize, child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: _headerAvatarContent(cs), ), ), Offstage( offstage: t < 0.5 || _avatarPages.length < 2, child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: _avatarPager(cs, t), ), ), ], ), ), ); } Widget _avatarPager(ColorScheme cs, double t) { final pages = _avatarPages; if (pages.length < 2) return const SizedBox.shrink(); final interactive = t > 0.5; return MouseRegion( onEnter: (_) { if (!_avatarHover) setState(() => _avatarHover = true); }, onExit: (_) { if (_avatarHover) setState(() => _avatarHover = false); }, child: Stack( fit: StackFit.expand, children: [ ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( dragDevices: PointerDeviceKind.values.toSet(), scrollbars: false, overscroll: false, ), child: PageView.builder( controller: _avatarPageController, itemCount: pages.length, physics: interactive ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), onPageChanged: (i) => setState(() => _avatarIndex = i), itemBuilder: (_, i) => _avatarPhoto(cs, pages[i]), ), ), if (interactive && _avatarHover) ...[ _avatarArrow( alignment: Alignment.centerLeft, icon: Symbols.chevron_left, enabled: _avatarIndex > 0, onTap: () => _stepAvatar(-1), ), _avatarArrow( alignment: Alignment.centerRight, icon: Symbols.chevron_right, enabled: _avatarIndex < pages.length - 1, onTap: () => _stepAvatar(1), ), ], ], ), ); } Widget _avatarArrow({ required Alignment alignment, required IconData icon, required bool enabled, required VoidCallback onTap, }) { return Align( alignment: alignment, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: enabled ? 1 : 0, child: IgnorePointer( ignoring: !enabled, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( width: 36, height: 36, decoration: const BoxDecoration( color: Colors.black38, shape: BoxShape.circle, ), child: Icon(icon, color: Colors.white, size: 24), ), ), ), ), ), ); } void _stepAvatar(int delta) { final target = (_avatarIndex + delta).clamp(0, _avatarPages.length - 1); if (target == _avatarIndex) return; _avatarPageController.animateToPage( target, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ); } Widget _avatarPhoto(ColorScheme cs, String url) { return CachedNetworkImage( imageUrl: url, fit: BoxFit.cover, memCacheWidth: _headerEverExpanded ? 720 : 288, fadeInDuration: const Duration(milliseconds: 150), errorWidget: (_, _, _) => ColoredBox( color: cs.surfaceContainerHigh, child: Center( child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32), ), ), ), ); } Widget _headerAvatarContent(ColorScheme cs) { if (_peerDeleted) { return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52); } final url = _avatarPages.isNotEmpty ? _avatarPages.first : widget.imageUrl; if (url.isEmpty) { return QlyraAvatar( name: widget.name, size: _headerAvatarSize, fontSize: 36, fadeIn: false, ); } return _avatarPhoto(cs, url); } void _refreshUnreadStories() { final preview = _storyPreview; if (preview == null || preview.unreadCount <= 0) { _unreadStories = const []; return; } final stories = storiesModule.cachedStories(preview.owner.ownerId); if (stories == null || stories.isEmpty) { _unreadStories = const []; return; } final from = (stories.length - preview.unreadCount).clamp( 0, stories.length, ); _unreadStories = stories.sublist(from); } Widget _storyChip(List unread) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _openStories, child: Row( mainAxisSize: MainAxisSize.min, children: [ StoryPeanut(stories: unread), const SizedBox(width: 8), Flexible( child: Text( '${unread.length} ' '${pluralRu(unread.length, 'история', 'истории', 'историй')}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600, fontFamily: displayFontOf(context), ), ), ), ], ), ); } Widget _buildBody(ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ if (_isLoading) ..._loadingBlocks(cs) else ...[ _buildActions(cs), const SizedBox(height: 16), _buildPersistentInfo(cs), _buildTabBar(cs), const SizedBox(height: 12), _buildTabContent(cs), SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom), ], ], ), ); } ContactName? _nameEntry(String type) { final data = _contactData; if (data == null) return null; for (final n in data.names) { if (n.type == type) return n; } return null; } bool get _isContact => _localContact != null; bool get _peerDeleted => _contactData?.isDeleted ?? false; String _joinName(String first, String last) => last.trim().isEmpty ? first.trim() : '${first.trim()} ${last.trim()}'; String get _customName { final c = _localContact; if (c != null) return _joinName(c.firstName, c.lastName ?? ''); return widget.name; } Widget _buildMoreButton(ColorScheme cs, [Color? iconColor]) { final entries = _moreMenuEntries(); final color = iconColor ?? cs.onSurface; if (entries.isEmpty) { return IconButton( icon: Icon(Symbols.more_vert, color: color), onPressed: null, ); } return PopupMenuButton( icon: Icon(Symbols.more_vert, color: color), onSelected: (action) => action(), itemBuilder: (_) => [ for (final entry in entries) PopupMenuItem( value: entry.onTap, child: Row( children: [ Icon( entry.icon, size: 20, color: entry.destructive ? cs.error : cs.onSurface, ), const SizedBox(width: 12), Text( entry.label, style: entry.destructive ? TextStyle(color: cs.error) : null, ), ], ), ), ], ); } List<({IconData icon, String label, bool destructive, VoidCallback onTap})> _moreMenuEntries() { if (_isLoading) return const []; final entries = < ({IconData icon, String label, bool destructive, VoidCallback onTap}) >[]; if (widget.chatType == 'DIALOG') { if (_isContact) { entries.add(( icon: Symbols.edit, label: l10n.editContactMenu, destructive: false, onTap: _openEdit, )); } if (!_isBot && _otherId != null && _otherId != _myId) { entries.add(( icon: _blocked ? Symbols.lock_open : Symbols.block, label: _blocked ? l10n.chatInfoMenuUnblock : l10n.chatInfoMenuBlock, destructive: !_blocked, onTap: _toggleBlock, )); } entries.add(( icon: Symbols.delete, label: l10n.chatInfoMenuDeleteChat, destructive: true, onTap: _deleteChat, )); } entries.add(( icon: Symbols.mop, label: l10n.chatInfoMenuClearHistory, destructive: true, onTap: _clearHistory, )); return entries; } Future _openEdit() async { final oneme = _nameEntry('ONEME'); final local = _localContact; final peerId = _otherId ?? widget.dialogPeerId ?? 0; if (peerId == 0) return; final result = await showEditContactSheet( context, contactId: peerId, avatarUrl: _contactData?.avatarUrl ?? local?.baseUrl ?? widget.imageUrl, customFirst: local?.firstName ?? '', customLast: local?.lastName ?? '', onemeFirst: oneme?.firstName ?? '', onemeLast: oneme?.lastName ?? '', ); if (!mounted || result == null) return; switch (result.action) { case EditContactAction.updated: final fresh = await ContactsModule.getContact(_myId, peerId); if (!mounted) return; setState(() { _localContact = fresh; _showRealName = false; }); case EditContactAction.removed: Navigator.of(context).pop(); } } String? get _realName { final data = _contactData; if (data == null) return null; for (final n in data.names) { if (n.type == 'ONEME') { final combined = [n.firstName, n.lastName] .where((s) => s != null && s.trim().isNotEmpty) .map((s) => s!.trim()) .join(' '); if (combined.isNotEmpty) return combined; final label = n.label; if (label != null && label.isNotEmpty) return label; } } return null; } Widget _buildNameRow(ColorScheme cs, Color textColor, double t) { final nameStyle = TextStyle( color: textColor, fontSize: lerpDouble(22, 25, t)!, fontWeight: FontWeight.w700, fontFamily: displayFontOf(context), ); final custom = _customName; final real = _realName; final hasToggle = _isContact && real != null && real != custom; return Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox(width: (hasToggle ? 30.0 : 0.0) * (1 - t)), Flexible( child: SelectionArea( child: ProfileHeroName( tag: widget.heroTag, text: custom, style: nameStyle, child: AnimatedTextSwap( showAlternate: _showRealName, alignment: Alignment.center, alternate: Text( real ?? custom, style: nameStyle, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), child: Text( custom, style: nameStyle, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ), ), SizedBox( width: hasToggle ? 30 : 0, height: 28, child: hasToggle ? IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), iconSize: 20, color: _showRealName ? Color.lerp(cs.primary, Colors.white, t) : textColor.withValues(alpha: 0.7), icon: AnimatedSlashIcon( icon: Symbols.visibility, slashedIcon: Symbols.visibility_off, slashed: !_showRealName, ), tooltip: real, onPressed: () => setState(() => _showRealName = !_showRealName), ) : null, ), ], ); } String _subtitle() { switch (widget.chatType) { case 'DIALOG': if (_peerDeleted) return l10n.chatInfoMemberDeleted; if (_isBot) return l10n.contactProfileBot; if (_isOnline) return l10n.contactProfileOnline; if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive; if (_seenTime != null && _seenTime! > 0) { return formatLastSeen(_seenTime!); } return ''; case 'CHAT': final total = _memberCount ?? _members.length; return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}'; case 'CHANNEL': final count = _memberCount ?? 0; return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}'; default: return ''; } } bool get _isMuted { if (_dontDisturbUntil == ChatsModule.muteOff) return false; if (_dontDisturbUntil < 0) return true; return _dontDisturbUntil > DateTime.now().millisecondsSinceEpoch; } bool get _iAmAdmin { final info = _chatInfo; if (info == null || _myId == 0) return false; return info.isOwner(_myId) || info.isAdmin(_myId); } bool get _isGroupOrChannel => widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL'; Widget _buildActions(ColorScheme cs) { final muteBtn = ( icon: Symbols.notifications, slashedIcon: Symbols.notifications_off, slashed: _isMuted, label: _isMuted ? l10n.chatInfoActionMuted : l10n.contactProfileActionSound, onTap: _muteBusy ? null : _toggleMute, ); final chatBtn = ( icon: Symbols.chat_bubble, slashedIcon: null, slashed: false, label: l10n.contactProfileActionChat, onTap: _openChat, ); final leaveBtn = ( icon: Symbols.exit_to_app, slashedIcon: null, slashed: false, label: l10n.chatInfoActionLeave, onTap: _leaveChat, ); final List< ({ IconData icon, IconData? slashedIcon, bool slashed, String label, VoidCallback? onTap, }) > btns; if (widget.chatType == 'DIALOG') { btns = [ chatBtn, muteBtn, if (!_isBot) ( icon: Symbols.call, slashedIcon: null, slashed: false, label: l10n.contactProfileActionCall, onTap: _confirmAndStartCall, ), ]; } else if (widget.chatType == 'CHANNEL') { btns = [muteBtn, leaveBtn]; } else { btns = [chatBtn, muteBtn, leaveBtn]; } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ for (int i = 0; i < btns.length; i++) ...[ _actionBtn( cs, btns[i].icon, btns[i].label, onTap: btns[i].onTap, slashedIcon: btns[i].slashedIcon, slashed: btns[i].slashed, ), if (i < btns.length - 1) const SizedBox(width: 8), ], ], ), if (_canAddContact) ...[ const SizedBox(height: 8), _wideActionBtn( cs, Symbols.person_add, l10n.contactProfileActionAddContact, _addContactBusy ? null : _addToContacts, ), ], ], ); } bool get _canAddContact => widget.chatType == 'DIALOG' && !_isContact && !_isBot && !_peerDeleted && _otherId != null && _otherId != _myId; Future _addToContacts() async { final peerId = _otherId; if (peerId == null || _addContactBusy) return; setState(() => _addContactBusy = true); CachedContact? contact; try { contact = await ContactsModule.addContact(api, peerId, ''); } catch (_) {} if (!mounted) return; setState(() { _addContactBusy = false; if (contact != null) { _localContact = contact; _showRealName = false; } }); showCustomNotification( context, contact != null ? l10n.nfcContactAdded : l10n.addContactError, ); } void _openChat() { if (widget.openedFromChat) { Navigator.of(context).pop(); return; } pushSwipeable( context, (_) => ChatScreen( chatId: _mediaChatId, name: widget.name, imageUrl: widget.imageUrl, chatType: widget.chatType, ), ); } Future _toggleMute() async { if (_muteBusy) return; setState(() => _muteBusy = true); final muted = _isMuted; final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever; final error = await chats.setChatMute( api, chatId: _mediaChatId, dontDisturbUntil: target, ); if (!mounted) return; setState(() { _muteBusy = false; if (error == null) _dontDisturbUntil = target; }); showCustomNotification( context, error ?? (muted ? l10n.chatInfoNotificationsOn : l10n.chatInfoNotificationsOff), ); } Future _confirmAndStartCall() async { final peerId = _otherId; if (peerId == null || peerId == _myId) return; final choice = await showBlurredConfirm( context, title: l10n.chatInfoCallConfirmTitle, message: l10n.chatInfoCallConfirmMessage(_customName), confirmLabel: l10n.chatInfoConfirmYes, cancelLabel: l10n.chatInfoConfirmNo, ); if (!mounted || !choice.confirmed) return; final navigator = Navigator.of(context); final avatarUrl = widget.imageUrl.isNotEmpty ? widget.imageUrl : null; final active = CallController.instance.activeSession; if (active != null) { await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: _customName, avatarUrl: avatarUrl, session: active, ), ), ); return; } try { final session = await CallController.instance.startOutgoing(peerId); if (!mounted) return; await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: _customName, avatarUrl: avatarUrl, session: session, ), ), ); } catch (_) { if (!mounted) return; showCustomNotification(context, l10n.chatInfoCallFailed); } } Future _leaveChat() async { final isChannel = widget.chatType == 'CHANNEL'; final choice = await showBlurredConfirm( context, title: isChannel ? l10n.chatInfoLeaveChannelTitle : l10n.chatInfoLeaveGroupTitle, message: isChannel ? l10n.chatInfoLeaveChannelMessage : l10n.chatInfoLeaveGroupMessage, confirmLabel: l10n.chatInfoLeaveConfirm, cancelLabel: l10n.chatInfoActionCancel, destructive: true, ); if (!mounted || !choice.confirmed) return; final ok = await chats.leaveChat(api, chatId: _mediaChatId); if (!mounted) return; if (!ok) { showCustomNotification(context, l10n.chatInfoLeaveFailed); return; } Navigator.of(context).popUntil((route) => route.isFirst); } Future _clearHistory() async { final canClearForAll = _isGroupOrChannel && _iAmAdmin; final choice = await showBlurredConfirm( context, title: l10n.chatInfoClearHistoryTitle, message: l10n.chatInfoClearHistoryMessage, confirmLabel: l10n.chatInfoClearHistoryConfirm, cancelLabel: l10n.chatInfoActionCancel, destructive: true, checkboxLabel: canClearForAll ? l10n.chatInfoClearHistoryForAll : null, ); if (!mounted || !choice.confirmed) return; final error = await chats.clearHistory( api, chatId: _mediaChatId, lastEventTime: _lastEventTime, forAll: canClearForAll && choice.checked, ); if (!mounted) return; showCustomNotification(context, error ?? l10n.chatInfoClearHistoryDone); } Future _deleteChat() async { final choice = await showBlurredConfirm( context, title: l10n.chatInfoDeleteChatTitle, message: l10n.chatInfoDeleteChatMessage, confirmLabel: l10n.chatInfoDeleteChatConfirm, cancelLabel: l10n.chatInfoActionCancel, destructive: true, ); if (!mounted || !choice.confirmed) return; final error = await chats.deleteChat( api, chatId: _mediaChatId, lastEventTime: _lastEventTime, forAll: false, ); if (!mounted) return; if (error != null) { showCustomNotification(context, error); return; } Navigator.of(context).popUntil((route) => route.isFirst); } Future _toggleBlock() async { final peerId = _otherId; if (peerId == null) return; final block = !_blocked; if (block) { final choice = await showBlurredConfirm( context, title: l10n.chatInfoBlockConfirmTitle, message: l10n.chatInfoBlockConfirmMessage(_customName), confirmLabel: l10n.chatInfoConfirmYes, cancelLabel: l10n.chatInfoConfirmNo, destructive: true, ); if (!mounted || !choice.confirmed) return; } final ok = await ContactsModule.setBlocked(api, peerId, block); if (!mounted) return; if (!ok) { showCustomNotification(context, l10n.chatInfoBlockFailed); return; } setState(() => _blocked = block); showCustomNotification( context, block ? l10n.chatInfoBlockDone : l10n.chatInfoUnblockDone, ); if (block) await _openComplaintCard(peerId); } Future _openComplaintCard(int peerId) async { if (!mounted) return; await showComplaintCard( context, title: l10n.chatInfoComplaintTitle, subtitle: l10n.chatInfoComplaintSubtitle, sendLabel: l10n.chatInfoComplaintSend, closeLabel: l10n.chatInfoComplaintClose, emptyLabel: l10n.chatInfoComplaintEmpty, loadReasons: () async { final reasons = await ComplaintsModule.reasonsFor( api, ComplaintsModule.userTypeId, ); return reasons .map((r) => (id: r.reasonId, title: r.reasonTitle)) .toList(); }, onSend: (reasonId) async { final ok = await ComplaintsModule.sendComplaint( api, reasonId: reasonId, typeId: ComplaintsModule.userTypeId, ids: [peerId], ); if (!mounted) return ok; showCustomNotification( context, ok ? l10n.chatInfoComplaintSent : l10n.chatInfoComplaintFailed, ); return ok; }, ); } Widget _actionBtn( ColorScheme cs, IconData icon, String label, { VoidCallback? onTap, IconData? slashedIcon, bool slashed = false, }) { return Expanded( child: GlossyPill( onTap: onTap, color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.symmetric(vertical: 10), depth: 6, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (slashedIcon != null) AnimatedSlashIcon( icon: icon, slashedIcon: slashedIcon, slashed: slashed, color: cs.primary, size: 22, ) else Icon(icon, color: cs.primary, size: 22), const SizedBox(height: 4), Text( label, style: TextStyle(color: cs.onSurface, fontSize: 11), textAlign: TextAlign.center, ), ], ), ), ); } Widget _wideActionBtn( ColorScheme cs, IconData icon, String label, [ VoidCallback? onTap, ]) { return GlossyPill( onTap: onTap, color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), depth: 6, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: cs.primary, size: 22), const SizedBox(width: 8), Flexible( child: Text( label, style: TextStyle(color: cs.onSurface, fontSize: 13), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ); } Widget _buildPersistentInfo(ColorScheme cs) { final items = []; if (widget.chatType == 'DIALOG') { if (_isBot) { final link = _contactData?.raw['link'] as String?; if (link != null && link.isNotEmpty) { items.add( _simpleInfoCard( cs, l10n.contactProfileInfoLink, link, isLink: true, ), ); } } else { final phone = _contactData?.raw['phone']; final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { items.add( _simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!), ); } final bio = (_contactData?.raw['description'] as String?) ?? (_contactData?.raw['about'] as String?); if (bio != null && bio.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); } } } else { final info = _chatInfo; final link = info?.link; if (link != null && link.isNotEmpty && (info?.canSeeInviteLink(_myId) ?? false)) { items.add(_linkCard(cs, link)); } final desc = _chatInfo?.description; if (desc != null && desc.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); items.add(_collapsibleDescCard(cs, desc)); } } if (items.isEmpty) return const SizedBox.shrink(); return SelectionArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [...items, const SizedBox(height: 16)], ), ); } Widget _simpleInfoCard( ColorScheme cs, String label, String value, { bool isLink = false, }) { return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), depth: 6, child: SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), FormattedMessageText( text: value, ranges: const [], entityMode: TextEntityMode.copy, style: TextStyle( color: isLink ? cs.primary : cs.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), ), ], ), ), ); } Widget _linkCard(ColorScheme cs, String link) { return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.fromLTRB(16, 12, 8, 14), depth: 6, child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.chatInfoInviteLink, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), FormattedMessageText( text: link, ranges: const [], entityMode: TextEntityMode.copy, style: TextStyle(color: cs.primary, fontSize: 15), ), ], ), ), IconButton( icon: Icon(Symbols.qr_code_2, color: cs.primary, size: 22), onPressed: () {}, ), ], ), ); } static const Duration _descRevealDuration = Duration(milliseconds: 260); static const Curve _descRevealCurve = Curves.easeOutCubic; Widget _collapsibleDescCard(ColorScheme cs, String desc) { const int collapsedLines = 3; final isLong = desc.length > 120; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.all(16), depth: 6, child: SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.contactProfileInfoDescription, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), AnimatedSize( duration: _descRevealDuration, curve: _descRevealCurve, alignment: Alignment.topLeft, child: FormattedMessageText( text: desc, ranges: const [], entityMode: TextEntityMode.copy, style: TextStyle( color: cs.onSurface, fontSize: 15, height: 1.4, ), maxLines: (_descExpanded || !isLong) ? null : collapsedLines, overflow: (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, ), ), if (isLong) ...[ const SizedBox(height: 6), GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { Haptics.tap(); setState(() => _descExpanded = !_descExpanded); }, child: AnimatedTextSwap( showAlternate: _descExpanded, duration: _descRevealDuration, curve: _descRevealCurve, alternate: Text( l10n.chatInfoCollapse, style: TextStyle(color: cs.primary, fontSize: 13), ), child: Text( l10n.chatInfoShowMore, style: TextStyle(color: cs.primary, fontSize: 13), ), ), ), ], ], ), ), ); } Widget _buildTabBar(ColorScheme cs) { return LayoutBuilder( builder: (context, constraints) => ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( dragDevices: { PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, }, ), child: Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { final delta = event.scrollDelta.dy != 0 ? event.scrollDelta.dy : event.scrollDelta.dx; _tabScrollController.animateTo( (_tabScrollController.offset + delta).clamp( _tabScrollController.position.minScrollExtent, _tabScrollController.position.maxScrollExtent, ), duration: const Duration(milliseconds: 80), curve: Curves.easeOut, ); } }, child: SingleChildScrollView( controller: _tabScrollController, scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ for (int i = 0; i < _tabs.length; i++) ...[ _tabChip(cs, _tabs[i]), if (i < _tabs.length - 1) const SizedBox(width: 8), ], ], ), ), ), ), ), ); } Widget _tabChip(ColorScheme cs, String tab) { final selected = tab == _selectedTab; return GestureDetector( onTap: () => setState(() => _selectedTab = tab), child: AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: selected ? cs.primaryContainer : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), ), child: Text( tab, style: TextStyle( color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, fontSize: 14, ), ), ), ); } Widget _buildTabContent(ColorScheme cs) { if (_selectedTab.isEmpty) return const SizedBox.shrink(); return AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: KeyedSubtree(key: ValueKey(_selectedTab), child: _tabBody(cs)), ); } Widget _tabBody(ColorScheme cs) { if (_selectedTab == 'Info') return _buildInfoTabContent(cs); if (_selectedTab == l10n.chatInfoTabMembers) { return _buildMembersTabContent(cs); } if (_selectedTab == l10n.chatInfoTabGeneralChats) { final peerId = _otherId; if (peerId == null) { return _buildPlaceholder( cs, l10n.chatInfoEmptyGeneralChats, Symbols.group, ); } return CommonChatsTab( key: const ValueKey('tab-common-chats'), userId: peerId, emptyLabel: l10n.chatInfoEmptyGeneralChats, ); } if (_selectedTab == l10n.chatInfoTabMedia) { return _sharedTab( cs, SharedContentKind.media, l10n.chatInfoEmptyMedia, Symbols.photo_library, ); } if (_selectedTab == l10n.chatInfoTabFiles) { return _sharedTab( cs, SharedContentKind.files, l10n.chatInfoEmptyFiles, Symbols.description, ); } if (_selectedTab == l10n.chatInfoTabVoice) { return _sharedTab( cs, SharedContentKind.voice, l10n.chatInfoEmptyVoice, Symbols.mic, ); } if (_selectedTab == l10n.chatInfoTabLinks) { return _sharedTab( cs, SharedContentKind.links, l10n.chatInfoEmptyLinks, Symbols.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, sourceName: widget.name, kind: kind, emptyLabel: emptyLabel, emptyIcon: emptyIcon, onGoToMessage: _goToMessage, scrollController: _bodyScrollController, ); } 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), 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 _buildInfoTabContent(ColorScheme cs) { final items = []; items.add(_buildInfoRowsCard(cs)); return SelectionArea( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: items, ), ); } Widget _buildInfoRowsCard(ColorScheme cs) { return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), depth: 6, child: _buildAllInfoRows(cs), ); } Widget _buildMembersTabContent(ColorScheme cs) { final hasHidden = _members.length > _memberRenderLimit; final shown = hasHidden ? _members.take(_memberRenderLimit) : _members; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), ), child: Column( children: [ _memberAction( cs, Symbols.person_add, l10n.chatInfoAddMember, _openAddMembers, ), if (_inviteLink != null) ...[ _listDivider(cs), _memberAction( cs, Symbols.link, l10n.chatInfoInviteByLink, () => _openInviteLink(_inviteLink!), ), ], ...shown.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), if (hasHidden || _membersLoading || !_membersEnd) ...[ _listDivider(cs), _membersFooter(cs), ], ], ), ); } Widget _membersFooter(ColorScheme cs) { if (_membersLoading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } return InkWell( onTap: () { if (!_revealMoreMembers()) _fetchMembersPage(); }, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ Icon(Symbols.expand_more, color: cs.primary, size: 26), const SizedBox(width: 14), Text( l10n.chatInfoShowMore, style: TextStyle(color: cs.primary, fontSize: 16), ), ], ), ), ); } Widget _memberAction( ColorScheme cs, IconData icon, String label, VoidCallback onTap, ) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ Icon(icon, color: cs.primary, size: 26), const SizedBox(width: 14), Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)), ], ), ), ); } Widget _listDivider(ColorScheme cs) => Divider( height: 1, indent: 56, endIndent: 0, color: cs.outlineVariant.withValues(alpha: 0.3), ); Widget _memberTile(ColorScheme cs, _MemberInfo member) { final name = member.name ?? ContactCache.get(member.id) ?? (member.isMe ? l10n.callParticipantYou : '${member.id}'); final avatar = member.avatarUrl ?? ContactCache.getAvatar(member.id); final String sublabel; if (member.blocked) { sublabel = l10n.chatInfoMemberDeleted; } else if (member.isMe) { sublabel = l10n.callParticipantYou; } else if (member.presenceStatus == 1) { sublabel = l10n.contactProfileOnline; } else if (member.presenceStatus == 2 || member.presenceStatus == 3) { sublabel = l10n.contactProfileRecentlyActive; } else if (member.seenTime != null && member.seenTime! > 0) { sublabel = formatLastSeen(member.seenTime!); } else { sublabel = l10n.contactProfileRecentlyActive; } final String? roleLabel = member.isOwner ? l10n.chatInfoRoleOwner : (member.isAdmin ? l10n.chatInfoRoleAdmin : null); final story = member.blocked || !AppStories.current.value ? null : storiesModule.previewOf(member.id); final avatarRadius = story == null ? 22.0 : 19.0; return InkWell( onTap: member.isMe ? null : () => openContactDialogProfile( context, contactId: member.id, name: name, avatarUrl: avatar, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ if (member.blocked) _ghostAvatar() else _memberAvatar( cs, story: story, radius: avatarRadius, name: name, avatarUrl: avatar, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: TextStyle( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w500, ), ), Text( sublabel, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), if (member.alias != null) _memberTag(cs, member.alias!) else if (roleLabel != null) Text( roleLabel, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), ); } Widget _memberAvatar( ColorScheme cs, { required StoryPreview? story, required double radius, required String name, String? avatarUrl, }) { final circle = (avatarUrl != null && avatarUrl.isNotEmpty) ? CircleAvatar( radius: radius, backgroundImage: CachedNetworkImageProvider( avatarUrl, maxWidth: 144, maxHeight: 144, ), backgroundColor: cs.primaryContainer, ) : CircleAvatar( radius: radius, backgroundColor: cs.primaryContainer, child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( color: cs.onPrimaryContainer, fontSize: radius * 0.72, ), ), ); if (story == null) return circle; return Builder( builder: (avatarContext) => GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _openMemberStories(avatarContext, story, name, avatarUrl), child: StoryAvatarRing( diameter: radius * 2, total: story.totalCount, read: story.readCount, strokeWidth: 2.2, ringGap: 3, haloWidth: 1.5, child: circle, ), ), ); } void _openMemberStories( BuildContext avatarContext, StoryPreview story, String name, String? avatarUrl, ) { Haptics.tap(); unawaited( openStoryViewer( context, previews: [story], origin: storyOriginOf(avatarContext), ownerOverrides: { story.owner.ownerId: StoryOwnerInfo(name: name, avatarUrl: avatarUrl), }, ), ); } Widget _ghostAvatar({double radius = 22, double fontSize = 24}) { return CircleAvatar( radius: radius, backgroundColor: const Color(0xFFD4D4D4), child: Text('👻', style: TextStyle(fontSize: fontSize)), ); } Widget _memberTag(ColorScheme cs, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: cs.primaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text( label, style: TextStyle( color: cs.onPrimaryContainer, fontSize: 12, fontWeight: FontWeight.w600, ), ), ); } Widget _buildAllInfoRows(ColorScheme cs) { final rows = <({String label, String value})>[]; final chat = _chatInfo?.raw; if (chat == null) { return Text( l10n.chatInfoNoData, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ); } void add(String label, dynamic val, {bool tsFormat = false}) { if (val == null) return; if (val is bool && !val) return; String str; if (tsFormat && val is int && val > 1) { str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val)); } else if (val is bool) { str = l10n.callValueYes; } else { str = val.toString(); } if (str.isEmpty) return; rows.add((label: label, value: str)); } final type = widget.chatType; add(l10n.chatInfoRowId, chat['id']); if (type == 'DIALOG') { add(l10n.chatInfoRowUserId, _contactData?.raw['id'] ?? _otherId); add(l10n.chatInfoRowCreated, chat['created'], tsFormat: true); add(l10n.chatInfoRowModified, chat['modified'], tsFormat: true); add(l10n.callInfoStatus, chat['status']); } if (type == 'CHAT') { add(l10n.chatInfoRowMembersCount, chat['participantsCount']); final owner = chat['owner'] as int?; if (owner != null && owner != 0) { add(l10n.chatInfoRowOwner, ContactCache.get(owner) ?? '$owner'); } add(l10n.chatInfoRowCreatedGroup, chat['created'], tsFormat: true); add( l10n.chatInfoRowJoined, (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1 ? chat['joinTime'] : null, tsFormat: true, ); add(l10n.chatInfoRowModifiedGroup, chat['modified'], tsFormat: true); add(l10n.chatInfoRowHasBots, chat['hasBots'] as bool?); final blocked = chat['blockedParticipantsCount'] as int?; if (blocked != null && blocked > 0) { add(l10n.chatInfoRowBlockedCount, blocked); } final opts = chat['options'] as Map?; add(l10n.chatInfoRowOfficialGroup, opts?['OFFICIAL'] as bool?); add(l10n.chatInfoRowSignAdmin, opts?['SIGN_ADMIN'] as bool?); _addChatOptions(add, opts); add(l10n.callInfoStatus, chat['status']); } if (type == 'CHANNEL') { add(l10n.chatInfoRowSubscribersCount, chat['participantsCount']); add(l10n.chatInfoRowCreated, chat['created'], tsFormat: true); add(l10n.chatInfoRowModified, chat['modified'], tsFormat: true); final opts = chat['options'] as Map?; add(l10n.chatInfoRowOfficialChannel, opts?['OFFICIAL'] as bool?); add(l10n.chatInfoRowComments, opts?['COMMENTS'] as bool?); add(l10n.chatInfoRowRkn, opts?['A_PLUS_CHANNEL'] as bool?); add(l10n.chatInfoRowSignAdmin, opts?['SIGN_ADMIN'] as bool?); add( l10n.chatInfoRowOnlyAdmin, opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?, ); _addChatOptions(add, opts); add(l10n.callInfoStatus, chat['status']); } if (rows.isEmpty) { return Text( l10n.chatInfoNoData, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ); } final extraRows = _buildExtraContactRows(); return AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ for (int i = 0; i < rows.length; i++) ...[ _infoRow( cs, rows[i].label, rows[i].value, trailing: _trailingFor(rows[i].label, cs), ), if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty)) Divider( height: 10, color: cs.outlineVariant.withValues(alpha: 0.25), ), ], if (_extraContactExpanded) for (int i = 0; i < extraRows.length; i++) ...[ _infoRow(cs, extraRows[i].label, extraRows[i].value), if (i < extraRows.length - 1) Divider( height: 10, color: cs.outlineVariant.withValues(alpha: 0.25), ), ], ], ), ); } void _addChatOptions( void Function(String label, dynamic val, {bool tsFormat}) add, Map? opts, ) { if (opts == null) return; add(l10n.chatInfoRowDisableForward, opts['DISABLE_FORWARD'] as bool?); add( l10n.chatInfoRowCopyDisabled, opts['MESSAGE_COPY_NOT_ALLOWED'] as bool?, ); add(l10n.chatInfoRowOnlyAdminCall, opts['ONLY_ADMIN_CAN_CALL'] as bool?); add(l10n.chatInfoRowAllCanPin, opts['ALL_CAN_PIN_MESSAGE'] as bool?); add( l10n.chatInfoRowMembersSeeLink, opts['MEMBERS_CAN_SEE_PRIVATE_LINK'] as bool?, ); add( l10n.chatInfoRowConfirmBeforeSend, opts['CONFIRM_BEFORE_SEND'] as bool?, ); add( l10n.chatInfoRowOnlyOwnerIconTitle, opts['ONLY_OWNER_CAN_CHANGE_ICON_TITLE'] as bool?, ); add( l10n.chatInfoRowPromotedDisabled, opts['PROMOTED_CONTENT_DISABLED'] as bool?, ); } List<({String label, String value})> _buildExtraContactRows() { final c = _contactData; if (c == null) return const []; final rows = <({String label, String value})>[]; final reg = c.raw['registrationTime']; if (reg is int && reg > 0) { rows.add(( label: l10n.contactProfileInfoRegistration, value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)), )); } final upd = c.raw['updateTime']; if (upd is int && upd > 0) { rows.add(( label: l10n.contactProfileInfoUpdated, value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)), )); } final country = c.raw['country']; if (country is String && country.isNotEmpty) { rows.add((label: l10n.contactProfileInfoCountry, value: country)); } final gender = c.raw['gender']; if (gender is int) { final g = formatGender(gender); if (g != null) rows.add((label: l10n.contactProfileInfoGender, value: g)); } final phone = c.raw['phone']; if (phone is int && phone > 0) { rows.add((label: l10n.contactProfileInfoPhone, value: '+$phone')); } else if (phone is String && phone.isNotEmpty && phone != '***') { rows.add((label: l10n.contactProfileInfoPhone, value: phone)); } final accStatus = c.raw['accountStatus']; if (accStatus is int && accStatus != 0) { rows.add(( label: l10n.contactProfileInfoAccountStatus, value: accStatus.toString(), )); } final opts = c.raw['options']; if (opts is List && opts.isNotEmpty) { rows.add(( label: l10n.contactProfileInfoFlags, value: opts.whereType().join(', '), )); } final link = c.raw['link']; if (link is String && link.isNotEmpty) { rows.add((label: l10n.contactProfileInfoLink, value: link)); } return rows; } Widget? _trailingFor(String label, ColorScheme cs) { if (label != l10n.chatInfoRowId) return null; if (widget.chatType != 'DIALOG') return null; if (_contactData == null) return null; return IconButton( tooltip: _extraContactExpanded ? l10n.chatInfoHideExtra : l10n.chatInfoShowMoreExtra, icon: AnimatedRotation( turns: _extraContactExpanded ? 0.125 : 0, duration: const Duration(milliseconds: 220), child: Icon(Symbols.add_circle, color: cs.primary, size: 22), ), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded), ); } Future _copyInfoValue(String value) async { await Clipboard.setData(ClipboardData(text: value)); if (!mounted) return; Haptics.tap(); showCustomNotification(context, l10n.msgActionsCopied); } Widget _infoRow( ColorScheme cs, String label, String value, { Widget? trailing, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: () => unawaited(_copyInfoValue(value)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), ), Text( value, style: TextStyle( color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), ), ), ?trailing, ], ), ); } Future _loadAvatarHistory(int peerId) async { if (!_headerHasPhoto || _avatarHistoryBusy) return; _avatarHistoryBusy = true; final cached = ContactsModule.cachedPhotos(peerId); if (cached != null) _applyAvatarPhotos(cached); try { final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30); if (!mounted) return; _avatarHistoryLoaded = true; _applyAvatarPhotos(photos); } catch (e) { logger.w('Не удалось получить историю аватарок $peerId: $e'); } finally { _avatarHistoryBusy = false; } } void _applyAvatarPhotos(ContactPhotos photos) { final urls = []; if (widget.imageUrl.isNotEmpty) urls.add(widget.imageUrl); for (final url in photos.urls) { if (url.isNotEmpty && !urls.contains(url)) urls.add(url); } if (urls.isEmpty || listEquals(urls, _avatarPages)) return; setState(() { _avatarPages = urls; _avatarTotal = math.max(photos.total, urls.length); _avatarIndex = _avatarIndex.clamp(0, urls.length - 1); }); } Future _loadStories(int peerId) async { if (!AppStories.current.value || _peerDeleted) return; final cached = storiesModule.previewOf(peerId); if (cached != null && !cached.isEmpty && mounted) { setState(() { _storyPreview = cached; _refreshUnreadStories(); }); } final fresh = await storiesModule.loadOwnerPreview( StoryOwner(ownerId: peerId), ); if (!mounted) return; setState(() { _storyPreview = (fresh == null || fresh.isEmpty) ? null : fresh; _refreshUnreadStories(); }); } Future _openStories() async { final preview = _storyPreview; if (preview == null) return; Haptics.tap(); final avatarContext = _avatarKey.currentContext; await openStoryViewer( context, previews: [preview], origin: avatarContext == null ? null : storyOriginOf(avatarContext), ownerOverrides: { preview.owner.ownerId: StoryOwnerInfo( name: _customName, avatarUrl: _contactData?.avatarUrl ?? widget.imageUrl, ), }, ); if (!mounted) return; await _loadStories(preview.owner.ownerId); } List _loadingBlocks(ColorScheme cs) { Widget block(double w, double h, {double r = 8}) => Container( width: w, height: h, decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(r), ), ); return [ block(double.infinity, 60, r: 14), const SizedBox(height: 16), block(double.infinity, 36, r: 20), const SizedBox(height: 12), block(double.infinity, 120, r: 14), ]; } }