import 'dart:async'; import 'dart:io' show File; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:file_picker/file_picker.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chat_preview.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/comments.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/media/image_optimizer.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/screens/contacts/open_contact_profile.dart'; import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/animated_text_swap.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../l10n/app_localizations.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/animoji.dart'; import '../../../models/animoji.dart'; import '../../../backend/modules/complaints.dart'; import '../../../core/calls/call_controller.dart'; import '../../../core/media/rlottie/rlottie.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/push/push_service.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../core/storage/chat_wallpaper_store.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/archived_chats_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/utils/emoji_keyword_index.dart'; import '../../../core/utils/logger.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import 'chat/chat_prank_controller.dart'; import 'chat/chat_controller.dart'; import 'chat/voice_record_controller.dart'; import 'chat/video_note_controller.dart'; import 'chat/command_panel_controller.dart'; import 'chat/sticker_panel_controller.dart'; import 'chat/chat_search_controller.dart'; import 'chat/message_search_result.dart'; import 'chat/upload_status.dart'; import 'chat/view/search_view.dart'; import 'chat/view/composer_input.dart'; import 'chat/view/sticker_panel_view.dart'; import 'chat/view/command_panel_view.dart'; import 'chat/view/selection_bar.dart'; import 'chat/view/chat_header.dart'; import 'chat/view/shimmer_loading.dart'; import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; import 'package:komet/core/config/app_composer_background.dart'; import 'package:komet/core/config/app_frost.dart'; import 'package:komet/core/config/app_composer_style.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; import '../../widgets/rich_message_controller.dart'; import '../../../core/utils/text_format.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/photo_viewer.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/lottie_image.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/sticker_pack_sheet.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_view.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/liquid_glass.dart'; import 'scheduled_messages_screen.dart'; import 'chat_wallpaper_preview_screen.dart'; class _DateSeparatorItem { final DateTime date; final GlobalKey key; _DateSeparatorItem(this.date, this.key); } class _MessageItem { final CachedMessage message; final int index; const _MessageItem(this.message, this.index); } class _UnreadSeparatorItem { const _UnreadSeparatorItem(); } class _FrostedPanel extends StatelessWidget { final Color tint; final Border? border; final double sigma; final BackdropKey? backdropKey; final Widget child; const _FrostedPanel({ required this.tint, this.border, this.sigma = AppFrost.panelSigma, this.backdropKey, required this.child, }); @override Widget build(BuildContext context) { return GlassSurface( frostTint: tint, frostSigma: sigma, border: border, backdropKey: backdropKey, child: child, ); } } class _MeasureSize extends StatefulWidget { final Widget child; final ValueChanged onHeight; const _MeasureSize({required this.onHeight, required this.child}); @override State<_MeasureSize> createState() => _MeasureSizeState(); } class _MeasureSizeState extends State<_MeasureSize> { final GlobalKey _key = GlobalKey(); double _last = -1; void _report() { if (!mounted) return; final height = _key.currentContext?.size?.height; if (height == null) return; if ((height - _last).abs() > 0.5) { _last = height; widget.onHeight(height); } } void _scheduleReport() { WidgetsBinding.instance.addPostFrameCallback((_) => _report()); } @override Widget build(BuildContext context) { _scheduleReport(); return NotificationListener( onNotification: (_) { _scheduleReport(); return true; }, child: SizeChangedLayoutNotifier( child: SizedBox(key: _key, child: widget.child), ), ); } } class ForwardRequest { final int sourceChatId; final List optimistic; const ForwardRequest({required this.sourceChatId, required this.optimistic}); } class ChatScreen extends StatefulWidget { final int chatId; final String name; final String imageUrl; final String chatType; final bool embedded; final VoidCallback? onClose; final ForwardRequest? forwardRequest; final String? initialMessageId; final int? initialMessageTime; final String? commentPostId; final CachedMessage? postMessage; const ChatScreen({ super.key, required this.chatId, required this.name, required this.imageUrl, required this.chatType, this.embedded = false, this.onClose, this.forwardRequest, this.initialMessageId, this.initialMessageTime, this.commentPostId, this.postMessage, }); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { final RichMessageController _messageController = RichMessageController(); final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; bool _keyboardWasOpen = false; bool _keyboardBeforeStickers = false; final ScrollController _scrollController = ScrollController(); bool _userDidScroll = false; String? _pinnedMessageId; double _pinnedAlignment = 0; int? _unreadAnchorTime; bool _awaitingPosition = false; bool _navigatingToTarget = false; bool _initialPositionDone = false; bool _positioningInFlight = false; bool _initialTargetHandled = false; int _historyAutoloadSuppressCount = 0; bool get _historyAutoloadSuppressed => _historyAutoloadSuppressCount > 0; int _readMarkTime = 0; Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); late final StickerPanelController _stickers; final ValueNotifier _uploadStatus = ValueNotifier( const UploadStatus(), ); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; StreamSubscription>? _commentsInfoSub; StreamSubscription? _commentSub; final Map _commentCounts = {}; final Set _commentCountsRequested = {}; bool get _commentsMode => widget.commentPostId != null; bool _commentsLoadingMore = false; bool _commentsHasMore = true; StreamSubscription? _connSub; final Map?>> _reactionNotifiers = {}; final Map>> _photoUploadProgress = {}; final ValueNotifier _scheduledCount = ValueNotifier(0); late final VoiceRecordController _voiceRec = VoiceRecordController( contextOf: () => context, isMounted: () => mounted, myId: () => _myId, onRecorded: _sendVoice, ); late final VideoNoteController _note = VideoNoteController( contextOf: () => context, isMounted: () => mounted, onRecorded: _sendVideoNote, formatElapsed: formatVoiceElapsed, ); ValueListenable>? _photoProgressFor(CachedMessage m) => _photoUploadProgress[m.id]; ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; if (existing != null) return existing; final info = m.payload?['reactionInfo']; final notifier = ValueNotifier?>( info is Map ? Map.from(info) : null, ); _reactionNotifiers[m.id] = notifier; return notifier; } void _reactToMessage(CachedMessage message, String emoji) { if (message.isControl || message.id.startsWith('temp_')) return; final notifier = _reactionNotifierFor(message); final previous = notifier.value; final applied = _applyLocalReaction(previous, emoji); notifier.value = applied; final isToggleOff = applied == null || applied['yourReaction'] == null; unawaited(_sendReaction(message, emoji, isToggleOff, previous)); } Future _sendReaction( CachedMessage message, String emoji, bool isToggleOff, Map? previous, ) async { ({bool ok, Map? info}) result; try { result = isToggleOff ? await messagesModule.cancelReaction(widget.chatId, message.id) : await messagesModule.setReaction(widget.chatId, message.id, emoji); } catch (_) { result = (ok: false, info: null); } if (!mounted) return; final notifier = _reactionNotifiers[message.id]; if (notifier == null) return; if (!result.ok) { notifier.value = previous; Haptics.error(); showCustomNotification(context, 'Не удалось обновить реакцию'); return; } notifier.value = result.info; _applyReactionInfoToMessage(message.id, result.info); } void _applyReactionInfoToMessage( String messageId, Map? info, ) { final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; final payload = {...?_messages[idx].payload}; if (info == null) { payload.remove('reactionInfo'); } else { payload['reactionInfo'] = info; } _messages[idx] = _messages[idx].copyWith(payload: payload); } Map? _applyLocalReaction( Map? current, String emoji, ) { final counters = {}; final order = []; final rawCounters = current?['counters']; if (rawCounters is List) { for (final c in rawCounters) { if (c is! Map) continue; final r = c['reaction']?.toString(); if (r == null || r.isEmpty) continue; final n = c['count']; counters[r] = n is int ? n : 0; order.add(r); } } void decrement(String key) { final next = (counters[key] ?? 1) - 1; if (next <= 0) { counters.remove(key); order.remove(key); } else { counters[key] = next; } } final prev = current?['yourReaction']?.toString(); String? your; if (prev != null && EmojiKeywordIndex.normalize(prev) == EmojiKeywordIndex.normalize(emoji)) { decrement(prev); your = null; } else { if (prev != null && prev.isNotEmpty) decrement(prev); if (!counters.containsKey(emoji)) order.add(emoji); counters[emoji] = (counters[emoji] ?? 0) + 1; your = emoji; } if (counters.isEmpty) return null; final total = counters.values.fold(0, (a, b) => a + b); return { 'counters': [ for (final key in order) {'reaction': key, 'count': counters[key]}, ], 'yourReaction': ?your, 'totalCount': total, }; } void _pruneReactionNotifiers() { final liveIds = _messages.map((m) => m.id).toSet(); final dead = _reactionNotifiers.keys .where((id) => !liveIds.contains(id)) .toList(); for (final id in dead) { _reactionNotifiers.remove(id)?.dispose(); } _messageKeys.removeWhere((id, _) => !liveIds.contains(id)); } int _otherStatus = 0; int? _otherSeenTime; int? _participantsCount; final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier _highlightMessageId = ValueNotifier(null); Timer? _highlightTimer; final ValueNotifier _jumpCacheExtent = ValueNotifier(null); Timer? _goToMessageSettleTimer; static const double _jumpCacheExtentPx = 800.0; late final ChatSearchController _search; late final AnimationController _searchAnim; final FocusNode _searchFocusNode = FocusNode(); late final ChatPrankController _prank = ChatPrankController( vsync: this, contextOf: () => context, isMounted: () => mounted, onChanged: () { if (mounted) setState(() {}); }, ); final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; late final CommandPanelController _commandPanel; String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; Timer? _shimmerStartTimer; bool _previewChat = false; bool _subscribing = false; String? _channelLink; bool _forwardRequestDone = false; final ChatController _chatController = ChatController(); List get _messages => _chatController.messages; set _messages(List v) => _chatController.messages = v; ValueNotifier get _messagesRev => _chatController.messagesRev; bool get _historyKickedOff => _chatController.historyKickedOff; set _historyKickedOff(bool v) => _chatController.historyKickedOff = v; final GlobalKey _messageListKey = GlobalKey(); _ChatMessageList? _messageListWidget; final Set _deletingIds = {}; static const double _avgMessageHeight = 72.0; static const double _historyPrefetchExtent = _avgMessageHeight * 8; static const double _scrollDownRevealExtent = _avgMessageHeight * 30; static const double _scrollDownTeleportFactor = 2.0; static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; static const double _pinnedBannerLift = 6.0; final BackdropKey _barBackdrop = BackdropKey(); final BackdropKey _pillBackdrop = BackdropKey(); bool get _isLoadingMore => _chatController.isLoadingMore; set _isLoadingMore(bool v) => _chatController.isLoadingMore = v; bool get _hasMoreHistory => _chatController.hasMoreHistory; set _hasMoreHistory(bool v) => _chatController.hasMoreHistory = v; List? _combinedItemsCache; int? _combinedItemsKey; bool _floatingDateScheduled = false; int get _myId => _chatController.myId; set _myId(int v) => _chatController.myId = v; CachedChat? chat; bool _peerIsBot = false; ChatWallpaper? _wallpaper; bool get _composerFrosted => AppComposerBackground.current.value != ComposerBackground.standard; bool get _composerUnderlap => AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted; bool get _liquidChrome => AppVisualStyle.current.value.glossyChrome && ChatChromeMaterial.isLiquid(AppChatChrome.current.value); ChatChromeStyle get _effectiveChrome { final chrome = AppChatChrome.current.value; if (chrome == ChatChromeStyle.liquidGlass) return ChatChromeStyle.transparent; if (_wallpaper != null && chrome == ChatChromeStyle.none) { return ChatChromeStyle.blur; } return chrome; } final ValueNotifier _composerHeight = ValueNotifier(96); final ValueNotifier _pinnedBannerHeight = ValueNotifier(0); final ValueNotifier _floatingDate = ValueNotifier(null); Timer? _floatingDateTimer; late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; late final AnimationController _scrollDownAnimController; late final CurvedAnimation _scrollDownCurved; bool _scrollDownVisible = false; int _listEpoch = 0; final List<({String id, double pixels, double alignment})> _returnStack = []; bool _returningToAnchor = false; final Map _separatorKeys = {}; String? _lastSentId; final ValueNotifier _otherUnread = ValueNotifier(0); final ValueNotifier _animojiHold = ValueNotifier(true); final ValueNotifier> _selectedIds = ValueNotifier(const {}); final ValueNotifier<({String id, Offset pos})?> _textSelection = ValueNotifier(null); late final AnimationController _selectionAnim; bool get _selectionMode => _selectedIds.value.isNotEmpty; void _prewarmQuickReactions() { if (!mounted || !RlottieEngine.instance.available) return; final dpr = (MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp( 1.0, 2.0, ); final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32; for (final a in animojiModule.quickAnimojis) { final url = a.lottieUrl; if (url != null && url.isNotEmpty) { unawaited(RlottieEngine.instance.prewarm(url, px)); } } } @override void initState() { super.initState(); _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); unawaited( animojiModule .ensureLoaded() .then((_) => _prewarmQuickReactions()) .catchError((_) {}), ); WidgetsBinding.instance.addObserver(this); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_recordScrollPixels); _scrollController.addListener(_scheduleReadMarker); _scrollController.addListener(_exitTextSelectionOnScroll); _scrollController.addListener(_updateScrollDownVisible); AppVisualStyle.current.addListener(_onVisualStyleChanged); AppChatChrome.current.addListener(_onVisualStyleChanged); AppComposerStyle.current.addListener(_onVisualStyleChanged); AppComposerBackground.current.addListener(_onVisualStyleChanged); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); _attachAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 320), reverseDuration: const Duration(milliseconds: 240), ); _stickers = StickerPanelController( vsync: this, onSendTyping: () => messagesModule.sendTyping(widget.chatId, 'STICKER'), ); _showAttachmentPanel.addListener(_onAttachPanelToggle); _commandPanel = CommandPanelController( vsync: this, textOf: () => _messageController.text, onSelected: _onCommandSelected, ); _selectionAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 260), reverseDuration: const Duration(milliseconds: 200), ); _searchAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 280), reverseDuration: const Duration(milliseconds: 220), ); _search = ChatSearchController( chatId: widget.chatId, isMounted: () => mounted, ); _pushSub = api.pushStream .where( (p) => p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping || p.opcode == Opcode.notifMsgDelayed, ) .listen(_onIncomingPush); _messageEventSub = chats.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); if (_commentsMode) { _commentSub = commentsModule.commentStream .where( (e) => e.chatId == widget.chatId && e.postId == widget.commentPostId, ) .listen(_onLiveComment); } else if (widget.chatType == 'CHANNEL') { _commentsInfoSub = commentsModule.infoStream.listen(_onCommentsInfo); } ChatActivityStore.instance .listenable(widget.chatId) .addListener(_recomputeHeaderStatus); _connSub = api.stateStream.listen((_) { if (mounted) _recomputeHeaderStatus(); }); debugForceOffline.addListener(_recomputeHeaderStatus); PresenceFetch.revision.addListener(_onPresenceChanged); ContactsModule.revision.addListener(_onContactsChanged); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), reverseDuration: const Duration(milliseconds: 380), ); _floatingDateCurved = CurvedAnimation( parent: _floatingDateAnimController, curve: Curves.easeOut, reverseCurve: Curves.easeIn, ); _scrollDownAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), reverseDuration: const Duration(milliseconds: 180), ); _scrollDownCurved = CurvedAnimation( parent: _scrollDownAnimController, curve: Curves.easeOut, reverseCurve: Curves.easeIn, ); unawaited( _fastPreloadCache().then((_) { if (mounted) unawaited(_runForwardRequest()); }), ); unawaited(_loadParticipantsCount()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } Future _loadParticipantsCount() async { if (_commentsMode) return; if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; final info = await chats.getChatInfo(api, widget.chatId); if (!mounted) return; if (widget.chatType == 'CHANNEL') { final link = info?['link']; if (link is String && link.isNotEmpty) _channelLink = link; } final count = info?['participantsCount'] as int?; if (count != null && count != _participantsCount) { _participantsCount = count; _recomputeHeaderStatus(); } } Future _loadPeerKind() async { if (widget.chatType != 'DIALOG' || _myId == 0) return; final peerId = widget.chatId ^ _myId; if (peerId <= 0) return; final cached = ContactInfoFetch.peek(peerId); if (cached != null) _applyPeerKind(cached.isBot); final info = await ContactInfoFetch.get(peerId); if (info != null) _applyPeerKind(info.isBot); } void _applyPeerKind(bool isBot) { if (!mounted || _peerIsBot == isBot) return; setState(() => _peerIsBot = isBot); } Future _fastPreloadCache() async { final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; _myId = p?.id ?? 0; if (p != null && p.id != 0) { final myName = [ p.firstName, p.lastName, ].whereType().where((s) => s.isNotEmpty).join(' '); if (myName.isNotEmpty) ContactCache.put(p.id, myName); ContactCache.putAvatar(p.id, p.baseUrl); } if (_commentsMode) return; _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); unawaited(_refreshBadge()); try { final chatRows = await chats.getChat(_myId, widget.chatId); if (!mounted) return; if (chatRows.isNotEmpty) { setState(() { chat = chatRows.first; }); _bumpMessages(); _seedPresenceFromChat(); _recomputeHeaderStatus(); _syncOtherReadTime(); } } catch (_) {} _resolveUnreadAnchor(); final cached = MessageSessionCache.get(_myId, widget.chatId); if (cached != null && cached.messages.isNotEmpty) { setState(() { _messages = List.of(cached.messages); _hasMoreHistory = !cached.reachedStart; _messagesRev.value++; }); _syncReactionNotifiersFromMessages(); _requestCommentCounts(); _revealOrHoldInitial(); return; } final firstRows = await AppDatabase.loadMessages( _myId, widget.chatId, limit: 20, onlyVisible: !KometSettings.viewDeleted.value, ); if (!mounted) return; if (firstRows.isNotEmpty) { final first = firstRows.reversed .map((r) => CachedMessage.fromDbRow(r)) .toList(); setState(() { _messages = first; _messagesRev.value++; }); _requestCommentCounts(); _revealOrHoldInitial(); } } void _resolveUnreadAnchor() { final c = chat; _readMarkTime = c?.participants[_myId] ?? 0; if (c == null || c.unreadCount <= 0) { _unreadAnchorTime = null; } else { final myMark = c.participants[_myId] ?? 0; _unreadAnchorTime = myMark > 0 ? myMark : null; } _awaitingPosition = c != null && c.unreadCount > 0; } void _resolveCountBasedAnchor() { final c = chat; if (c == null || c.unreadCount <= 0 || _messages.isEmpty) return; final unread = c.unreadCount; if (_messages.length > unread) { _unreadAnchorTime = _messages[_messages.length - unread - 1].time; } else if (!_hasMoreHistory) { _unreadAnchorTime = _messages.first.time - 1; } } void _revealOrHoldInitial() { if (_awaitingPosition && !_canPositionNow()) return; setState(() { _isLoading = false; _onLoadingFinished(); }); } bool _canPositionNow() { if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; if (ua == null) return false; final firstUnread = _messages.indexWhere((m) => m.time > ua); if (firstUnread == -1) return _newestMessageLoaded(); return firstUnread > 0 || !_hasMoreHistory; } bool _newestMessageLoaded() { if (_messages.isEmpty) return false; final serverLast = chat?.lastMsgTime ?? 0; return _messages.last.time >= serverLast; } void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { _kickoffHistory(); return; } final anim = ModalRoute.of(context)?.animation; if (anim == null || anim.status == AnimationStatus.completed) { _kickoffHistory(); return; } Timer? safety; void onStatus(AnimationStatus status) { if (status != AnimationStatus.completed) return; anim.removeStatusListener(onStatus); safety?.cancel(); if (!mounted) return; _kickoffHistory(); } anim.addStatusListener(onStatus); safety = Timer(const Duration(milliseconds: 400), () { anim.removeStatusListener(onStatus); if (!mounted) return; _kickoffHistory(); }); } void _kickoffHistory() { _animojiHold.value = false; if (_historyKickedOff) return; _historyKickedOff = true; _shimmerStartTimer = Timer(const Duration(milliseconds: 150), () { if (!mounted || !_isLoading) return; _shimmerController.repeat(); }); _loadHistory(); } void _onLoadingFinished() { _shimmerStartTimer?.cancel(); _shimmerStartTimer = null; _applyInitialPositioning(); } void _recordScrollPixels() { if (!_scrollController.hasClients) return; if (_initialPositionDone && _scrollController.position.userScrollDirection != ScrollDirection.idle) { _userDidScroll = true; _pinnedMessageId = null; } } void _positionToMessage(String messageId, double alignment) { _pinnedMessageId = messageId; _pinnedAlignment = alignment.clamp(0.0, 1.0); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _scrollToLoadedMessage( messageId, alignment: _pinnedAlignment, highlight: false, notifyIfMissing: false, onSettled: () { if (mounted) setState(_markPositioned); }, ); }); } void _reapplyPinIfNeeded() { final id = _pinnedMessageId; if (id == null || _userDidScroll || !_scrollController.hasClients) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _pinnedMessageId != id || _userDidScroll) return; _alignLoadedMessage(id, _pinnedAlignment, 0); }); } void _applyInitialPositioning() { if (_initialPositionDone) { if (_shimmerController.isAnimating) _shimmerController.stop(); _scheduleReadMarker(); return; } if (_positioningInFlight) return; if (_commentsMode) { _initialPositionDone = true; _markPositioned(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; _scrollController.jumpTo(_scrollController.position.maxScrollExtent); }); return; } if (_messages.isEmpty) { if (!_hasMoreHistory) _markPositioned(); return; } final c = chat; if (c != null && c.unreadCount > 0) { if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; if (ua == null) { if (_hasMoreHistory) { _positioningInFlight = true; unawaited(_loadUntilUnreadReady()); } else { _markPositioned(); } return; } final firstUnread = _messages.indexWhere((m) => m.time > ua); if (firstUnread == -1) { _markPositioned(); return; } if (firstUnread > 0 || !_hasMoreHistory) { _initialPositionDone = true; _positionToMessage(_messages[firstUnread].id, 0.15); } else { _positioningInFlight = true; unawaited(_loadUntilUnreadReady()); } return; } _markPositioned(); } void _markPositioned() { _positioningInFlight = false; _initialPositionDone = true; _awaitingPosition = false; _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 _openChatInfo({ChatInfoTab? initialTab}) { final navigator = Navigator.of(context); final chatRoute = ModalRoute.of(context); navigator.push( MaterialPageRoute( builder: (_) => ChatInfoScreen( chatId: widget.chatId, name: _headerName(), imageUrl: widget.imageUrl, chatType: widget.chatType, initialTab: initialTab, onJumpToMessage: (chatRoute == null || widget.embedded) ? null : (messageId, time) { navigator.popUntil((r) => r == chatRoute); _requestGoToMessage(messageId, time); }, ), ), ); } late final PhotoViewerActions _photoActions = PhotoViewerActions( goToMessage: _requestGoToMessage, forward: _forwardMessageById, delete: (messageId, senderId) => _confirmDeleteMessage(messageId, senderId == _myId), viewAllPhotos: () => _openChatInfo(initialTab: ChatInfoTab.media), ); void _forwardMessageById(String messageId) { final message = _messages.where((m) => m.id == messageId).firstOrNull; if (message == null) { showCustomNotification(context, 'Сообщение не загружено'); return; } unawaited(_forwardMessages([message])); } void _requestGoToMessage(String id, int time) { if (!mounted) return; setState(_beginTargetNavigation); _goToMessageSettleTimer = Timer(const Duration(milliseconds: 340), () { if (mounted) unawaited(_runGoToMessage(id, time)); }); } Future _loadUntilUnreadReady() async { await _walkHistoryBack( reached: () { if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; return ua != null && _messages.indexWhere((m) => m.time > ua) > 0; }, maxPages: 15, ); if (!mounted) return; if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; final idx = ua == null ? -1 : _messages.indexWhere((m) => m.time > ua); _positioningInFlight = false; _initialPositionDone = true; if (idx >= 0) { _positionToMessage(_messages[idx].id, 0.15); } else { setState(_markPositioned); } } void _scheduleReadMarker() { _readMarkTimer?.cancel(); _readMarkTimer = Timer( const Duration(milliseconds: 350), _updateReadMarker, ); } void _updateReadMarker() { if (_commentsMode) return; if (!mounted || _myId == 0 || _messages.isEmpty) return; if (_awaitingPosition || !_initialPositionDone) return; if (!_scrollController.hasClients) return; final listBox = _listKey.currentContext?.findRenderObject(); if (listBox is! RenderBox) return; final viewportBottom = listBox.size.height; if (viewportBottom <= 0) return; CachedMessage? candidate; int topIndex = -1; for (int i = _messages.length - 1; i >= 0; i--) { final m = _messages[i]; final ctx = _messageKeys[m.id]?.currentContext; if (ctx == null) continue; final box = ctx.findRenderObject(); if (box is! RenderBox || !box.attached) continue; final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; final bottom = top + box.size.height; if (bottom <= 0 || top >= viewportBottom) continue; candidate ??= m; topIndex = i; } if (candidate == null) return; final atBottom = candidate.id == _messages.last.id; if (_unreadAnchorTime != null && _unreadSeparatorScrolledPast( atBottom, topIndex, listBox, viewportBottom, )) { _unreadAnchorTime = null; _bumpMessages(); } if (candidate.time <= _readMarkTime) return; _readMarkTime = candidate.time; final remaining = _messages .where((m) => m.time > _readMarkTime && m.senderId != _myId) .length; unawaited( chats.markReadUpTo( api, _myId, widget.chatId, candidate.id, candidate.time, remaining: remaining, ), ); } bool _unreadSeparatorScrolledPast( bool atBottom, int topIndex, RenderBox listBox, double viewportBottom, ) { if (atBottom) return true; final ua = _unreadAnchorTime; if (ua == null) return false; final firstUnread = _messages.indexWhere((m) => m.time > ua); if (firstUnread == -1) return true; if (topIndex >= 0 && topIndex > firstUnread) return true; final box = _messageKeys[_messages[firstUnread].id]?.currentContext ?.findRenderObject(); if (box is RenderBox && box.attached) { final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; if (top <= 0) return true; } return false; } Future _markMessageUnread(CachedMessage message) async { final unread = await chats.markUnread( api, _myId, widget.chatId, message.time, ); if (!mounted) return; if (unread == null) { showCustomNotification(context, 'Не удалось пометить непрочитанным'); return; } Navigator.of(context).pop(); } bool _canShowReadBy(CachedMessage message) { if (message.isControl || message.deleted) return false; if (int.tryParse(message.id) == null) return false; final type = chat?.type ?? widget.chatType; return type == 'CHAT' || type == 'GROUP'; } Future> _loadReadBy(CachedMessage message) async { final marks = await chats.getReadMarks(api, _myId, widget.chatId); final reactions = await messagesModule.getDetailedReactions( widget.chatId, message.id, ); final readerIds = { ...marks.entries.where((e) => e.value >= message.time).map((e) => e.key), ...reactions.keys, }..removeAll({_myId, message.senderId}); if (readerIds.isEmpty || !mounted) return const []; await messagesModule.ensureContactNames(readerIds); await animojiModule.ensureLoaded(); if (!mounted) return const []; final animojiByEmoji = { for (final animoji in animojiModule.animojis) EmojiKeywordIndex.normalize(animoji.emoji): animoji, }; final unknownName = AppLocalizations.of( context, )!.msgActionsReadByUnknownUser; final readers = readerIds.map((id) { final emoji = reactions[id]; final animoji = emoji == null ? null : animojiByEmoji[EmojiKeywordIndex.normalize(emoji)]; final name = ContactCache.get(id); return MessageReader( id: id, name: name == null || name.isEmpty ? unknownName : name, avatarUrl: ContactCache.getAvatar(id), reaction: emoji == null ? null : ReactionEmoji( emoji: emoji, animationUrl: animoji?.lottieUrl, staticUrl: animoji?.iconUrl, ), ); }).toList(); readers.sort((a, b) { final aReacted = a.reaction != null; final bReacted = b.reaction != null; if (aReacted != bReacted) return aReacted ? -1 : 1; return (marks[b.id] ?? 0).compareTo(marks[a.id] ?? 0); }); return readers; } bool _canPinMessage(CachedMessage message) { if (message.isControl) return false; if (int.tryParse(message.id) == null) return false; return chat?.canPinMessages(_myId) ?? false; } Future _togglePinMessage(CachedMessage message) async { final messageId = int.tryParse(message.id); if (messageId == null) return; final previousChat = chat; final willUnpin = chat?.pinnedMsgId == messageId; if (willUnpin) { _applyPinnedMessageLocally(); } else { final preview = _pinnedPreviewFor(message); _applyPinnedMessageLocally( messageId: messageId, text: preview.text, time: message.time, isPreview: preview.isPreview, ); } final error = await chats.setPinnedMessage( api, chatId: widget.chatId, messageId: willUnpin ? null : messageId, notify: !willUnpin, ); if (!mounted) return; if (error != null) { if (previousChat != null) setState(() => chat = previousChat); showCustomNotification(context, error); return; } showCustomNotification( context, willUnpin ? 'Сообщение откреплено' : 'Сообщение закреплено', ); } Future _unpinCurrentMessage() async { final previousChat = chat; _applyPinnedMessageLocally(); final error = await chats.setPinnedMessage( api, chatId: widget.chatId, messageId: null, notify: false, ); if (!mounted) return; if (error != null) { if (previousChat != null) setState(() => chat = previousChat); showCustomNotification(context, error); return; } showCustomNotification(context, 'Сообщение откреплено'); } ({String? text, bool isPreview}) _pinnedPreviewFor(CachedMessage message) { final payload = message.payload; if (payload != null) return pinnedMessagePreview(payload); return pinnedMessagePreview({ 'text': message.text, 'attaches': message.attachments?.map((a) => a.toMap()).toList() ?? const [], }); } void _applyPinnedMessageLocally({ int? messageId, String? text, int? time, bool isPreview = false, }) { final current = chat; if (current == null) return; setState(() { chat = current.copyWith( pinnedMsgId: messageId, pinnedMsgText: text, pinnedMsgTime: time, pinnedMsgIsPreview: isPreview, ); }); } void _jumpToPinnedMessage() { final id = chat?.pinnedMsgId; if (id == null) return; final messageId = id.toString(); if (_messages.any((m) => m.id == messageId)) { _scrollToLoadedMessage(messageId); return; } setState(_beginTargetNavigation); unawaited(_runGoToMessage(messageId, chat?.pinnedMsgTime ?? 0)); } bool _badgeRefreshing = false; bool _badgeRefreshQueued = false; void _onChatsBump() { unawaited(_reloadChatMeta()); if (_badgeRefreshing) { _badgeRefreshQueued = true; return; } unawaited(_runBadgeRefresh()); } Future _reloadChatMeta() async { if (_myId == 0) return; final rows = await chats.getChat(_myId, widget.chatId); if (!mounted || rows.isEmpty) return; final fresh = rows.first; final current = chat; if (current != null && current.pinnedMsgId == fresh.pinnedMsgId && current.pinnedMsgText == fresh.pinnedMsgText && current.pinnedMsgTime == fresh.pinnedMsgTime && current.pinnedMsgIsPreview == fresh.pinnedMsgIsPreview && current.owner == fresh.owner && current.options.length == fresh.options.length && current.options.containsAll(fresh.options) && current.admins.length == fresh.admins.length && current.admins.containsAll(fresh.admins)) { return; } setState(() => chat = fresh); } Future _runBadgeRefresh() async { _badgeRefreshing = true; try { await _refreshBadge(); } finally { _badgeRefreshing = false; if (_badgeRefreshQueued && mounted) { _badgeRefreshQueued = false; unawaited(_runBadgeRefresh()); } } } Future _refreshBadge() async { if (_myId == 0) return; final total = await AppDatabase.sumUnread( _myId, excludeChatId: widget.chatId, excludeChatIds: ArchivedChatsStore.instance.archivedChatIds(_myId), ); if (mounted) _otherUnread.value = total; } Future _loadHistory() async { if (_myId == 0) { final activeProfile = await AppDatabase.loadActiveProfile(); if (!mounted) return; _myId = activeProfile?.id ?? 0; } if (_commentsMode) { await _loadCommentsHistory(); return; } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } unawaited(_refreshScheduledCount()); await _chatController.loadRemainingHistory( onApplyMerged: _applyMergedMessages, onLoadingFinished: () { setState(() { _isLoading = false; _onLoadingFinished(); }); }, onPreview: () => _previewChat = true, onSenderNames: () { _loadForwardedSenderNames(); _loadGroupSenderNames(); }, ); } void _maybeLoadMoreHistory() { if (!_scrollController.hasClients) return; if (_historyAutoloadSuppressed) return; if (_isLoading) return; if (_commentsMode) { if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) return; final pos = _scrollController.position; if (pos.pixels - pos.minScrollExtent <= _historyPrefetchExtent) { unawaited(_loadMoreComments()); } return; } _maybeFillGap(); if (_isLoadingMore || !_hasMoreHistory) return; if (_messages.isEmpty) return; final pos = _scrollController.position; if (pos.maxScrollExtent <= 0) return; if (pos.maxScrollExtent - pos.pixels <= _historyPrefetchExtent) { unawaited(_loadMoreHistory()); } } void _maybeFillGap() { final controller = _chatController; if (!controller.hasGap || controller.loadingGap) return; for (final gap in controller.gaps) { final box = _keyForMessage(gap.edgeId).currentContext?.findRenderObject(); if (box is RenderBox && box.attached) { unawaited(_fillGapForward(gap)); return; } } } Future _fillGapForward(HistoryGap gap) async { final edgeId = gap.edgeId; final beforeDy = _messageOffsetInList(edgeId); final added = await _chatController.fillGapForward(gap); if (!mounted || added == 0) return; _syncReactionNotifiersFromMessages(); _bumpMessages(); await WidgetsBinding.instance.endOfFrame; if (!mounted || !_scrollController.hasClients) return; final afterDy = _messageOffsetInList(edgeId); if (beforeDy != null && afterDy != null) { final delta = beforeDy - afterDy; if (delta.abs() > 0.5) { final pos = _scrollController.position; _scrollController.jumpTo( (pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent), ); } } _loadForwardedSenderNames(); _loadGroupSenderNames(); } double? _messageOffsetInList(String messageId) { final listBox = _listKey.currentContext?.findRenderObject(); final box = _keyForMessage(messageId).currentContext?.findRenderObject(); if (listBox is! RenderBox || box is! RenderBox || !box.attached) { return null; } return box.localToGlobal(Offset.zero, ancestor: listBox).dy; } Future _loadMessageWindow(String messageId, int targetTime) async { if (targetTime <= 0) { await _walkHistoryBack( reached: () => _messages.any((m) => m.id == messageId), maxPages: 10, ); return; } _historyAutoloadSuppressCount++; try { await _chatController.loadMessageWindow( targetId: messageId, targetTime: targetTime, ); } finally { _historyAutoloadSuppressCount--; } if (!mounted) return; _syncReactionNotifiersFromMessages(); _bumpMessages(); _loadForwardedSenderNames(); _loadGroupSenderNames(); } Future _walkHistoryBack({ required bool Function() reached, required int maxPages, int targetTime = 0, }) async { if (reached()) return; _historyAutoloadSuppressCount++; try { var page = 0; while (mounted && page < maxPages && _hasMoreHistory && !reached() && (_messages.isEmpty || _messages.first.time > targetTime)) { page++; final before = _messages.isEmpty ? 0 : _messages.first.time; await _loadMoreHistory( resolveSenderNames: false, pageSize: ChatController.historyWalkPageSize, persist: false, ); if (!mounted) return; final after = _messages.isEmpty ? 0 : _messages.first.time; if (after == before) break; } } finally { _historyAutoloadSuppressCount--; } if (!mounted) return; _chatController.persistSessionCache(); _loadForwardedSenderNames(); _loadGroupSenderNames(); } Future _loadMoreHistory({ bool resolveSenderNames = true, int? pageSize, bool persist = true, }) async { await _chatController.loadMoreHistory( pageSize: pageSize, persist: persist, onLoadingStarted: _bumpMessages, onLoaded: (added) { if (added > 0) _syncReactionNotifiersFromMessages(); _bumpMessages(); if (resolveSenderNames) { _loadForwardedSenderNames(); _loadGroupSenderNames(); } }, onError: (_) { if (mounted) { _isLoadingMore = false; _bumpMessages(); } }, ); } void _applyMergedMessages( List decodedDesc, { bool markLoaded = false, }) { final changed = _chatController.mergeMessages(decodedDesc); _requestCommentCounts(); if (!changed && !markLoaded) return; setState(() { if (markLoaded) { _isLoading = false; _onLoadingFinished(); } }); if (changed) { _syncReactionNotifiersFromMessages(); _pruneReactionNotifiers(); _chatController.persistSessionCache(); _reapplyPinIfNeeded(); } } void _requestCommentCounts() { if (_commentsMode) return; if ((chat?.type ?? widget.chatType) != 'CHANNEL') return; final pending = []; for (final m in _messages) { if (m.isControl) continue; if (_commentCountsRequested.contains(m.id)) continue; _commentCountsRequested.add(m.id); pending.add(m.id); } if (pending.isEmpty) return; unawaited( commentsModule.fetchInfo( accountId: _myId, chatId: widget.chatId, postIds: pending, ), ); } void _onCommentsInfo(Map info) { if (!mounted) return; var changed = false; for (final m in _messages) { final count = info[m.id]?.totalCount; if (count == null) continue; if (_commentCounts[m.id] != count) { _commentCounts[m.id] = count; changed = true; } } if (changed) setState(() {}); } String _commentsLabelFor(String postId) { final l10n = AppLocalizations.of(context)!; final count = _commentCounts[postId]; if (count == null || count == 0) return l10n.commentsWrite; return l10n.commentsCount(count); } void _openComments(CachedMessage post) { Navigator.of(context) .push( MaterialPageRoute( builder: (_) => ChatScreen( chatId: widget.chatId, name: widget.name, imageUrl: widget.imageUrl, chatType: 'CHANNEL', commentPostId: post.id, postMessage: _stripInlineKeyboard(post), ), ), ) .then((_) => _refreshCommentCount(post.id)); } void _refreshCommentCount(String postId) { if (!mounted) return; _commentCountsRequested.remove(postId); _requestCommentCounts(); } CachedMessage _stripInlineKeyboard(CachedMessage post) { final attaches = post.attachments; if (attaches == null || attaches.isEmpty) return post; final filtered = attaches .where((a) => a.type != AttachmentType.inlineKeyboard) .toList(); if (filtered.length == attaches.length) return post; return post.copyWith(attachments: filtered); } Future _loadCommentsHistory() async { final post = widget.postMessage; final loaded = await commentsModule.fetchHistory( _myId, widget.chatId, widget.commentPostId!, fromTime: post?.time ?? DateTime.now().millisecondsSinceEpoch, forward: 30, backward: 0, ); if (!mounted) return; final comments = [...loaded]..sort((a, b) => a.time.compareTo(b.time)); _messages = post != null ? [post, ...comments] : comments; _commentsHasMore = comments.isNotEmpty; _syncReactionNotifiersFromMessages(); unawaited(_resolveCommentNames(comments)); _bumpMessages(); setState(() { _isLoading = false; _onLoadingFinished(); }); } Future _loadMoreComments() async { if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) return; _commentsLoadingMore = true; final newest = _messages.last; try { final more = await commentsModule.fetchHistory( _myId, widget.chatId, widget.commentPostId!, fromTime: newest.time, forward: 30, backward: 0, ); if (!mounted) return; final existing = _messages.map((m) => m.id).toSet(); final fresh = more.where((c) => !existing.contains(c.id)).toList(); if (fresh.isEmpty) { _commentsHasMore = false; } else { _messages = [..._messages, ...fresh] ..sort((a, b) => a.time.compareTo(b.time)); _syncReactionNotifiersFromMessages(); unawaited(_resolveCommentNames(fresh)); _bumpMessages(); } } finally { _commentsLoadingMore = false; } } void _onLiveComment(CommentAddedEvent event) { if (!mounted) return; final comment = event.comment; if (comment.senderId == _myId) return; if (_messages.any((m) => m.id == comment.id)) return; final nearBottom = _isNearListBottom(); _messages.add(comment); _syncReactionNotifiersFromMessages(); _bumpMessages(); unawaited(_resolveCommentNames([comment])); if (nearBottom) _scrollToBottom(); } bool _isNearListBottom() { if (!_scrollController.hasClients) return true; return _scrollController.position.pixels <= _historyPrefetchExtent; } Future _resolveCommentNames(List list) async { final ids = list .map((m) => m.senderId) .where((id) => id != 0 && ContactCache.get(id) == null) .toSet(); if (ids.isEmpty) return; final resolved = await messagesModule.ensureContactNames(ids); if (resolved && mounted) _bumpMessages(); } void _syncReactionNotifiersFromMessages() { for (final m in _messages) { if (_reactionNotifiers.containsKey(m.id)) continue; final info = m.payload?['reactionInfo']; _reactionNotifiers[m.id] = ValueNotifier( info is Map ? Map.from(info) : null, ); } } @override void deactivate() { _saveDraft(); super.deactivate(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { if (_voiceRec.isRecording.value) { unawaited(_voiceRec.stop(cancel: true)); } if (_note.isRecording.value) { unawaited(_note.stop(cancel: true)); } _saveDraft(); } super.didChangeAppLifecycleState(state); } @override void didChangeMetrics() { super.didChangeMetrics(); final view = View.of(context); final keyboardOpen = view.viewInsets.bottom / view.devicePixelRatio > 100; if (_keyboardWasOpen && !keyboardOpen && _messageFocusNode.hasFocus) { _messageFocusNode.unfocus(); } _keyboardWasOpen = keyboardOpen; } @override void dispose() { _chatController.persistSessionCache(); if (_previewChat) { unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); } WidgetsBinding.instance.removeObserver(this); chats.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose(); _animojiHold.dispose(); _saveDraft(); _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); _scrollController.removeListener(_maybeLoadMoreHistory); _scrollController.removeListener(_recordScrollPixels); _scrollController.removeListener(_scheduleReadMarker); _scrollController.removeListener(_exitTextSelectionOnScroll); _scrollController.removeListener(_updateScrollDownVisible); _readMarkTimer?.cancel(); AppVisualStyle.current.removeListener(_onVisualStyleChanged); AppChatChrome.current.removeListener(_onVisualStyleChanged); AppComposerStyle.current.removeListener(_onVisualStyleChanged); AppComposerBackground.current.removeListener(_onVisualStyleChanged); _composerHeight.dispose(); _pinnedBannerHeight.dispose(); _floatingDateTimer?.cancel(); _floatingDateCurved.dispose(); _floatingDateAnimController.dispose(); _floatingDate.dispose(); _scrollDownCurved.dispose(); _scrollDownAnimController.dispose(); _hasText.dispose(); _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); _uploadSub?.cancel(); _pushSub?.cancel(); _messageEventSub?.cancel(); _commentsInfoSub?.cancel(); _commentSub?.cancel(); _connSub?.cancel(); _voiceRec.dispose(); _note.dispose(); debugForceOffline.removeListener(_recomputeHeaderStatus); for (final n in _reactionNotifiers.values) { n.dispose(); } _reactionNotifiers.clear(); for (final n in _photoUploadProgress.values) { n.dispose(); } _photoUploadProgress.clear(); ChatActivityStore.instance .listenable(widget.chatId) .removeListener(_recomputeHeaderStatus); PresenceFetch.revision.removeListener(_onPresenceChanged); ContactsModule.revision.removeListener(_onContactsChanged); if (_wallpaperListening) { ChatWallpaperStore.instance.revision.removeListener( _applyEffectiveWallpaper, ); } _headerStatusNotifier.dispose(); _otherReadTime.dispose(); _chatController.dispose(); _prank.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); _commandPanel.dispose(); _selectionAnim.dispose(); _searchAnim.dispose(); _searchFocusNode.dispose(); _search.dispose(); _selectedIds.dispose(); _textSelection.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _stickers.dispose(); _scrollController.dispose(); _shimmerStartTimer?.cancel(); _shimmerController.dispose(); _replyTo.dispose(); _highlightTimer?.cancel(); _highlightMessageId.dispose(); _goToMessageSettleTimer?.cancel(); _jumpCacheExtent.dispose(); _messageKeys.clear(); super.dispose(); } void _onTextChanged() { final newHasText = _messageController.text.trim().isNotEmpty; if (newHasText != _hasText.value) { _hasText.value = newHasText; } _commandPanel.update(); } void _onCommandSelected(SlashCommand c) { final text = '${c.name} '; _messageController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); _messageFocusNode.requestFocus(); } void _restoreDraft() { if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) { return; } final draft = DraftStore.instance.get(_myId, widget.chatId); if (draft == null || draft.isEmpty) return; _messageController.text = draft; _messageController.selection = TextSelection.collapsed( offset: draft.length, ); } void _saveDraft() { if (_myId == 0 || _commentsMode) return; unawaited( DraftStore.instance.set( _myId, widget.chatId, _messageController.buildContent().text, ), ); } void _onAttachPanelToggle() { if (_showAttachmentPanel.value) { _attachAnim.forward(); } else { _attachAnim.reverse(); } } int _computeOtherReadTime() { final c = chat; if (c == null) return 0; int otherReadTime = 0; for (final entry in c.participants.entries) { if (entry.key != _myId && entry.value > otherReadTime) { otherReadTime = entry.value; } } return otherReadTime; } void _syncOtherReadTime() { final t = _computeOtherReadTime(); if (_otherReadTime.value != t) _otherReadTime.value = t; } String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; if (msg.status == 'sending' || msg.status == 'error') return msg.status; return 'sent'; } void _onIncomingPush(Packet packet) { if (!mounted) return; switch (packet.opcode) { case Opcode.notifMark: _onMessageRead(packet); case Opcode.notifTyping: _onTyping(packet); case Opcode.notifMsgDelayed: final p = packet.payload; if (p is Map && p['chatId'] == widget.chatId) { // lastDelayedUpdateTime — авторитетный признак от сервера: // 0 — отложенных в чате не осталось, иначе они есть. Реагируем // мгновенно по пушу, не дожидаясь повторного запроса. final t = p['lastDelayedUpdateTime']; if (t is int && t == 0) { _scheduledCount.value = 0; } else { if (_scheduledCount.value == 0) _scheduledCount.value = 1; _refreshScheduledCount(); } } } } void _markHasScheduled() { if (_scheduledCount.value == 0) _scheduledCount.value = 1; } Future _refreshScheduledCount() async { if (_myId == 0) return; try { final list = await messagesModule.fetchDelayedMessages( _myId, widget.chatId, ); if (mounted) _scheduledCount.value = list.length; } catch (_) {} } void _bumpMessages() { _combinedItemsCache = null; _messagesRev.value++; } void _enterSelection(CachedMessage message) { if (message.isControl) return; Haptics.medium(); if (_selectedIds.value.contains(message.id)) return; _selectedIds.value = {..._selectedIds.value, message.id}; _syncSelectionAnim(); } void _toggleSelection(CachedMessage message) { if (message.isControl) return; final next = Set.from(_selectedIds.value); if (!next.remove(message.id)) next.add(message.id); Haptics.selection(); _selectedIds.value = next; if (!next.contains(message.id)) _exitTextSelection(message.id); _syncSelectionAnim(); } void _clearSelection() { _exitTextSelection(); if (_selectedIds.value.isEmpty) return; _selectedIds.value = const {}; _syncSelectionAnim(); } void _startTextSelection(CachedMessage message, Offset globalPosition) { if (message.isControl || (message.text?.isEmpty ?? true)) return; _textSelection.value = (id: message.id, pos: globalPosition); } void _exitTextSelection([String? onlyId]) { final current = _textSelection.value; if (current == null) return; if (onlyId != null && current.id != onlyId) return; _textSelection.value = null; } void _exitTextSelectionOnScroll() { if (_textSelection.value == null) return; if (!_scrollController.hasClients) return; if (_scrollController.position.userScrollDirection != ScrollDirection.idle) { _exitTextSelection(); } } void _syncSelectionAnim() { if (_selectedIds.value.isEmpty) { _selectionAnim.reverse(); } else if (_selectionAnim.status != AnimationStatus.forward && _selectionAnim.value < 1) { _selectionAnim.forward(); } } List _selectedMessages(Set ids) => _messages.where((m) => ids.contains(m.id)).toList(); CachedMessage? _singleCopyableText(Set ids) { CachedMessage? found; var textCount = 0; for (final m in _messages) { if (!ids.contains(m.id)) continue; if ((m.text ?? '').isEmpty) continue; if (++textCount > 1) return null; found = m; } return found; } CachedMessage? _singleEditable(Set ids) { if (ids.length != 1) return null; final list = _selectedMessages(ids); if (list.isEmpty) return null; return _canEditMessage(list.first) ? list.first : null; } void _copySelected(CachedMessage message) { final text = message.text; if (text == null || text.isEmpty) return; Clipboard.setData(ClipboardData(text: text)); Haptics.tap(); showCustomNotification(context, 'Скопировано'); _clearSelection(); } void _editSelected(CachedMessage message) { _clearSelection(); _startEditMessage(message); } Future _deleteSelected() async { final msgs = _selectedMessages(_selectedIds.value); if (msgs.isEmpty) return; final serverMsgs = msgs.where((m) => !m.id.startsWith('temp_')).toList(); if (serverMsgs.isEmpty) { for (final m in msgs) { _startDeleteAnimation(m.id); } _clearSelection(); return; } final canForEveryone = serverMsgs.every((m) => m.senderId == _myId); final forEveryone = await _showDeleteMessageDialog(canForEveryone); if (forEveryone == null || !mounted) return; final ok = await messagesModule.deleteMessages( widget.chatId, serverMsgs.map((m) => m.id).toList(), forEveryone: forEveryone, ); if (!mounted) return; if (!ok) { Haptics.error(); showCustomNotification(context, 'Не удалось удалить сообщения'); return; } for (final m in msgs) { _startDeleteAnimation(m.id); } _clearSelection(); } void _replySelected() { final msgs = _selectedMessages(_selectedIds.value); if (msgs.isEmpty) return; final message = msgs.first; _clearSelection(); _startReply(message); } void _forwardSelected() { final msgs = _selectedMessages(_selectedIds.value); _clearSelection(); unawaited(_forwardMessages(msgs)); } Future _forwardMessages(List msgs) async { final forwardable = msgs.where((m) => !m.id.startsWith('temp_')).toList(); if (forwardable.isEmpty) { showCustomNotification(context, 'Нечего пересылать'); return; } final target = await openForwardScreen( context: context, messageCount: forwardable.length, ); if (target == null || !mounted) return; if (api.state != SessionState.online) { showCustomNotification(context, 'Нет соединения'); return; } final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time)); if (target.chatId == widget.chatId) { await _forwardIntoCurrentChat(ordered); return; } final optimistic = await _seedForwardsToChat(target, ordered); if (!mounted) return; Haptics.send(); pushSwipeable( context, (_) => ChatScreen( chatId: target.chatId, name: target.name, imageUrl: target.imageUrl, chatType: target.chatType, forwardRequest: ForwardRequest( sourceChatId: widget.chatId, optimistic: optimistic, ), ), ); } Future _forwardIntoCurrentChat(List sources) async { final now = DateTime.now().millisecondsSinceEpoch; final optimistic = []; var i = 0; for (final src in sources) { final msg = MessagesModule.buildForwardMessage( myId: _myId, targetChatId: widget.chatId, sourceChatId: widget.chatId, source: src, tempId: _nextTempId(), time: now + i, status: 'sending', ); optimistic.add(msg); _messages.add(msg); unawaited(_persistOutgoing(msg)); i++; } _bumpMessages(); Haptics.send(); _scrollToBottom(); if (optimistic.isNotEmpty) { final last = optimistic.last; unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: last.id, time: last.time, text: MessagesModule.forwardPreviewText(last), status: 'sending', ), ); } for (final opt in optimistic) { await _sendOneForward(opt, widget.chatId); } } Future> _seedForwardsToChat( ForwardTarget target, List sources, ) async { await chats.ensureChatCached(api, _myId, target.chatId); final now = DateTime.now().millisecondsSinceEpoch; final optimistic = []; var i = 0; for (final src in sources) { final msg = MessagesModule.buildForwardMessage( myId: _myId, targetChatId: target.chatId, sourceChatId: widget.chatId, source: src, tempId: _nextTempId(), time: now + i, status: 'sending', ); optimistic.add(msg); await AppDatabase.saveMessages([msg.toDbRow()]); i++; } final cached = MessageSessionCache.get(_myId, target.chatId); if (cached != null) { MessageSessionCache.save(_myId, target.chatId, [ ...cached.messages, ...optimistic, ], reachedStart: cached.reachedStart); } if (optimistic.isNotEmpty) { final last = optimistic.last; unawaited( chats.applyOutgoing( _myId, target.chatId, messageId: last.id, time: last.time, text: MessagesModule.forwardPreviewText(last), status: 'sending', ), ); } return optimistic; } Future _runForwardRequest() async { final req = widget.forwardRequest; if (req == null || _forwardRequestDone) return; _forwardRequestDone = true; for (final opt in req.optimistic) { await _sendOneForward(opt, req.sourceChatId); } } Future _sendOneForward( CachedMessage optimistic, int sourceChatId, ) async { final link = optimistic.payload?['link']; final rawWireId = link is Map ? link['messageId'] : null; final wireId = rawWireId is int ? rawWireId : null; if (wireId == null) return; try { final realId = await messagesModule.forwardMessage( widget.chatId, sourceChatId, wireId, ); if (!mounted) return; final sent = MessagesModule.reidentifyMessage( optimistic, realId.isNotEmpty ? realId : optimistic.id, status: 'sent', ); final index = _messages.indexWhere((m) => m.id == optimistic.id); if (index != -1) { _messages[index] = sent; _bumpMessages(); } unawaited(_persistOutgoing(sent, removeId: optimistic.id)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: sent.id, time: sent.time, text: MessagesModule.forwardPreviewText(sent), status: 'sent', ), ); } catch (_) { final index = _messages.indexWhere((m) => m.id == optimistic.id); if (index != -1 && mounted) { _messages.removeAt(index); _bumpMessages(); } unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id)); if (mounted) { Haptics.error(); showCustomNotification(context, 'Не удалось переслать'); } } } Widget _buildComposerArea(BuildContext context) { final cs = Theme.of(context).colorScheme; final content = Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _selectionAnim, builder: (context, child) { final t = Curves.easeOut.transform( _selectionAnim.value.clamp(0.0, 1.0), ); if (t == 0) return child!; if (t == 1) return const SizedBox.shrink(); return ClipRect( child: Align( alignment: Alignment.topCenter, heightFactor: 1 - t, child: Transform.translate( offset: Offset(0, 48 * t), child: Opacity(opacity: 1 - t, child: child), ), ), ); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _attachAnim, builder: (context, _) { if (_attachAnim.value == 0) { return const SizedBox.shrink(); } final curve = _attachAnim.status == AnimationStatus.reverse ? Curves.easeIn : Curves.easeOut; final t = curve.transform(_attachAnim.value); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: ClipRect( child: Align( alignment: Alignment.bottomCenter, heightFactor: t, child: Opacity( opacity: t, child: AttachmentPanel( onClose: () => _showAttachmentPanel.value = false, onPickFile: _pickAndUploadFile, onSendById: _sendFileById, ), ), ), ), ); }, ), AnimatedBuilder( animation: _stickers.anim, builder: (context, _) => ComposerInputBar( bottomSafe: _stickers.anim.value == 0, chatType: _commentsMode ? 'CHAT' : widget.chatType, chrome: _effectiveChrome, style: AppComposerStyle.current.value, background: AppComposerBackground.current.value, backdropKey: _pillBackdrop, attachAnim: _attachAnim, replyTo: _replyTo, myId: _myId, hasText: _hasText, uploadStatus: _uploadStatus, messageController: _messageController, messageFocusNode: _messageFocusNode, voiceRec: _voiceRec, note: _note, onToggleStickerPanel: _toggleStickerPanel, onSendText: _sendMessage, onScheduleMessage: _scheduleMessage, onOpenAttach: _openAttachmentSheet, onOpenAttachScheduled: _openAttachmentSheetScheduled, onSendHistory: _sendHistoryFile, onCancelReply: _cancelReply, formatElapsed: formatVoiceElapsed, contextMenuBuilder: (ctx, state) => _formatContextMenu(_messageController, ctx, state), isMuted: chat?.isMuted ?? false, onToggleMute: _toggleChatMute, channelSubscribed: !_previewChat, channelSubscribing: _subscribing, onSubscribe: _subscribeChannel, showStickerButton: !_commentsMode, showAttachButton: !_commentsMode, forceSend: _commentsMode, hintText: _commentsMode ? 'Комментарий' : 'Message', ), ), StickerPanelView( stickers: _stickers, onStickerTap: _sendSticker, onEmojiTap: _insertAnimoji, ), ], ), ), AnimatedBuilder( animation: _selectionAnim, builder: (context, child) { final t = Curves.easeOut.transform( _selectionAnim.value.clamp(0.0, 1.0), ); if (t == 0) return const SizedBox.shrink(); return ClipRect( child: Align( alignment: Alignment.bottomCenter, heightFactor: t, child: Opacity(opacity: t, child: child), ), ); }, child: ValueListenableBuilder>( valueListenable: _selectedIds, builder: (context, selected, _) => SelectionBottomBar( cs: cs, selected: selected, onReply: _replySelected, onForward: _forwardSelected, ), ), ), ], ); Widget wrapChrome(Widget child) { if (_composerFrosted) { if (AppComposerStyle.current.value != ComposerStyle.materialYou) { return child; } return _FrostedPanel( sigma: AppFrost.sigma, tint: AppFrost.panelTint(cs), border: Border(top: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: child, ); } if (_effectiveChrome != ChatChromeStyle.blur) return child; return _FrostedPanel( tint: AppFrost.blurPanelTint(cs), border: Border(top: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: child, ); } final base = wrapChrome(content); return AnimatedBuilder( animation: _searchAnim, builder: (context, _) { final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); if (s == 0) return base; if (s >= 1) return const SizedBox.shrink(); return ClipRect( child: Align( alignment: Alignment.topCenter, heightFactor: 1 - s, child: Opacity( opacity: 1 - s, child: IgnorePointer(child: base), ), ), ); }, ); } bool _canEditMessage(CachedMessage message) { if (message.senderId != _myId) return false; if (message.id.startsWith('temp_')) return false; if (message.isControl) return false; final status = message.status; if (status == 'sending' || status == 'error') return false; return true; } Future _startEditMessage(CachedMessage message) async { final cs = Theme.of(context).colorScheme; final controller = RichMessageController(text: message.text ?? '') ..setFormatRanges(message.formatRanges); final saved = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (sheetContext) => Padding( padding: EdgeInsets.only( left: 20, right: 20, top: 20, bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Изменить сообщение', style: TextStyle( color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, fontFamily: 'Outfit', ), ), const SizedBox(height: 16), TextField( controller: controller, autofocus: true, minLines: 1, maxLines: 6, style: TextStyle(color: cs.onSurface), contextMenuBuilder: (ctx, state) => _formatContextMenu(controller, ctx, state), decoration: InputDecoration( hintText: 'Текст сообщения', filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), ), ), const SizedBox(height: 20), FilledButton( onPressed: () => Navigator.of(sheetContext).pop(true), child: const Text('Сохранить'), ), ], ), ), ); if (saved != true || !mounted) { controller.dispose(); return; } final content = controller.buildContent(); final rawText = content.text; final newText = rawText.trim(); final elements = _trimmedElements(content.elements, rawText, newText); controller.dispose(); final oldElements = serializeFormatElements( message.formatRanges.where((r) => composerFormats.contains(r.format)), ); if (newText == (message.text ?? '') && _sameElements(elements, oldElements)) { return; } final ok = await messagesModule.editMessage( widget.chatId, message.id, text: newText, elements: elements, ); if (!mounted) return; if (!ok) { Haptics.error(); showCustomNotification(context, 'Не удалось изменить сообщение'); return; } final idx = _messages.indexWhere((m) => m.id == message.id); if (idx != -1) { final old = _messages[idx]; final newHistory = KometSettings.viewRedacted.value ? CachedMessage.appendEditHistory( old.editHistory, old.text, DateTime.now().millisecondsSinceEpoch, ) : old.editHistory; final edited = CachedMessage( id: old.id, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, text: newText.isEmpty ? null : newText, time: old.time, status: 'EDITED', payload: {...?old.payload, 'elements': elements}, attachments: old.attachments, isControl: old.isControl, editHistory: newHistory, ); _messages[idx] = edited; _bumpMessages(); unawaited(_persistOutgoing(edited)); } Haptics.send(); } Future _confirmDeleteMessage(String messageId, bool isMe) async { final isLocalOnly = messageId.startsWith('temp_'); final canForEveryone = isMe && !isLocalOnly; if (isLocalOnly) { _startDeleteAnimation(messageId); return; } final forEveryone = await _showDeleteMessageDialog(canForEveryone); if (forEveryone == null || !mounted) return; final ok = await messagesModule.deleteMessages(widget.chatId, [ messageId, ], forEveryone: forEveryone); if (!mounted) return; if (!ok) { Haptics.error(); showCustomNotification(context, 'Не удалось удалить сообщение'); return; } _startDeleteAnimation(messageId); } void _startDeleteAnimation(String messageId) { if (!_deletingIds.add(messageId)) return; Haptics.tap(); _bumpMessages(); } Future _finalizeDelete(String messageId) async { if (!mounted) return; _deletingIds.remove(messageId); final idx = _messages.indexWhere((m) => m.id == messageId); if (idx != -1) { _messages.removeAt(idx); _reactionNotifiers.remove(messageId)?.dispose(); } _bumpMessages(); try { await AppDatabase.deleteMessage(_myId, widget.chatId, messageId); await chats.reconcileLastMessage(_myId, widget.chatId); } catch (_) {} } Future _showDeleteMessageDialog(bool canForEveryone) { final cs = Theme.of(context).colorScheme; var alsoForEveryone = canForEveryone; return showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setLocalState) { return AlertDialog( backgroundColor: cs.surfaceContainerHigh, title: const Text('Удалить сообщение'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Вы точно хотите удалить это сообщение?', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), ), if (canForEveryone) ...[ const SizedBox(height: 16), InkWell( onTap: () => setLocalState( () => alsoForEveryone = !alsoForEveryone, ), borderRadius: BorderRadius.circular(8), child: Row( children: [ Checkbox( value: alsoForEveryone, onChanged: (v) => setLocalState( () => alsoForEveryone = v ?? false, ), ), Expanded( child: Text( 'Также удалить для ${widget.name}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: cs.onSurface, fontSize: 15, ), ), ), ], ), ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Отмена'), ), TextButton( onPressed: () => Navigator.pop(ctx, canForEveryone && alsoForEveryone), child: Text('Удалить', style: TextStyle(color: cs.error)), ), ], ); }, ); }, ); } void _onMessageEvent(MessageEvent event) { if (!mounted) return; if (_commentsMode) return; switch (event) { case MessageAddedEvent(:final message): if (message.senderId == _myId) return; if (_messages.any((m) => m.id == message.id)) return; final nearBottom = _isNearBottom(); _lastSentId = message.id; _messages.add(message); _bumpMessages(); _clearTyping(message.senderId); Haptics.tap(); if (nearBottom) { _scrollToBottom(); _scheduleReadMarker(); } else { _reapplyPinIfNeeded(); } _prank.checkTrigger(message); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; _messages[idx] = message; _bumpMessages(); case MessageSentEvent(:final tempId, :final message): final idx = _messages.indexWhere((m) => m.id == tempId); if (idx == -1) return; _lastSentId = message.id; _messages[idx] = message; _bumpMessages(); case MessageRemovedEvent(:final messageId): final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; _messages.removeAt(idx); _bumpMessages(); _reactionNotifiers.remove(messageId)?.dispose(); case MessageMarkedDeletedEvent(:final messageId): final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; if (_messages[idx].deleted) return; _messages[idx] = _messages[idx].copyWith(deleted: true); _bumpMessages(); case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): _reactionNotifiers[messageId]?.value = reactionInfo; } } Future _loadOtherPresence() async { if (_myId == 0) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; if (PresenceFetch.live(otherId) != null) return; try { final entry = await PresenceFetch.get(otherId); if (!mounted || entry == null) return; PresenceFetch.apply(otherId, entry); } catch (_) {} } void _onPresenceChanged() { if (!mounted) return; final otherId = _resolveOtherId(); if (otherId == null) return; final p = PresenceFetch.live(otherId); if (p == null) return; _otherStatus = (p['status'] as int?) ?? 0; _otherSeenTime = p['seen'] as int?; _recomputeHeaderStatus(); } void _onVisualStyleChanged() { if (mounted) { setState(() {}); _bumpMessages(); } } void _onContactsChanged() { if (mounted) setState(() {}); } String _headerName() { if (_commentsMode) return AppLocalizations.of(context)!.commentsTitle; if (widget.chatType == 'DIALOG') { final otherId = _resolveOtherId(); if (otherId != null) { final cached = ContactCache.get(otherId); if (cached != null && cached.isNotEmpty) return cached; } } return widget.name; } PreferredSizeWidget _buildAppBar(ColorScheme cs) { final glossy = AppVisualStyle.current.value.glossyChrome; final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); final height = glossy ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! : kToolbarHeight; final chrome = _effectiveChrome; return AppBar( backgroundColor: chrome == ChatChromeStyle.color ? (glossy ? Colors.transparent : cs.surfaceContainerHigh) : Colors.transparent, flexibleSpace: chrome == ChatChromeStyle.blur ? _FrostedPanel( tint: AppFrost.blurPanelTint(cs), border: Border(bottom: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: const SizedBox.expand(), ) : (chrome == ChatChromeStyle.none && !glossy) ? IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ cs.surface, cs.surface, cs.surface.withValues(alpha: 0.0), ], stops: const [0.0, 0.72, 1.0], ), ), child: const SizedBox.expand(), ), ) : (chrome == ChatChromeStyle.transparent && !glossy) ? _FrostedPanel( sigma: AppFrost.sigma, tint: AppFrost.panelTint(cs), border: Border(bottom: AppFrost.hairline(cs)), backdropKey: _barBackdrop, child: const SizedBox.expand(), ) : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), elevation: 0, toolbarHeight: height, automaticallyImplyLeading: false, titleSpacing: 0, centerTitle: false, title: SizedBox( height: height, child: AnimatedBuilder( animation: Listenable.merge([_selectionAnim, _searchAnim]), builder: (context, _) { final t = Curves.easeOut.transform( _selectionAnim.value.clamp(0.0, 1.0), ); final s = Curves.easeOut.transform( _searchAnim.value.clamp(0.0, 1.0), ); return ValueListenableBuilder>( valueListenable: _selectedIds, builder: (context, selected, _) => Stack( fit: StackFit.expand, children: [ if (t < 1 && s < 1) IgnorePointer( ignoring: t > 0.5 || s > 0.5, child: Opacity( opacity: (1 - t) * (1 - s), child: Transform.translate( offset: Offset(0, -height * 0.4 * t), child: ChatHeaderRow( glossy: glossy, frosted: glossy && chrome == ChatChromeStyle.transparent, liquid: _liquidChrome, backdropKey: _pillBackdrop, cs: cs, embedded: widget.embedded, chatId: widget.chatId, name: _headerName(), imageUrl: widget.imageUrl, chatType: widget.chatType, isOfficial: chat?.isOfficial ?? false, myId: _myId, headerStatus: _headerStatusNotifier, scheduledCount: _scheduledCount, otherUnread: _otherUnread, showCall: !_commentsMode && widget.chatType == 'DIALOG' && !_peerIsBot, onClose: widget.onClose, onOpenInfo: _commentsMode ? () {} : _openChatInfo, onOpenScheduled: _openScheduledMessages, onCall: _startCall, onMenu: _commentsMode ? (_) {} : _openChatMenu, ), ), ), ), if (t > 0) IgnorePointer( ignoring: t < 0.5, child: Opacity( opacity: t, child: Transform.translate( offset: Offset(0, height * 0.4 * (1 - t)), child: SelectionTopBar( cs: cs, selected: selected, glossy: glossy, copyMsg: _singleCopyableText(selected), editMsg: _singleEditable(selected), onClear: _clearSelection, onCopy: _copySelected, onEdit: _editSelected, onDelete: _deleteSelected, ), ), ), ), if (s > 0) IgnorePointer( ignoring: s < 0.5, child: Opacity( opacity: s, child: SearchTopBar( cs: cs, glossy: glossy, search: _search, focusNode: _searchFocusNode, onClose: _closeSearch, ), ), ), ], ), ); }, ), ), ); } void _openChatMenu(BuildContext btnContext) { final box = btnContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; final anchorRect = box.localToGlobal(Offset.zero) & box.size; showChatMenu( context: context, anchorRect: anchorRect, items: [ ChatMenuItem( icon: (chat?.isMuted ?? false) ? Symbols.volume_off : Symbols.volume_up, label: (chat?.isMuted ?? false) ? 'Включить уведомления' : 'Отключить уведомления', dividerAfter: true, onTap: _toggleChatMute, ), ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: _openSearch), ChatMenuItem( icon: Symbols.wallpaper, label: 'Изменить обои', onTap: _openWallpaperSheet, ), ChatMenuItem( icon: Symbols.mop, label: 'Очистить историю', onTap: _clearHistory, ), ChatMenuItem( icon: Symbols.delete, label: 'Удалить чат', onTap: _deleteChat, ), ], ); } Future _subscribeChannel() async { if (_subscribing) return; setState(() => _subscribing = true); try { var link = _channelLink; if (link == null || link.isEmpty) { final info = await chats.getChatInfo(api, widget.chatId); link = info?['link'] as String?; } if (link == null || link.isEmpty) { throw const PacketError('Не удалось получить ссылку канала'); } final result = await chats.joinChannel(api, link, _myId); if (!mounted) return; setState(() { _previewChat = false; _subscribing = false; chat = result.chat; _participantsCount = result.subscribersCount ?? ((_participantsCount ?? 0) + 1); }); _recomputeHeaderStatus(); showCustomNotification(context, 'Вы подписались на канал'); } catch (e) { if (!mounted) return; setState(() => _subscribing = false); showCustomNotification( context, e is PacketError ? e.message : 'Не удалось подписаться', ); } } Future _toggleChatMute() async { final current = chat; if (current == null) return; final muted = current.isMuted; final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever; final error = await chats.setChatMute( api, chatId: widget.chatId, dontDisturbUntil: target, ); if (!mounted) return; if (error != null) { showCustomNotification(context, error); return; } setState(() => chat = current.copyWith(dontDisturbUntil: target)); showCustomNotification( context, muted ? 'Уведомления включены' : 'Уведомления отключены', ); } bool _wallpaperListening = false; Future _loadWallpaper() async { await ChatWallpaperStore.instance.load(); if (!mounted) return; if (!_wallpaperListening) { _wallpaperListening = true; ChatWallpaperStore.instance.revision.addListener( _applyEffectiveWallpaper, ); } _applyEffectiveWallpaper(); } void _applyEffectiveWallpaper() { if (!mounted) return; final store = ChatWallpaperStore.instance; final wp = store.get(_myId, widget.chatId) ?? store.get(_myId, kGlobalWallpaperChatId); if (!identical(wp, _wallpaper)) setState(() => _wallpaper = wp); } Future _openWallpaperSheet() async { if (_myId == 0) return; final pick = await showChatWallpaperSheet(context, current: _wallpaper); if (pick == null || !mounted) return; final store = ChatWallpaperStore.instance; switch (pick.type) { case WallpaperPickType.none: await store.clear(_myId, widget.chatId); _applyEffectiveWallpaper(); break; case WallpaperPickType.theme: final theme = pick.theme; if (theme == null) break; await store.setTheme(_myId, widget.chatId, theme.id); _applyEffectiveWallpaper(); break; case WallpaperPickType.gallery: await _pickWallpaperFromGallery(); break; } } Future _pickWallpaperFromGallery() async { final result = await FilePicker.platform.pickFiles( type: FileType.image, withData: true, ); if (result == null || result.files.isEmpty) return; final bytes = result.files.first.bytes; if (bytes == null) { if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); return; } if (!mounted) return; final settings = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes), ), ); if (settings == null || !mounted) return; final wp = await ChatWallpaperStore.instance.setImage( _myId, widget.chatId, bytes, settings: settings, ); if (!mounted) return; if (wp == null) { showCustomNotification(context, 'Не удалось сохранить обои'); return; } _applyEffectiveWallpaper(); } Future _clearHistory() async { final confirmed = await showConfirmDialog( context, title: 'Очистить историю', message: 'Все сообщения в этом чате будут удалены без возможности ' 'восстановления.', confirmLabel: 'Очистить', destructive: true, ); if (!mounted || !confirmed) return; final err = await chats.clearHistory( api, chatId: widget.chatId, lastEventTime: chat?.lastEventTime ?? 0, ); if (!mounted) return; if (err != null) { showCustomNotification(context, err); return; } setState(() { _messages = []; _hasMoreHistory = false; _combinedItemsCache = null; }); _messagesRev.value++; } Future _deleteChat() async { final confirmed = await showConfirmDialog( context, title: 'Удалить чат', message: 'Чат будет удалён вместе со всей перепиской.', confirmLabel: 'Удалить', destructive: true, ); if (!mounted || !confirmed) return; final err = await chats.deleteChat( api, chatId: widget.chatId, lastEventTime: chat?.lastEventTime ?? 0, forAll: false, ); if (!mounted) return; if (err != null) { showCustomNotification(context, err); return; } Navigator.of(context).pop(); } Future _startCall() async { if (widget.chatType != 'DIALOG' || _peerIsBot) { showCustomNotification(context, 'Звонки доступны только в диалогах'); return; } // Звонок уже идёт (возможно, свёрнут) — просто открываем его экран снова. final navigator = Navigator.of(context); final active = CallController.instance.activeSession; if (active != null) { await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: widget.name, avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null, session: active, ), ), ); _onCallScreenClosed(); return; } final peerId = widget.chatId ^ _myId; if (peerId <= 0) return; try { final session = await CallController.instance.startOutgoing(peerId); if (!mounted) return; await navigator.push( MaterialPageRoute( builder: (_) => CallScreen( name: widget.name, avatarUrl: widget.imageUrl.isNotEmpty ? widget.imageUrl : null, session: session, ), ), ); _onCallScreenClosed(); } catch (_) { if (!mounted) return; showCustomNotification(context, 'Не удалось начать звонок'); } } void _onCallScreenClosed() { if (!mounted) return; if (CallController.instance.activeSession != null) return; unawaited(_refreshAfterCall()); } Future _refreshAfterCall() async { await Future.delayed(const Duration(milliseconds: 700)); if (!mounted || _myId == 0) return; try { final serverMessages = await messagesModule.fetchHistory( _myId, widget.chatId, ); if (KometSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch( _myId, widget.chatId, serverMessages, ); } final rows = await AppDatabase.loadMessages( _myId, widget.chatId, limit: 100, onlyVisible: !KometSettings.viewDeleted.value, ); final decoded = await CachedMessage.fromDbRowsAsync(rows); if (mounted) _applyMergedMessages(decoded); } catch (e) { logger.w('Обновление после звонка не удалось: $e'); } } void _seedPresenceFromChat() { if (_otherStatus != 0 || _otherSeenTime != null) return; final otherId = _resolveOtherId(); if (otherId == null) return; final p = PresenceFetch.live(otherId); if (p == null) return; _otherStatus = (p['status'] as int?) ?? 0; _otherSeenTime = p['seen'] as int?; } void _recomputeHeaderStatus() { if (_commentsMode) { _headerStatusNotifier.value = ''; return; } _headerStatusNotifier.value = _headerStatus(); } String _headerStatus() { final conn = connectionStatusLabel(api.state); if (conn != null) return conn; final activity = ChatActivityStore.instance.activity(widget.chatId); if (activity != null) return activity.label; if (widget.chatType == 'CHAT') { final count = _participantsCount ?? chat?.participants.length ?? 0; return '$count участников'; } if (widget.chatType == 'CHANNEL') { final count = _participantsCount ?? chat?.participants.length ?? 0; return '$count подписчиков'; } if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 2 || _otherStatus == 3) return 'Был(-а) недавно'; final s = _otherSeenTime; if (s != null && s > 0) return formatLastSeen(s); return ''; } void _onTyping(Packet packet) { final payload = packet.payload; if (payload is! Map) return; if (payload['chatId'] != widget.chatId) return; final userId = payload['userId']; if (userId is! int || userId == _myId) return; ChatActivityStore.instance.mark( widget.chatId, userId, chatActivityFromType(payload['type']), ); } void _clearTyping(int userId) { ChatActivityStore.instance.clearUser(widget.chatId, userId); } void _onMessageRead(Packet packet) { final payload = packet.payload; if (payload is! Map) return; if (payload['chatId'] != widget.chatId) return; final userId = payload['userId']; if (userId is! int || userId == _myId) return; final mark = payload['mark']; if (mark is! int) return; if (payload['setAsUnread'] == true) return; final c = chat; if (c == null) return; if (c.participants[userId] == mark) return; c.participants[userId] = mark; _syncOtherReadTime(); } static String _formatLabel(TextFormat format) { switch (format) { case TextFormat.strong: return 'Жирный'; case TextFormat.emphasized: return 'Курсив'; case TextFormat.underline: return 'Подчёркнутый'; case TextFormat.strikethrough: return 'Зачёркнутый'; case TextFormat.monospaced: return 'Моноширинный'; case TextFormat.quote: return 'Цитата'; case TextFormat.link: return 'Ссылка'; case TextFormat.animoji: return 'Animoji'; } } Widget _formatContextMenu( RichMessageController controller, BuildContext context, EditableTextState editableState, ) { final selection = controller.selection; final buttonItems = []; if (selection.isValid && !selection.isCollapsed) { for (final format in composerFormats) { final active = controller.isFormatActive(format); buttonItems.add( ContextMenuButtonItem( label: '${active ? '✓ ' : ''}${_formatLabel(format)}', onPressed: () { controller.toggleFormat(format); editableState.hideToolbar(); }, ), ); } } buttonItems.addAll(editableState.contextMenuButtonItems); return AdaptiveTextSelectionToolbar.buttonItems( anchors: editableState.contextMenuAnchors, buttonItems: buttonItems, ); } static bool _sameElements( List> a, List> b, ) { if (a.length != b.length) return false; String canon(List> els) { final copy = [...els] ..sort((x, y) { final t = (x['type'] as String).compareTo(y['type'] as String); return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int); }); return copy .map((e) => '${e['type']}:${e['from']}:${e['length']}') .join(','); } return canon(a) == canon(b); } List> _trimmedElements( List> raw, String rawText, String text, ) { if (raw.isEmpty) return const []; final leading = rawText.length - rawText.trimLeft().length; final result = >[]; for (final element in raw) { var from = (element['from'] as int) - leading; var length = element['length'] as int; if (from < 0) { length += from; from = 0; } if (from >= text.length || length <= 0) continue; if (from + length > text.length) length = text.length - from; if (length <= 0) continue; result.add({...element, 'from': from, 'length': length}); } return result; } Future _sendMessage() async { final content = _messageController.buildContent(); final rawText = content.text; final text = rawText.trim(); if (text.isEmpty || _myId == 0) return; if (AppCommands.current.value && text.startsWith('/')) { final command = findSlashCommand(text); if (command == null) { _messageController.clear(); _hasText.value = false; showCustomNotification(context, 'ТАКОЙ КОМАНДЫ НЕТУ🚨🚨🚨'); return; } if (command.run != null) { final args = commandArgs(text); _messageController.clear(); _hasText.value = false; unawaited(command.run!(_commandContext(args))); return; } } final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; final reply = _replyTo.value; final int? replyId = reply == null ? null : int.tryParse(reply.id); Map? replyPayload; if (reply != null && replyId != null) { replyPayload = { 'link': { 'type': 'REPLY', 'chatId': widget.chatId, 'message': { 'id': replyId, 'sender': reply.senderId, 'text': reply.text, 'time': reply.time, 'attaches': reply.payload?['attaches'] ?? const [], }, }, }; } _replyTo.value = null; final elements = _trimmedElements(content.elements, rawText, text); final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null : {...?replyPayload, if (elements.isNotEmpty) 'elements': elements}; final composed = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: text, time: now, status: online ? 'sending' : 'pending', payload: composedPayload, ); _hasText.value = false; _lastSentId = tempId; _messages.add(composed); _messageController.clear(); if (!_commentsMode && DraftStore.instance.get(_myId, widget.chatId) != null) { unawaited(DraftStore.instance.clear(_myId, widget.chatId)); } _bumpMessages(); if (!_commentsMode) { unawaited(_persistOutgoing(composed)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: tempId, time: now, text: text, status: composed.status ?? 'sending', elements: elements, ), ); } // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. Haptics.send(); _scrollToBottom(); _prank.checkTrigger(composed); if (!online) return; try { final actualId = _commentsMode ? await commentsModule.sendComment( _myId, widget.chatId, widget.commentPostId!, text, replyToMessageId: replyId, elements: elements, ) : await messagesModule.sendMessage( _myId, widget.chatId, text, replyToMessageId: replyId, elements: elements, ); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { final sent = CachedMessage( id: actualId.isNotEmpty ? actualId : tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: text, time: now, status: 'sent', payload: composedPayload, ); _messages[index] = sent; _bumpMessages(); if (!_commentsMode) { unawaited(_persistOutgoing(sent, removeId: tempId)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: sent.id, time: now, text: text, status: 'sent', elements: elements, ), ); } } if (!_commentsMode && chat == null) { unawaited( chats.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; setState(() => chat = list.first); _bumpMessages(); _syncOtherReadTime(); }), ); } } catch (e) { final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { final queued = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: text, time: now, status: 'pending', payload: composedPayload, ); _messages[index] = queued; _bumpMessages(); if (!_commentsMode) { unawaited(_persistOutgoing(queued)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: tempId, time: now, text: text, status: 'pending', elements: elements, ), ); } } } } int? _resolveOtherId() { if (widget.chatType != 'DIALOG' || _myId == 0) return null; final id = widget.chatId ^ _myId; return id > 0 ? id : null; } int _complaintTypeId(String type) { switch (type) { case 'CHANNEL': return 5; case 'CHAT': return 4; default: return 3; } } Future> _loadReportReasons(int typeId) async { final reasons = await ComplaintsModule.reasonsFor(api, typeId); return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList(); } Future _reportMessage( CachedMessage message, int typeId, int reasonId, ) async { final messageIdNum = int.tryParse(message.id); if (messageIdNum == null) { if (mounted) { showCustomNotification(context, 'Не удалось отправить жалобу'); } return false; } final ok = await ComplaintsModule.sendComplaint( api, reasonId: reasonId, typeId: typeId, ids: [messageIdNum], parentId: widget.chatId, ); if (!mounted) return ok; showCustomNotification( context, ok ? 'Жалоба отправлена' : 'Не удалось отправить жалобу', ); return ok; } CachedMessage _replaceMessage( int index, { String? id, String? text, String? status, }) { final old = _messages[index]; final updated = CachedMessage( id: id ?? old.id, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, text: text ?? old.text, time: old.time, status: status ?? old.status, payload: old.payload, attachments: old.attachments, isControl: old.isControl, editHistory: old.editHistory, ); _messages[index] = updated; _bumpMessages(); return updated; } Future _postCommandMessage(String text) async { if (!mounted || _myId == 0) return ''; final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; final composed = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: text, time: now, status: online ? 'sending' : 'pending', ); _messages.add(composed); _bumpMessages(); _scrollToBottom(); unawaited(_persistOutgoing(composed)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: tempId, time: now, text: text, status: composed.status ?? 'sending', ), ); if (!online) return tempId; try { final actualId = await messagesModule.sendMessage( _myId, widget.chatId, text, ); final realId = actualId.isNotEmpty ? actualId : tempId; final i = _messages.indexWhere((m) => m.id == tempId); if (i != -1) { final sent = _replaceMessage(i, id: realId, status: 'sent'); unawaited(_persistOutgoing(sent, removeId: tempId)); unawaited( chats.applyOutgoing( _myId, widget.chatId, messageId: realId, time: now, text: text, status: 'sent', ), ); } return realId; } catch (_) { return tempId; } } Future _updateCommandMessage(String id, String text) async { if (id.isEmpty) return; final i = _messages.indexWhere((m) => m.id == id); if (i != -1) { final edited = _replaceMessage(i, text: text, status: 'EDITED'); unawaited(_persistOutgoing(edited)); } if (!id.startsWith('temp_')) { await messagesModule.editMessage(widget.chatId, id, text: text); } } CommandContext _commandContext(String args) => CommandContext( accountId: _myId, chatId: widget.chatId, otherUserId: _resolveOtherId(), args: args, messages: messagesModule, isOnline: () => api.state == SessionState.online, isActive: () => mounted, notify: (message, {duration}) { if (mounted) showCustomNotification(context, message, duration: duration); }, postMessage: _postCommandMessage, updateMessage: _updateCommandMessage, sendPhotos: _sendPhotos, ); Future _scheduleMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; final when = await _pickScheduleTime(); if (when == null || !mounted) return; try { await messagesModule.sendMessage( _myId, widget.chatId, text, scheduledTime: when.millisecondsSinceEpoch, ); if (!mounted) return; _hasText.value = false; _messageController.clear(); Haptics.send(); _markHasScheduled(); showCustomNotification( context, 'Запланировано на ${formatDateTimeWords(when)}', ); } catch (_) { if (!mounted) return; Haptics.error(); showCustomNotification(context, 'Не удалось запланировать сообщение'); } } Future _pickScheduleTime() => showScheduleTimePicker(context); void _openScheduledMessages() { Navigator.of(context) .push( MaterialPageRoute( builder: (_) => ScheduledMessagesScreen( chatId: widget.chatId, accountId: _myId, chatName: widget.name, ), ), ) .then((_) { if (mounted) _refreshScheduledCount(); }); } Future _persistOutgoing(CachedMessage msg, {String? removeId}) async { try { if (removeId != null && removeId != msg.id) { await AppDatabase.deleteMessage(_myId, widget.chatId, removeId); } await AppDatabase.saveMessages([msg.toDbRow()]); } catch (_) {} } Future _loadGroupSenderNames() async { if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; final unknownIds = {}; for (final msg in _messages) { if (msg.isControl) continue; final id = msg.senderId; if (id == 0 || id == _myId) continue; if (ContactCache.get(id) == null) unknownIds.add(id); } if (unknownIds.isEmpty) return; final resolved = await messagesModule.ensureContactNames(unknownIds); if (resolved && mounted) _bumpMessages(); } Future _loadForwardedSenderNames() async { final forwardIds = {}; for (final msg in _messages) { if (msg.attachments != null) { for (final a in msg.attachments!) { if (a is ForwardedMessageAttachment) { if (a.originalSenderName == null && ContactCache.get(a.originalSenderId) == null) { forwardIds.add(a.originalSenderId); } } } } } if (forwardIds.isEmpty) return; final resolved = {}; for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); if (name != null) { resolved[id] = (name: name, avatar: ContactCache.getAvatar(id)); } } if (resolved.isEmpty || !mounted) return; var anyChanged = false; for (var i = 0; i < _messages.length; i++) { final msg = _messages[i]; final attaches = msg.attachments; if (attaches == null) continue; var msgChanged = false; final newAttaches = attaches.map((a) { if (a is ForwardedMessageAttachment && a.originalSenderName == null && resolved.containsKey(a.originalSenderId)) { final r = resolved[a.originalSenderId]!; msgChanged = true; return ForwardedMessageAttachment( originalSenderId: a.originalSenderId, originalSenderName: r.name, originalSenderAvatar: r.avatar, originalMessageId: a.originalMessageId, originalTime: a.originalTime, originalText: a.originalText, originalChatId: a.originalChatId, originalAttachments: a.originalAttachments, originalContact: a.originalContact, ); } return a; }).toList(); if (!msgChanged) continue; anyChanged = true; _messages[i] = msg.copyWith(attachments: newAttaches); } if (anyChanged) { _bumpMessages(); } } void _scrollToBottom() { _returnStack.clear(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) return; final pos = _scrollController.position; final runway = pos.viewportDimension; final teleport = pos.pixels > runway * _scrollDownTeleportFactor; if (teleport) { _pinnedMessageId = null; _listEpoch++; _jumpCacheExtent.value = _jumpCacheExtentPx; _bumpMessages(); _scrollController.jumpTo(runway); } unawaited( _scrollController .animateTo( pos.minScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ) .whenComplete(() { if (!teleport) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _jumpCacheExtent.value = null; }); }), ); }); } void _updateScrollDownVisible() { if (!_scrollController.hasClients) return; final pos = _scrollController.position; if (_returnStack.isNotEmpty && _isNearBottom() && pos.userScrollDirection != ScrollDirection.idle) { _returnStack.clear(); } final show = pos.pixels >= _scrollDownRevealExtent || _returnStack.isNotEmpty; if (show == _scrollDownVisible) return; _scrollDownVisible = show; if (show) { _scrollDownAnimController.forward(); } else { _scrollDownAnimController.reverse(); } } void _pushReturnAnchor(String messageId) { if (!_scrollController.hasClients) return; final listBox = _listKey.currentContext?.findRenderObject(); final dy = _messageOffsetInList(messageId); final viewportH = listBox is RenderBox ? listBox.size.height : 0.0; final alignment = viewportH > 0 && dy != null ? (dy / viewportH).clamp(0.0, 1.0) : 0.5; _returnStack.add(( id: messageId, pixels: _scrollController.position.pixels, alignment: alignment.toDouble(), )); if (!_scrollDownVisible) { _scrollDownVisible = true; _scrollDownAnimController.forward(); } } void _onScrollDownTap() { if (_returningToAnchor || _navigatingToTarget) return; if (!_scrollController.hasClients) { _scrollToBottom(); return; } final pixels = _scrollController.position.pixels; while (_returnStack.isNotEmpty) { final anchor = _returnStack.removeLast(); if (anchor.pixels < pixels && _messages.any((m) => m.id == anchor.id)) { _returningToAnchor = true; unawaited( _returnToAnchor( anchor, ).whenComplete(() => _returningToAnchor = false), ); return; } } _scrollToBottom(); } Future _returnToAnchor( ({String id, double pixels, double alignment}) anchor, ) async { final pos = _scrollController.position; final runway = pos.viewportDimension; final target = anchor.pixels.clamp( pos.minScrollExtent, pos.maxScrollExtent, ); final distance = (pos.pixels - target).abs(); final far = distance > runway * _scrollDownTeleportFactor; if (!far) { await _scrollController.animateTo( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); if (!mounted) return; await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment); return; } _jumpCacheExtent.value = _jumpCacheExtentPx; if (target + runway < distance) { _listEpoch++; _bumpMessages(); _scrollController.jumpTo(target + runway); await _scrollController.animateTo( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); if (!mounted) return; } await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment); if (!mounted) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _jumpCacheExtent.value = null; }); } bool _isNearBottom() { if (!_scrollController.hasClients) return true; return _scrollController.position.pixels <= 120; } void _startReply(CachedMessage message) { _replyTo.value = message; _messageFocusNode.requestFocus(); } void _cancelReply() { _replyTo.value = null; } void _openSenderProfile(int senderId) { if (senderId == 0 || senderId == _myId) return; unawaited( openContactDialogProfile( context, contactId: senderId, name: ContactCache.get(senderId) ?? 'User #$senderId', avatarUrl: ContactCache.getAvatar(senderId), ), ); } void _openStickerPack(StickerAttachment sticker) { final stickerId = int.tryParse(sticker.stickerId ?? ''); if (stickerId == null) { showCustomNotification(context, 'Стикерпак недоступен'); return; } showStickerPackSheet( context, stickerId: stickerId, knownSetId: int.tryParse(sticker.stickerPackId ?? ''), ); } void _jumpToMessage(String messageId, {String? fromId}) { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { showCustomNotification(context, 'Сообщение не загружено'); return; } if (fromId != null) _pushReturnAnchor(fromId); final key = _keyForMessage(messageId); final ctx = key.currentContext; if (ctx != null) { Scrollable.ensureVisible( ctx, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, alignment: 0.4, ); } else { unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4)); } _highlightTimer?.cancel(); _highlightMessageId.value = messageId; _highlightTimer = Timer(const Duration(milliseconds: 1400), () { if (!mounted) return; if (_highlightMessageId.value == messageId) { _highlightMessageId.value = null; } }); } final Map _messageKeys = {}; GlobalKey _keyForMessage(String messageId) => _messageKeys.putIfAbsent(messageId, () => GlobalKey()); void _openSearch() { if (_search.searchMode.value || _selectionMode) return; _search.searchMode.value = true; _searchAnim.forward(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _search.searchMode.value) _searchFocusNode.requestFocus(); }); } void _closeSearch() { if (!_search.searchMode.value) return; _searchFocusNode.unfocus(); _searchAnim.reverse(); _search.reset(); } Future _navigateToInitialMessage() async { final id = widget.initialMessageId; if (id == null) { _finishTargetNavigation(); return; } await _runGoToMessage(id, widget.initialMessageTime ?? 0); } Future _runGoToMessage(String id, int targetTime) async { await WidgetsBinding.instance.endOfFrame; if (!mounted) return; if (!_messages.any((m) => m.id == id)) { await _loadMessageWindow(id, targetTime); 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(); } ({int min, int max})? _laidOutMessageRange(List items) { int? lo; int? hi; for (var i = 0; i < items.length; i++) { final it = items[i]; if (it is! _MessageItem) continue; final ro = _keyForMessage( it.message.id, ).currentContext?.findRenderObject(); if (ro is RenderBox && ro.attached) { lo ??= i; hi = i; } } if (lo == null) return null; return (min: lo, max: hi!); } Future _scrollToMessagePrecise( String id, { double alignment = 0.32, }) async { if (!mounted || !_scrollController.hasClients) return; if (_messages.indexWhere((m) => m.id == id) == -1) return; _historyAutoloadSuppressCount++; try { var stable = 0; for (var iter = 0; iter < 120; iter++) { if (!mounted || !_scrollController.hasClients) return; final listObj = _listKey.currentContext?.findRenderObject(); final boxObj = _keyForMessage(id).currentContext?.findRenderObject(); final p = _scrollController.position; if (listObj is RenderBox && boxObj is RenderBox && boxObj.attached) { final viewportH = listObj.size.height; final actualTop = boxObj .localToGlobal(Offset.zero, ancestor: listObj) .dy; final desiredTop = alignment * viewportH; final delta = desiredTop - actualTop; final target = (p.pixels + delta).clamp( p.minScrollExtent, p.maxScrollExtent, ); if (delta.abs() <= 2.0 || (target - p.pixels).abs() <= 1.0) { stable++; if (stable >= 4) return; await Future.delayed(const Duration(milliseconds: 60)); 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; final viewportH = listObj is RenderBox ? listObj.size.height : 600.0; var stepMag = viewportH * 0.8; if (stepMag > 700) stepMag = 700; final range = _laidOutMessageRange(items); final step = (range != null && pos > range.max) ? -stepMag : stepMag; final target = (p.pixels + step).clamp( p.minScrollExtent, p.maxScrollExtent, ); if ((target - p.pixels).abs() < 1.0) return; _scrollController.jumpTo(target); await WidgetsBinding.instance.endOfFrame; } } finally { _historyAutoloadSuppressCount--; } } Future _openSearchResult(MessageSearchResult result) async { _closeSearch(); if (_messages.any((m) => m.id == result.id)) { await WidgetsBinding.instance.endOfFrame; if (!mounted) return; _scrollToLoadedMessage(result.id); return; } setState(_beginTargetNavigation); await _runGoToMessage(result.id, result.time); } void _scrollToLoadedMessage( String messageId, { double alignment = 0.4, bool highlight = true, bool notifyIfMissing = true, VoidCallback? onSettled, }) { if (!_scrollController.hasClients) { onSettled?.call(); return; } final items = _buildCombinedItems(); final pos = items.indexWhere( (it) => it is _MessageItem && it.message.id == messageId, ); if (pos == -1) { if (notifyIfMissing) { showCustomNotification(context, 'Сообщение не загружено'); } onSettled?.call(); return; } 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++) { below += items[i] is _MessageItem ? _avgMessageHeight : 44.0; } final maxExtent = _scrollController.position.maxScrollExtent; _scrollController.jumpTo(below.clamp(0.0, maxExtent).toDouble()); } if (highlight) { _highlightTimer?.cancel(); _highlightMessageId.value = messageId; _highlightTimer = Timer(const Duration(milliseconds: 1600), () { if (!mounted) return; if (_highlightMessageId.value == messageId) { _highlightMessageId.value = null; } }); } WidgetsBinding.instance.addPostFrameCallback( (_) => _alignLoadedMessage(messageId, alignment, 0, onSettled: onSettled), ); } void _alignLoadedMessage( String messageId, double alignment, int attempt, { VoidCallback? onSettled, }) { if (!mounted || !_scrollController.hasClients) { onSettled?.call(); return; } final listBox = _listKey.currentContext?.findRenderObject(); final box = _keyForMessage(messageId).currentContext?.findRenderObject(); if (listBox is! RenderBox || box is! RenderBox || !box.attached) { if (attempt >= 8) { onSettled?.call(); return; } WidgetsBinding.instance.addPostFrameCallback( (_) => _alignLoadedMessage( messageId, alignment, attempt + 1, onSettled: onSettled, ), ); return; } final viewportHeight = listBox.size.height; final actualTop = box.localToGlobal(Offset.zero, ancestor: listBox).dy; final desiredTop = alignment.clamp(0.0, 1.0) * viewportHeight; final delta = desiredTop - actualTop; final pos = _scrollController.position; final target = (pos.pixels + delta).clamp( pos.minScrollExtent, pos.maxScrollExtent, ); if (viewportHeight <= 0 || delta.abs() <= 0.5 || (target - pos.pixels).abs() <= 0.5 || attempt >= 8) { onSettled?.call(); return; } _scrollController.jumpTo(target); WidgetsBinding.instance.addPostFrameCallback( (_) => _alignLoadedMessage( messageId, alignment, attempt + 1, onSettled: onSettled, ), ); } String _searchSenderName(int senderId) { if (senderId == _myId) return 'Вы'; final cached = ContactCache.get(senderId); if (cached != null && cached.isNotEmpty) return cached; if (widget.chatType == 'DIALOG') return widget.name; return 'Пользователь'; } String? _searchSenderAvatar(int senderId) { final cached = ContactCache.getAvatar(senderId); if (cached != null && cached.isNotEmpty) return cached; if (senderId != _myId && widget.chatType == 'DIALOG' && widget.imageUrl.isNotEmpty) { return widget.imageUrl; } return null; } int _firstUnreadIndex() { final anchor = _unreadAnchorTime; if (anchor == null) return -1; return _messages.indexWhere((m) => m.time > anchor); } List _buildCombinedItems() { final key = Object.hash( _messagesRev.value, _messages.length, _unreadAnchorTime, ); final cached = _combinedItemsCache; if (cached != null && _combinedItemsKey == key) return cached; final unreadIndex = _firstUnreadIndex(); final List items = []; final Set usedDates = {}; for (int i = 0; i < _messages.length; i++) { final msg = _messages[i]; final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time); final dayMillis = DateTime( msgDate.year, msgDate.month, msgDate.day, ).millisecondsSinceEpoch; bool needSeparator = i == 0; if (!needSeparator) { final prevDate = DateTime.fromMillisecondsSinceEpoch( _messages[i - 1].time, ); final prevDayMillis = DateTime( prevDate.year, prevDate.month, prevDate.day, ).millisecondsSinceEpoch; needSeparator = dayMillis != prevDayMillis; } if (needSeparator) { _separatorKeys.putIfAbsent(dayMillis, () => GlobalKey()); usedDates.add(dayMillis); items.add( _DateSeparatorItem( DateTime.fromMillisecondsSinceEpoch(dayMillis), _separatorKeys[dayMillis]!, ), ); } if (i == unreadIndex) { items.add(const _UnreadSeparatorItem()); } items.add(_MessageItem(msg, i)); } _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); _combinedItemsCache = items; _combinedItemsKey = key; return items; } void _onScrollForDate() { if (!_scrollController.hasClients) return; _floatingDateTimer?.cancel(); _floatingDateTimer = Timer(const Duration(seconds: 1), () { if (mounted) _floatingDateAnimController.reverse(); }); if (_floatingDateScheduled) return; _floatingDateScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _floatingDateScheduled = false; _updateFloatingDate(); }); } void _updateFloatingDate() { if (!mounted || _separatorKeys.isEmpty) return; DateTime? result; final listRenderBox = _listKey.currentContext?.findRenderObject(); if (listRenderBox is! RenderBox) return; _separatorKeys.forEach((dayMillis, gkey) { final ctx = gkey.currentContext; if (ctx == null) return; final box = ctx.findRenderObject(); if (box is! RenderBox) return; final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox); if (pos.dy + box.size.height < 4) { final date = DateTime.fromMillisecondsSinceEpoch(dayMillis); if (result == null || date.isAfter(result!)) { result = date; } } }); if (result == null) return; final bool dateChanged = result != _floatingDate.value; _floatingDate.value = result; if (dateChanged) { _floatingDateAnimController.forward(from: 0); } else { _floatingDateAnimController.forward(); } } String _formatDateLabel(DateTime date) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final d = DateTime(date.year, date.month, date.day); if (d == today) return 'Сегодня'; if (d == yesterday) return 'Вчера'; const months = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', ]; if (date.year == now.year) { return '${date.day} ${months[date.month - 1]}'; } return '${date.day} ${months[date.month - 1]} ${date.year}'; } Widget _buildDateSeparatorWidget( BuildContext context, DateTime date, { Key? key, bool floating = false, }) { final cs = Theme.of(context).colorScheme; return Padding( key: key, padding: EdgeInsets.symmetric(vertical: floating ? 2 : 8), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Text( _formatDateLabel(date), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, fontStyle: floating ? FontStyle.normal : FontStyle.italic, ), ), ), ), ); } Widget _buildUnreadSeparatorWidget(BuildContext context) { final cs = Theme.of(context).colorScheme; final accent = cs.primary; return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), child: Row( children: [ Expanded( child: Container( height: 1.5, decoration: BoxDecoration( color: accent.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(1), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( 'Непрочитанные сообщения', style: TextStyle( color: accent, fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.2, ), ), ), Expanded( child: Container( height: 1.5, decoration: BoxDecoration( color: accent.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(1), ), ), ), ], ), ); } @override Widget build(BuildContext context) { final theme = _prank.active ? _prank.pinkTheme(Theme.of(context)) : Theme.of(context); final cs = theme.colorScheme; final underlap = _effectiveChrome != ChatChromeStyle.color; // TODO: Локализация // TODO: Cклонения final mq = MediaQuery.of(context); final bottomInset = _keyboardReserve > 0 ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; return ListenableBuilder( listenable: Listenable.merge([ _selectedIds, _search.searchMode, _textSelection, ]), builder: (context, child) => PopScope( canPop: _selectedIds.value.isEmpty && !_search.searchMode.value && _textSelection.value == null, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (_search.searchMode.value) { _closeSearch(); } else if (_textSelection.value != null) { _exitTextSelection(); } else { _clearSelection(); } }, child: child!, ), child: MediaQuery( data: mq.copyWith( viewInsets: mq.viewInsets.copyWith(bottom: bottomInset), ), child: Theme( data: theme, child: RepaintBoundary( key: _prank.captureKey, child: ValueListenableBuilder( valueListenable: AppSwipeBackDesktop.current, builder: (context, desktopSwipe, child) => SwipeToPop( enabled: widget.embedded && desktopSwipe, onPop: widget.onClose, child: child!, ), child: AnimatedBuilder( animation: _searchAnim, child: LottieHoldScope( isHeld: _animojiHold, child: underlap ? _buildUnderlapBody() : _buildColorBody(), ), builder: (context, body) => Scaffold( backgroundColor: cs.surface, extendBodyBehindAppBar: underlap, appBar: _buildAppBar(cs), body: body, ), ), ), ), ), ), ); } Widget? _buildPinnedBanner({required bool floating}) { final pinned = chat; if (pinned == null || !pinned.hasPinnedMessage) return null; return _PinnedMessageBanner( text: pinned.pinnedMsgText, isPreview: pinned.pinnedMsgIsPreview, floating: floating, frosted: _effectiveChrome == ChatChromeStyle.transparent, liquid: _liquidChrome, backdropKey: _pillBackdrop, onTap: _jumpToPinnedMessage, onUnpin: pinned.canPinMessages(_myId) ? () => unawaited(_unpinCurrentMessage()) : null, ); } Widget _buildColorBody() { final cs = Theme.of(context).colorScheme; final banner = _buildPinnedBanner(floating: false); final frosted = _composerFrosted; final composer = _MeasureSize( onHeight: (value) => _composerHeight.value = value, child: _buildComposerArea(context), ); return Column( children: [ ?banner, Expanded( child: Stack( fit: StackFit.expand, children: [ if (_wallpaper != null) Positioned.fill( child: ChatWallpaperView(wallpaper: _wallpaper!), ), Positioned.fill(child: _buildMessagesArea()), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( left: 0, right: 0, bottom: frosted ? height : 0, child: CommandPanelView(commandPanel: _commandPanel), ), ), if (frosted) Positioned(left: 0, right: 0, bottom: 0, child: composer), SearchOverlay( cs: cs, searchAnim: _searchAnim, search: _search, onOpenResult: _openSearchResult, senderName: _searchSenderName, senderAvatar: _searchSenderAvatar, ), ], ), ), if (!frosted) composer, ], ); } double _pinnedBannerTop() { final glossy = AppVisualStyle.current.value.glossyChrome; return MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight) - _pinnedBannerLift; } void _resetPinnedBannerHeight() { if (_pinnedBannerHeight.value == 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && chat?.hasPinnedMessage != true) { _pinnedBannerHeight.value = 0; } }); } Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; final vignette = _effectiveChrome == ChatChromeStyle.none; final bannerTop = _pinnedBannerTop(); final banner = _buildPinnedBanner(floating: true); if (banner == null) _resetPinnedBannerHeight(); return Stack( fit: StackFit.expand, children: [ if (_wallpaper != null) Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)), Positioned.fill(child: _buildMessagesArea()), SearchOverlay( cs: cs, searchAnim: _searchAnim, search: _search, onOpenResult: _openSearchResult, senderName: _searchSenderName, senderAvatar: _searchSenderAvatar, ), if (vignette) ...[ Positioned( top: 0, left: 0, right: 0, child: _buildEdgeVignette(cs, top: true), ), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( left: 0, right: 0, bottom: 0, child: _buildEdgeVignette(cs, top: false, height: height), ), ), ], if (banner != null) Positioned( top: bannerTop, left: 8, right: 8, child: _MeasureSize( onHeight: (value) => _pinnedBannerHeight.value = value, child: banner, ), ), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( left: 0, right: 0, bottom: height, child: CommandPanelView(commandPanel: _commandPanel), ), ), Positioned( left: 0, right: 0, bottom: 0, child: Builder( builder: (context) => MediaQuery.removePadding( context: context, removeTop: true, child: _MeasureSize( onHeight: (value) => _composerHeight.value = value, child: _buildComposerArea(context), ), ), ), ), ], ); } Widget _buildEdgeVignette( ColorScheme cs, { required bool top, double? height, }) { final double resolved; if (height != null) { resolved = height; } else { final glossy = AppVisualStyle.current.value.glossyChrome; resolved = MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight); } return IgnorePointer( child: Container( height: resolved, decoration: BoxDecoration( gradient: LinearGradient( begin: top ? Alignment.topCenter : Alignment.bottomCenter, end: top ? Alignment.bottomCenter : Alignment.topCenter, colors: [cs.surface, cs.surface.withValues(alpha: 0.0)], ), ), ), ); } Widget _buildMessagesArea() { final showShimmer = _messages.isEmpty ? _isLoading : (_awaitingPosition || _navigatingToTarget); return Stack( fit: StackFit.expand, children: [ Opacity( opacity: showShimmer ? 0.0 : 1.0, child: NotificationListener( onNotification: (_) { _updateReadMarker(); return false; }, child: _buildMessagesList(), ), ), if (showShimmer) Positioned.fill(child: ShimmerLoading(shimmer: _shimmerController)), ], ); } Widget _buildMessagesList() => _messageListWidget ??= _ChatMessageList(this, key: _messageListKey); EdgeInsets _messagesListPadding(BuildContext context) { if (AppChatChrome.current.value == ChatChromeStyle.color) { return const EdgeInsets.symmetric(vertical: 8); } final topInset = MediaQuery.paddingOf(context).top; return EdgeInsets.only(top: topInset + 8, bottom: 8); } double _floatingDateTop(double pinnedHeight) { if (AppChatChrome.current.value == ChatChromeStyle.color) { final glossy = AppVisualStyle.current.value.glossyChrome; return glossy ? 2 : 4; } if (chat?.hasPinnedMessage == true && pinnedHeight > 0) { return _pinnedBannerTop() + pinnedHeight + 2; } return _pinnedBannerTop() + 2; } Widget _buildLoadMoreIndicator() { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: SmallSpinner(size: 22, color: cs.onSurfaceVariant), ), ); } Widget _buildMessagesListContent() { if (_messages.isEmpty) { return Center( child: Text( 'No messages yet', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); } final items = _buildCombinedItems(); return Stack( key: _listKey, children: [ ValueListenableBuilder( valueListenable: AppCacheExtent.current, builder: (context, userCacheExtent, _) => ValueListenableBuilder( valueListenable: _jumpCacheExtent, builder: (context, jumpExtent, _) { final cacheExtent = jumpExtent != null && jumpExtent < userCacheExtent ? jumpExtent : userCacheExtent; return CustomScrollView( controller: _scrollController, reverse: true, cacheExtent: cacheExtent, slivers: [ SliverPadding( padding: _messagesListPadding(context), sliver: SliverList( key: ValueKey(_listEpoch), delegate: SliverChildBuilderDelegate( (context, index) { if (index == 0) { return ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => SizedBox( height: _composerUnderlap ? height : 0, ), ); } 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 bool isChannelPost = !_commentsMode && (chat?.type ?? widget.chatType) == 'CHANNEL' && !message.isControl; final bool isCommentedPost = _commentsMode && message.id == widget.commentPostId; final bubble = MessageBubble( message: message, isMe: isMe, myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, chatType: _commentsMode ? 'CHAT' : (chat?.type ?? 'CHAT'), chatId: widget.chatId, photoActions: _photoActions, overrideStatus: _effectiveStatus(message), otherReadTime: _otherReadTime, reactionsListenable: _reactionNotifierFor( message, ), uploadProgress: _photoProgressFor(message), onReplyTap: (id) => _jumpToMessage(id, fromId: message.id), onAvatarTap: _openSenderProfile, onStickerTap: _openStickerPack, onReactionTap: message.isControl ? null : (emoji) => _reactToMessage(message, emoji), peerName: widget.name, peerAvatarUrl: widget.imageUrl, senderNameOverride: isCommentedPost ? widget.name : null, senderAvatarOverride: isCommentedPost ? widget.imageUrl : null, textSelection: _textSelection, onExitTextSelection: _exitTextSelection, commentsLabel: isChannelPost ? _commentsLabelFor(message.id) : null, onCommentsTap: isChannelPost ? () => _openComments(message) : null, ); 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), onStartTextSelection: (pos) => _startTextSelection(message, pos), onDelete: () => _confirmDeleteMessage(message.id, 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), loadReadBy: _canShowReadBy(message) ? () => _loadReadBy(message) : null, onReaderTap: _openSenderProfile, loadReportReasons: canReport ? () => _loadReportReasons(reportTypeId) : null, onReport: canReport ? (reasonId) => _reportMessage( message, reportTypeId, reasonId, ) : null, onReact: message.isControl ? null : (emoji) => _reactToMessage(message, emoji), reactions: _reactionNotifierFor(message), 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( 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; }, childCount: items.length + 1 + (_isLoadingMore ? 1 : 0), ), ), ), ], ); }, ), ), ValueListenableBuilder( valueListenable: _pinnedBannerHeight, builder: (context, pinnedHeight, child) => Positioned( top: _floatingDateTop(pinnedHeight), left: 0, right: 0, child: child!, ), child: IgnorePointer( child: ValueListenableBuilder( valueListenable: _floatingDate, builder: (context, date, _) { if (date == null) return const SizedBox.shrink(); return AnimatedBuilder( animation: _floatingDateCurved, builder: (context, child) { final t = _floatingDateCurved.value; return Opacity( opacity: t, child: Transform.scale( scale: 0.82 + 0.18 * t, child: child, ), ); }, child: _buildDateSeparatorWidget( context, date, floating: true, ), ); }, ), ), ), _buildScrollDownButton(), ], ); } Widget _buildScrollDownButton() { final cs = Theme.of(context).colorScheme; final frosted = _effectiveChrome == ChatChromeStyle.transparent; return ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, child) => Positioned( right: 16, bottom: (_composerUnderlap ? height : 0) + 12, child: child!, ), child: AnimatedBuilder( animation: _scrollDownCurved, builder: (context, child) { final t = _scrollDownCurved.value; if (t == 0) return const SizedBox.shrink(); return Opacity( opacity: t, child: Transform.scale(scale: 0.82 + 0.18 * t, child: child), ); }, child: SizedBox( width: 46, height: 46, child: GlossyPill( color: frosted || _liquidChrome ? AppFrost.pillTint(cs) : null, blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null, liquid: _liquidChrome, backdropKey: _pillBackdrop, elevated: true, onTap: _onScrollDownTap, child: Center( child: Icon( Symbols.keyboard_arrow_down, color: cs.onSurface, weight: 500, size: 26, ), ), ), ), ), ); } Uint8List _buildWave(List amps, {int bars = 80}) { final out = Uint8List(bars); if (amps.isEmpty) return out; for (var i = 0; i < bars; i++) { final start = (i * amps.length / bars).floor(); final end = (((i + 1) * amps.length / bars).ceil()).clamp( start + 1, amps.length, ); var peak = 0.0; for (var j = start; j < end; j++) { if (amps[j] > peak) peak = amps[j]; } out[i] = (peak * 120).round().clamp(0, 120); } return out; } Future _sendVoice(File file, int durationMs, List amps) async { if (_myId == 0) { try { await file.delete(); } catch (_) {} return; } final wave = _buildWave(amps); final tempId = _nextTempId(); final progress = ValueNotifier>(const [0]); _photoUploadProgress[tempId] = progress; _messages.add( CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, time: DateTime.now().millisecondsSinceEpoch, status: 'sending', attachments: [AudioAttachment(duration: durationMs)], ), ); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); try { final info = await messagesModule.requestAudioUploadUrl(); if (info == null || info.url.isEmpty) throw Exception('no_url'); final ok = await fileUploader.uploadMediaFile( Uri.parse(info.url), file, onProgress: (sent, total) { if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; }, ); if (!ok) throw Exception('upload_failed'); if (!mounted) { _disposePhotoProgress(tempId); return; } final serverMsg = await messagesModule.sendAudioMessage( widget.chatId, info.token, duration: durationMs, wave: wave, ); if (!mounted) { _disposePhotoProgress(tempId); return; } if (serverMsg == null) throw Exception('send_failed'); final real = CachedMessage.fromPushPayload( _myId, widget.chatId, serverMsg, ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real, removeId: tempId)); } _disposePhotoProgress(tempId); } catch (_) { if (mounted) { _failPhotoMessage(tempId); } else { _disposePhotoProgress(tempId); } } finally { try { await file.delete(); } catch (_) {} } } Future _sendVideoNote(File file, int durationMs) async { if (_myId == 0) { try { await file.delete(); } catch (_) {} return; } final tempId = _nextTempId(); final progress = ValueNotifier>(const [0]); _photoUploadProgress[tempId] = progress; _messages.add( CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, time: DateTime.now().millisecondsSinceEpoch, status: 'sending', attachments: [VideoAttachment(duration: durationMs, videoType: 1)], ), ); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); try { final info = await messagesModule.requestVideoNoteUploadUrl(); if (info == null || info.url.isEmpty) throw Exception('no_url'); final ok = await fileUploader.uploadMediaFile( Uri.parse(info.url), file, onProgress: (sent, total) { if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; }, ); if (!ok) throw Exception('upload_failed'); if (!mounted) { _disposePhotoProgress(tempId); return; } final serverMsg = await messagesModule.sendVideoNoteMessage( widget.chatId, info.token, duration: durationMs, ); if (!mounted) { _disposePhotoProgress(tempId); return; } if (serverMsg == null) throw Exception('send_failed'); final real = CachedMessage.fromPushPayload( _myId, widget.chatId, serverMsg, ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real, removeId: tempId)); } _disposePhotoProgress(tempId); } catch (e) { logger.w('sendVideoNote failed: $e'); if (mounted) { _failPhotoMessage(tempId); final reason = e is PacketError ? '${e.errorKey ?? ''} ${e.message}'.trim() : e is Exception && e.toString().contains('send_failed') ? 'сервер не обработал видео' : e is Exception && e.toString().contains('upload_failed') ? 'загрузка отклонена' : e.toString(); showCustomNotification(context, 'Кружок не отправлен: $reason'); } else { _disposePhotoProgress(tempId); } } finally { try { await file.delete(); } catch (_) {} } } String _addOptimisticFileMessage(FileAttachment attachment) { final now = DateTime.now().millisecondsSinceEpoch; final tempId = _nextTempId(); final msg = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, time: now, status: 'sending', attachments: [attachment], ); _lastSentId = tempId; _messages.add(msg); _bumpMessages(); Haptics.send(); _scrollToBottom(); return tempId; } void _updateFileMessageStatus( String tempId, String status, { FileAttachment? attachment, }) { if (!mounted) return; final idx = _messages.indexWhere((m) => m.id == tempId); if (idx == -1) return; final old = _messages[idx]; _messages[idx] = CachedMessage( id: tempId, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, text: old.text, time: old.time, status: status, payload: old.payload, attachments: attachment != null ? [attachment] : old.attachments, ); _bumpMessages(); } Future _sendHistoryFile(FileHistoryEntry entry) async { final tempId = _addOptimisticFileMessage( FileAttachment( fileId: entry.fileId, fileToken: entry.token, name: entry.filename, size: entry.size, ), ); _showAttachmentPanel.value = false; try { final ok = await messagesModule.sendFileMessage( widget.chatId, entry.fileId, token: entry.token, ); _updateFileMessageStatus(tempId, ok ? 'sent' : 'error'); } catch (_) { _updateFileMessageStatus(tempId, 'error'); } } Future _sendFileById(int fileId) async { final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId)); try { final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); if (!mounted) return ok; if (ok) { FileHistoryCache.add( FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()), ); _updateFileMessageStatus(tempId, 'sent'); _showAttachmentPanel.value = false; } else { _updateFileMessageStatus(tempId, 'error'); showCustomNotification(context, 'Ошибка отправки'); } return ok; } catch (e) { _updateFileMessageStatus(tempId, 'error'); if (mounted) showCustomNotification(context, 'Ошибка: $e'); return false; } } Future _openAttachmentSheetScheduled() async { final when = await _pickScheduleTime(); if (when == null || !mounted) return; await _openAttachmentSheet(scheduledTime: when.millisecondsSinceEpoch); } Future _openAttachmentSheet({int? scheduledTime}) async { final keyboard = MediaQuery.viewInsetsOf(context).bottom; final hadKeyboard = keyboard > 0; if (hadKeyboard) { setState(() => _keyboardReserve = keyboard); } FocusManager.instance.primaryFocus?.unfocus(); await showAttachmentSheet( context, title: widget.name, onSend: scheduledTime == null ? _sendPhotos : (picked, caption) => _sendScheduledPhotos(picked, caption, scheduledTime), onPickFile: scheduledTime == null ? _pickAndUploadFile : () => _pickAndUploadFile(scheduledTime: scheduledTime), onShareLocation: _shareLocation, onCreatePoll: _createPoll, ); if (!mounted || !hadKeyboard) return; _messageFocusNode.requestFocus(); await Future.delayed(const Duration(milliseconds: 350)); if (mounted) setState(() => _keyboardReserve = 0); } Future _sendPhotos(List picked, String caption) async { if (_myId == 0) return; final videos = picked.where((ph) => ph.item.isVideo).toList(); final photos = picked.where((ph) => !ph.item.isVideo).toList(); if (photos.isEmpty && videos.isEmpty) return; for (var i = 0; i < videos.length; i++) { final cap = (photos.isEmpty && i == 0) ? caption : ''; await _sendVideo(videos[i], cap); } if (photos.isEmpty) return; final jobs = <({File file, GalleryItem? item})>[]; final attachments = []; for (final photo in photos) { final edited = photo.editedFile; final file = edited ?? photo.item.localFile ?? await photo.item.originFile(); if (file == null) continue; final dim = edited != null ? await imageFileDimensions(edited) : await photo.item.dimensions(); jobs.add((file: file, item: edited == null ? photo.item : null)); attachments.add( PhotoAttachment(localPath: file.path, width: dim?.$1, height: dim?.$2), ); } if (jobs.isEmpty || !mounted) return; final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final progress = ValueNotifier>( List.filled(jobs.length, 0), ); _photoUploadProgress[tempId] = progress; _messages.add( CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: caption.isEmpty ? null : caption, time: now, status: 'sending', attachments: attachments, ), ); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); try { final tokens = await _uploadPhotos(jobs, progress); if (!mounted) { _disposePhotoProgress(tempId); return; } if (tokens.any((t) => t == null)) { _failPhotoMessage(tempId); return; } progress.value = List.filled(jobs.length, 1); final serverMsg = await messagesModule.sendPhotoMessage( widget.chatId, tokens.cast(), caption: caption.isEmpty ? null : caption, ); if (!mounted) { _disposePhotoProgress(tempId); return; } if (serverMsg == null) { _failPhotoMessage(tempId); return; } final real = CachedMessage.fromPushPayload( _myId, widget.chatId, serverMsg, ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real)); } _disposePhotoProgress(tempId); } catch (e) { if (mounted) { _failPhotoMessage(tempId); } else { _disposePhotoProgress(tempId); } } } Future _sendVideo( PickedPhoto video, String caption, { int? scheduledTime, }) async { if (_myId == 0) return; final file = video.editedFile ?? video.item.localFile ?? await video.item.originFile(); if (file == null || !mounted) return; final scheduled = scheduledTime != null; final durationMs = video.item.duration?.inMilliseconds; String? tempId; ValueNotifier>? progress; if (scheduled) { showCustomNotification(context, 'Загрузка…'); } else { tempId = _nextTempId(); progress = ValueNotifier>(const [0]); _photoUploadProgress[tempId] = progress; _messages.add( CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, text: caption.isEmpty ? null : caption, time: DateTime.now().millisecondsSinceEpoch, status: 'sending', attachments: [VideoAttachment(duration: durationMs)], ), ); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); } final progressNotifier = progress; try { final info = await messagesModule.requestVideoUploadUrl(); if (info == null || info.url.isEmpty) throw Exception('no_url'); final ok = await fileUploader.uploadVideoFile( Uri.parse(info.url), file, onProgress: progressNotifier == null ? null : (sent, total) { if (total > 0) { progressNotifier.value = [(sent / total).clamp(0.0, 1.0)]; } }, ); if (!ok) throw Exception('upload_failed'); if (!mounted) { if (tempId != null) _disposePhotoProgress(tempId); return; } final serverMsg = await messagesModule.sendVideoMessage( widget.chatId, info.token, caption: caption.isEmpty ? null : caption, scheduledTime: scheduledTime, ); if (!mounted) { if (tempId != null) _disposePhotoProgress(tempId); return; } if (serverMsg == null) throw Exception('send_failed'); if (scheduled) { Haptics.send(); _markHasScheduled(); showCustomNotification( context, 'Запланировано на ' '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', ); } else { final real = CachedMessage.fromPushPayload( _myId, widget.chatId, serverMsg, ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real, removeId: tempId)); } _disposePhotoProgress(tempId!); } } catch (_) { if (!mounted) { if (tempId != null) _disposePhotoProgress(tempId); return; } if (scheduled) { Haptics.error(); showCustomNotification(context, 'Не удалось запланировать видео'); } else { _failPhotoMessage(tempId!); } } } Future _sendScheduledPhotos( List picked, String caption, int scheduledTime, ) async { if (_myId == 0) return; final videos = picked.where((ph) => ph.item.isVideo).toList(); final photos = picked.where((ph) => !ph.item.isVideo).toList(); if (photos.isEmpty && videos.isEmpty) return; for (var i = 0; i < videos.length; i++) { final cap = (photos.isEmpty && i == 0) ? caption : ''; await _sendVideo(videos[i], cap, scheduledTime: scheduledTime); } if (photos.isEmpty) return; final jobs = <({File file, GalleryItem? item})>[]; for (final photo in photos) { final edited = photo.editedFile; final file = edited ?? photo.item.localFile ?? await photo.item.originFile(); if (file != null) { jobs.add((file: file, item: edited == null ? photo.item : null)); } } if (jobs.isEmpty || !mounted) return; showCustomNotification(context, 'Загрузка…'); final progress = ValueNotifier>( List.filled(jobs.length, 0), ); try { final tokens = await _uploadPhotos(jobs, progress); if (!mounted) return; if (tokens.any((t) => t == null)) { showCustomNotification(context, 'Не удалось загрузить фото'); return; } final result = await messagesModule.sendPhotoMessage( widget.chatId, tokens.cast(), caption: caption.isEmpty ? null : caption, scheduledTime: scheduledTime, ); if (!mounted) return; if (result != null) { Haptics.send(); _markHasScheduled(); showCustomNotification( context, 'Запланировано на ' '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', ); } else { showCustomNotification(context, 'Не удалось запланировать'); } } catch (_) { if (mounted) { Haptics.error(); showCustomNotification(context, 'Ошибка при загрузке'); } } finally { progress.dispose(); } } Future _sendAttachMessage( List optimistic, Future?> Function() send, ) async { if (_myId == 0) return; final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final tempMessage = CachedMessage( id: tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, time: now, status: 'sending', attachments: optimistic, ); _messages.add(tempMessage); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); try { final serverMsg = await send(); if (!mounted) return; final idx = _messages.indexWhere((m) => m.id == tempId); if (idx == -1) return; if (serverMsg == null) { _updateFileMessageStatus(tempId, 'error'); showCustomNotification(context, 'Ошибка отправки'); return; } final real = CachedMessage.fromPushPayload( _myId, widget.chatId, serverMsg, ); _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real, removeId: tempId)); } catch (e) { if (!mounted) return; _updateFileMessageStatus(tempId, 'error'); showCustomNotification(context, 'Ошибка: $e'); } } void _toggleStickerPanel() { if (_stickers.showPanel.value) { _stickers.hide(); if (_keyboardBeforeStickers) _messageFocusNode.requestFocus(); return; } final keyboard = MediaQuery.viewInsetsOf(context).bottom; _keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus; if (keyboard > 120) _stickers.panelHeight = keyboard; FocusManager.instance.primaryFocus?.unfocus(); _stickers.showPanel.value = true; } Future _sendSticker(StickerItem sticker) async { _stickers.hide(); await _sendAttachMessage([ StickerAttachment( stickerId: sticker.id.toString(), baseUrl: sticker.url, lottieUrl: sticker.lottieUrl, width: sticker.width, height: sticker.height, ), ], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id)); } void _insertAnimoji(Animoji animoji) { _messageController.insertAnimoji(animoji); unawaited(animojiModule.noteUsed(animoji)); Haptics.selection(); } Future _shareLocation() async { final position = await _resolveCurrentPosition(); if (position == null || !mounted) return; final lat = position.latitude; final lon = position.longitude; await _sendAttachMessage([ LocationAttachment(latitude: lat, longitude: lon, zoom: 15), ], () => messagesModule.sendLocationMessage(widget.chatId, lat, lon)); } Future _resolveCurrentPosition() async { try { if (!await Geolocator.isLocationServiceEnabled()) { if (mounted) showCustomNotification(context, 'Включите геолокацию'); return null; } var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { if (mounted) showCustomNotification(context, 'Нет доступа к геолокации'); return null; } return await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); } catch (e) { if (mounted) showCustomNotification(context, 'Не удалось получить геопозицию'); return null; } } Future _createPoll() async { final draft = await showCreatePollSheet(context); if (draft == null || !mounted) return; await _sendAttachMessage( [PollAttachment(pollId: 0, title: draft.title)], () => messagesModule.sendPollMessage( widget.chatId, draft.title, draft.answers, multiple: draft.multiple, anonymous: draft.anonymous, ), ); } static const int _photoUploadConcurrency = 3; static const int _photoUploadAttempts = 3; Future> _uploadPhotos( List<({File file, GalleryItem? item})> jobs, ValueNotifier> progress, ) async { final tokens = List.filled(jobs.length, null); var nextIndex = 0; var failed = false; Future worker() async { while (!failed) { final i = nextIndex++; if (i >= jobs.length) return; final token = await _uploadOnePhoto(jobs[i], i, progress); if (token == null) { failed = true; return; } tokens[i] = token; } } final workerCount = math.min(_photoUploadConcurrency, jobs.length); await Future.wait(List.generate(workerCount, (_) => worker())); return tokens; } Future _uploadOnePhoto( ({File file, GalleryItem? item}) job, int index, ValueNotifier> progress, ) async { File file; try { file = await optimizePhotoForUpload(job.file, item: job.item); } catch (e) { logger.w('optimize photo: $e'); file = job.file; } for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) { if (attempt > 0) { await Future.delayed(Duration(seconds: attempt)); if (!mounted) return null; _setPhotoProgress(progress, index, 0); } try { final url = await messagesModule.requestPhotoUploadUrl(); if (url == null || url.isEmpty) continue; final token = await fileUploader.uploadPhoto( Uri.parse(url), file, filename: _photoFilename(file), onProgress: (sent, total) { if (total <= 0) return; _setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0)); }, ); if (token != null) return token; } catch (e) { logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); } } return null; } void _setPhotoProgress( ValueNotifier> progress, int index, double value, ) { final next = List.from(progress.value); if (index < next.length) { next[index] = value; progress.value = next; } } String _photoFilename(File file) { final segments = file.uri.pathSegments; final name = segments.isNotEmpty ? segments.last : ''; return name.isNotEmpty ? name : 'photo.jpg'; } void _failPhotoMessage(String tempId) { final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { final old = _messages[idx]; _messages[idx] = CachedMessage( id: old.id, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, text: old.text, time: old.time, status: 'error', attachments: old.attachments, ); _bumpMessages(); } _disposePhotoProgress(tempId); Haptics.error(); } void _disposePhotoProgress(String tempId) { _photoUploadProgress.remove(tempId)?.dispose(); } Future _pickAndUploadFile({int? scheduledTime}) async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; final file = result.files.first; if (file.path == null) return; _showAttachmentPanel.value = false; _uploadStatus.value = UploadStatus(active: true, total: file.size); final scheduled = scheduledTime != null; final tempId = scheduled ? null : _addOptimisticFileMessage( FileAttachment(name: file.name, size: file.size), ); UploadNotificationService.start(file.name); var notifLastSent = 0; var notifLastMs = DateTime.now().millisecondsSinceEpoch; var notifSpeedBps = 0; var notifLastPercent = -1; void stopNotif() => UploadNotificationService.stop(); _uploadSub?.cancel(); _uploadSub = fileUploader .upload( chatId: widget.chatId, file: File(file.path!), filename: file.name, totalSize: file.size, scheduledTime: scheduledTime, ) .listen( (event) { if (!mounted) return; switch (event) { case UploadProgress(:final sent, :final total): _uploadStatus.value = UploadStatus( active: true, sent: sent, total: total, ); final nowMs = DateTime.now().millisecondsSinceEpoch; final elapsed = nowMs - notifLastMs; if (elapsed >= 500) { notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed) .round(); notifLastSent = sent; notifLastMs = nowMs; } final percent = total > 0 ? (sent * 100 ~/ total) : 0; if (percent != notifLastPercent) { notifLastPercent = percent; UploadNotificationService.update( filename: file.name, progressPercent: percent, speedBps: notifSpeedBps, ); } case UploadDone(:final fileId, :final token, :final url): stopNotif(); FileHistoryCache.add( FileHistoryEntry( fileId: fileId, url: url, token: token, filename: file.name, size: file.size, sentAt: DateTime.now(), ), ); if (scheduled) { Haptics.send(); showCustomNotification( context, 'Запланировано на ' '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', ); } else { _updateFileMessageStatus( tempId!, 'sent', attachment: FileAttachment( fileId: fileId, fileToken: token, name: file.name, size: file.size, ), ); } case UploadError(:final message): stopNotif(); showCustomNotification(context, 'Ошибка: $message'); if (tempId != null) _updateFileMessageStatus(tempId, 'error'); } }, onDone: () { if (!mounted) return; stopNotif(); if (tempId != null) { final inFlight = _messages.firstWhere( (m) => m.id == tempId, orElse: () => CachedMessage( id: '', accountId: 0, chatId: 0, senderId: 0, time: 0, ), ); if (inFlight.id == tempId && inFlight.status == 'sending') { _updateFileMessageStatus(tempId, 'error'); } } _uploadStatus.value = const UploadStatus(); _uploadSub = null; }, onError: (Object e) { if (!mounted) return; stopNotif(); showCustomNotification(context, 'Ошибка: $e'); if (tempId != null) _updateFileMessageStatus(tempId, 'error'); _uploadStatus.value = const UploadStatus(); _uploadSub = null; }, ); } } class _SwipeToReply extends StatefulWidget { final Widget child; final bool isMe; final VoidCallback onReply; const _SwipeToReply({ required this.child, required this.isMe, required this.onReply, }); @override State<_SwipeToReply> createState() => _SwipeToReplyState(); } class _SwipeToReplyState extends State<_SwipeToReply> with SingleTickerProviderStateMixin { static const double _maxDrag = 72.0; static const double _triggerThreshold = 56.0; late final AnimationController _springBack; double _dragX = 0.0; double _springFrom = 0.0; bool _triggered = false; @override void initState() { super.initState(); _springBack = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), )..addListener(() { final t = Curves.easeOut.transform(_springBack.value); setState(() => _dragX = _springFrom * (1 - t)); }); } @override void dispose() { _springBack.dispose(); super.dispose(); } void _onDragUpdate(DragUpdateDetails d) { if (_springBack.isAnimating) _springBack.stop(); var next = _dragX + d.delta.dx; if (next > 0) next = 0; if (next < -_maxDrag) next = -_maxDrag; final wasTriggered = _triggered; _triggered = next <= -_triggerThreshold; if (_triggered && !wasTriggered) Haptics.medium(); setState(() => _dragX = next); } void _onDragEnd(DragEndDetails d) { if (_triggered) widget.onReply(); _triggered = false; _springFrom = _dragX; _springBack.forward(from: 0); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragUpdate: _onDragUpdate, onHorizontalDragEnd: _onDragEnd, child: Stack( alignment: Alignment.centerRight, children: [ Positioned( right: 16, child: Opacity( opacity: progress, child: Transform.scale( scale: 0.6 + 0.4 * progress, child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: Icon(Symbols.reply, size: 20, color: cs.primary), ), ), ), ), Transform.translate(offset: Offset(_dragX, 0), child: widget.child), ], ), ); } } class _PinnedMessageBanner extends StatelessWidget { final String? text; final bool isPreview; final VoidCallback onTap; final VoidCallback? onUnpin; final bool floating; final bool frosted; final bool liquid; final BackdropKey? backdropKey; const _PinnedMessageBanner({ required this.text, required this.isPreview, required this.onTap, this.onUnpin, this.floating = false, this.frosted = false, this.liquid = false, this.backdropKey, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final content = Material( color: frosted ? AppFrost.panelTint(cs) : floating ? cs.surfaceContainerHigh.withValues(alpha: 0.92) : cs.surfaceContainerHigh, borderRadius: floating ? BorderRadius.circular(16) : null, clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ Container( width: 3, height: 34, decoration: BoxDecoration( color: cs.primary, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( AppLocalizations.of(context)!.pinnedMessageTitle, style: TextStyle( color: cs.primary, fontWeight: FontWeight.w600, fontSize: 14, ), ), const SizedBox(height: 2), _PinnedMessageText( text: text, isPreview: isPreview, color: cs.onSurfaceVariant, ), ], ), ), if (onUnpin != null) ...[ const SizedBox(width: 8), IconButton( icon: Icon(Symbols.close, color: cs.onSurfaceVariant), iconSize: 20, visualDensity: VisualDensity.compact, onPressed: onUnpin, ), ], ], ), ), ), ); final bottomBorder = Border(bottom: AppFrost.hairline(cs)); if (frosted) { return GlassSurface( liquid: liquid, borderRadius: floating ? BorderRadius.circular(16) : BorderRadius.zero, frostTint: Colors.transparent, border: floating ? null : bottomBorder, backdropKey: backdropKey, child: content, ); } if (!floating) { return DecoratedBox( decoration: BoxDecoration(border: bottomBorder), child: content, ); } return content; } } class _PinnedMessageText extends StatefulWidget { final String? text; final bool isPreview; final Color color; const _PinnedMessageText({ required this.text, required this.isPreview, required this.color, }); @override State<_PinnedMessageText> createState() => _PinnedMessageTextState(); } class _PinnedMessageTextState extends State<_PinnedMessageText> { late String? _primaryText; late bool _primaryIsPreview; late String? _secondaryText; late bool _secondaryIsPreview; bool _showSecondary = false; @override void initState() { super.initState(); _primaryText = widget.text; _primaryIsPreview = widget.isPreview; _secondaryText = widget.text; _secondaryIsPreview = widget.isPreview; } @override void didUpdateWidget(covariant _PinnedMessageText oldWidget) { super.didUpdateWidget(oldWidget); if (widget.text == oldWidget.text && widget.isPreview == oldWidget.isPreview) { return; } if (_showSecondary) { _primaryText = widget.text; _primaryIsPreview = widget.isPreview; } else { _secondaryText = widget.text; _secondaryIsPreview = widget.isPreview; } _showSecondary = !_showSecondary; } @override Widget build(BuildContext context) { return ClipRect( child: AnimatedTextSwap( showAlternate: _showSecondary, alternate: _buildText(context, _secondaryText, _secondaryIsPreview), child: _buildText(context, _primaryText, _primaryIsPreview), ), ); } Widget _buildText(BuildContext context, String? text, bool isPreview) { final label = text == null || text.isEmpty ? AppLocalizations.of(context)!.msgActionsNoText : text; return Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: widget.color, fontSize: 14, fontStyle: isPreview ? FontStyle.italic : null, ), ); } } class _SelectableMessageRow extends StatefulWidget { final Widget child; final CachedMessage message; final bool isMe; final ValueListenable> selectedIds; final Animation selectionAnim; final bool Function() isSelectionActive; final VoidCallback onToggleSelection; final VoidCallback onEnterSelection; final void Function(Offset globalPosition) onStartTextSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onMarkUnread; final VoidCallback? onPin; final bool Function() isPinned; final Future> Function()? loadReadBy; final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final void Function(String emoji)? onReact; final ValueListenable?>? reactions; const _SelectableMessageRow({ required this.child, required this.message, required this.isMe, required this.selectedIds, required this.selectionAnim, required this.isSelectionActive, required this.onToggleSelection, required this.onEnterSelection, required this.onStartTextSelection, required this.onDelete, this.onEdit, this.onReply, this.onForward, this.onMarkUnread, this.onPin, required this.isPinned, this.loadReadBy, this.onReaderTap, this.loadReportReasons, this.onReport, this.onReact, this.reactions, }); @override State<_SelectableMessageRow> createState() => _SelectableMessageRowState(); } class _SelectableMessageRowState extends State<_SelectableMessageRow> { static const double _gutterWidth = 40; final GlobalKey _boundaryKey = GlobalKey(); Offset? _lastTapDown; Timer? _openTimer; bool _isPinnedNow() => widget.isPinned(); @override void dispose() { _openTimer?.cancel(); super.dispose(); } void _openMenu() { final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); if (renderObject is! RenderRepaintBoundary) return; final origin = renderObject.localToGlobal(Offset.zero); final rect = origin & renderObject.size; final rawDpr = MediaQuery.of(ctx).devicePixelRatio; final dpr = rawDpr > 2.0 ? 2.0 : rawDpr; final ui.Image snapshot; try { snapshot = renderObject.toImageSync(pixelRatio: dpr); } catch (_) { return; } Haptics.tap(); final controller = MessageActionsController(); showMessageActions( context: ctx, snapshot: snapshot, originRect: rect, tapPoint: _lastTapDown ?? rect.center, isMe: widget.isMe, messageText: widget.message.text, controller: controller, style: AppMessageActionsStyle.current.value, interaction: MessageActionsInteraction.tap, editHistory: widget.message.editHistory, loadReadBy: widget.loadReadBy, onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, onPin: widget.onPin, isPinned: _isPinnedNow(), onReact: widget.onReact, selectedReaction: widget.reactions?.value?['yourReaction']?.toString(), quickReactions: _quickReactionEmojis(), loadReactionEmojis: () async { await animojiModule.ensureLoaded(); return _animojiReactionEmojis(); }, onDispose: controller.dispose, ); } List _quickReactionEmojis() { final quick = animojiModule.quickAnimojis; if (quick.isEmpty) { return AnimojiModule.fallbackReactions .map((e) => ReactionEmoji(emoji: e)) .toList(); } return quick.map(_toReactionEmoji).toList(); } List _animojiReactionEmojis() { final list = animojiModule.animojis; if (list.isEmpty) { return AnimojiModule.fallbackReactions .map((e) => ReactionEmoji(emoji: e)) .toList(); } return list.map(_toReactionEmoji).toList(); } ReactionEmoji _toReactionEmoji(Animoji a) => ReactionEmoji( emoji: a.emoji, animationUrl: a.lottieUrl, staticUrl: a.iconUrl, ); void _onSecondaryTapDown(TapDownDetails details) { final ctx = _boundaryKey.currentContext; if (ctx == null) return; final renderObject = ctx.findRenderObject(); if (renderObject is! RenderRepaintBoundary) return; final origin = renderObject.localToGlobal(Offset.zero); final rect = origin & renderObject.size; final controller = MessageActionsController(); showMessageActions( context: ctx, originRect: rect, tapPoint: details.globalPosition, isMe: widget.isMe, messageText: widget.message.text, controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, editHistory: widget.message.editHistory, loadReadBy: widget.loadReadBy, onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, onPin: widget.onPin, isPinned: _isPinnedNow(), onDispose: controller.dispose, ); } void _handleTap() { if (widget.isSelectionActive()) { widget.onToggleSelection(); return; } final react = widget.onReact; if (react != null && (_openTimer?.isActive ?? false)) { _openTimer?.cancel(); _openTimer = null; Haptics.tap(); react('❤️'); return; } _openTimer?.cancel(); _openTimer = Timer(const Duration(milliseconds: 200), () { if (mounted && !widget.isSelectionActive()) _openMenu(); }); } void _handleLongPressStart(Offset globalPosition) { if (!widget.isSelectionActive()) { widget.onEnterSelection(); return; } final selected = widget.selectedIds.value.contains(widget.message.id); final hasText = widget.message.text?.isNotEmpty ?? false; if (selected && hasText && !widget.message.isControl) { widget.onStartTextSelection(globalPosition); } else { widget.onToggleSelection(); } } Widget _buildCheckCircle(bool selected, ColorScheme cs) { return AnimatedContainer( duration: const Duration(milliseconds: 160), curve: Curves.easeOut, width: 24, height: 24, decoration: BoxDecoration( shape: BoxShape.circle, color: selected ? cs.primary : Colors.transparent, border: Border.all( color: selected ? cs.primary : cs.mutedText, width: 2, ), ), child: selected ? Icon(Symbols.check, size: 16, weight: 700, color: cs.onPrimary) : null, ); } @override Widget build(BuildContext context) { if (widget.message.isControl) return widget.child; final cs = Theme.of(context).colorScheme; return AnimatedBuilder( animation: widget.selectionAnim, builder: (context, _) { final t = Curves.easeOut.transform( widget.selectionAnim.value.clamp(0.0, 1.0), ); return ValueListenableBuilder>( valueListenable: widget.selectedIds, builder: (context, selected, _) { final isSelected = selected.contains(widget.message.id); final active = selected.isNotEmpty; return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (d) => _lastTapDown = d.globalPosition, onTap: _handleTap, onLongPressStart: (d) => _handleLongPressStart(d.globalPosition), onSecondaryTapDown: active ? null : _onSecondaryTapDown, child: ColoredBox( color: isSelected ? cs.primary.withValues(alpha: 0.10) : Colors.transparent, child: Stack( children: [ RepaintBoundary( key: _boundaryKey, child: IgnorePointer( ignoring: active, child: Padding( padding: EdgeInsets.only(left: _gutterWidth * t), child: widget.child, ), ), ), if (t > 0) Positioned( left: 8, bottom: 10, child: Opacity( opacity: t, child: _buildCheckCircle(isSelected, cs), ), ), ], ), ), ); }, ); }, ); } } class _DeletingMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; const _DeletingMessageAnimation({ super.key, required this.child, required this.onComplete, }); @override State<_DeletingMessageAnimation> createState() => _DeletingMessageAnimationState(); } class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation _opacity; late final Animation _scale; late final Animation _collapse; bool _fired = false; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 280), ); _opacity = Tween( begin: 1, end: 0, ).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6))); _scale = Tween( begin: 1, end: 0.82, ).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6))); _collapse = Tween(begin: 1, end: 0).animate( CurvedAnimation( parent: _ctrl, curve: const Interval(0.35, 1.0, curve: Curves.easeInOut), ), ); _ctrl.forward().whenComplete(() { if (_fired) return; _fired = true; widget.onComplete(); }); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizeTransition( sizeFactor: _collapse, axisAlignment: 0.0, child: FadeTransition( opacity: _opacity, child: ScaleTransition( scale: _scale, alignment: Alignment.center, child: widget.child, ), ), ); } } class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; const _SentMessageAnimation({ super.key, required this.child, required this.onComplete, }); @override State<_SentMessageAnimation> createState() => _SentMessageAnimationState(); } class _SentMessageAnimationState extends State<_SentMessageAnimation> with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation _opacity; late final Animation _slide; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), ); _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); _slide = Tween( begin: 16, end: 0, ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); _ctrl.forward().whenComplete(widget.onComplete); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _ctrl, builder: (context, child) => Opacity( opacity: _opacity.value, child: Transform.translate( offset: Offset(0, _slide.value), child: child, ), ), child: widget.child, ); } } class _ChatMessageList extends StatefulWidget { final _ChatScreenState host; const _ChatMessageList(this.host, {super.key}); @override State<_ChatMessageList> createState() => _ChatMessageListState(); } class _ChatMessageListState extends State<_ChatMessageList> { @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.host._messagesRev, builder: (context, _, _) => widget.host._buildMessagesListContent(), ); } }