diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index a4fff33..23d7f42 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -63,6 +63,7 @@ class ChatInfoScreen extends StatefulWidget { class _ChatInfoScreenState extends State { final _tabScrollController = ScrollController(); + final _bodyScrollController = ScrollController(); int _myId = 0; bool _isLoading = true; @@ -93,6 +94,7 @@ class _ChatInfoScreenState extends State { @override void dispose() { _tabScrollController.dispose(); + _bodyScrollController.dispose(); super.dispose(); } @@ -251,6 +253,7 @@ class _ChatInfoScreenState extends State { Widget _buildScrollBody(ColorScheme cs) { return CustomScrollView( + controller: _bodyScrollController, slivers: [ SliverAppBar( backgroundColor: Colors.transparent, @@ -762,6 +765,7 @@ class _ChatInfoScreenState extends State { emptyLabel: emptyLabel, emptyIcon: emptyIcon, onGoToMessage: _goToMessage, + scrollController: _bodyScrollController, ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 03ade95..79ae7e5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -313,8 +313,7 @@ class _ChatScreenState extends State final total = counters.values.fold(0, (a, b) => a + b); return { 'counters': [ - for (final key in order) - {'reaction': key, 'count': counters[key]}, + for (final key in order) {'reaction': key, 'count': counters[key]}, ], 'yourReaction': ?your, 'totalCount': total, @@ -2292,7 +2291,10 @@ class _ChatScreenState extends State navigator.popUntil( (r) => r == chatRoute, ); - _requestGoToMessage(messageId, time); + _requestGoToMessage( + messageId, + time, + ); }, ), ), @@ -3371,6 +3373,24 @@ class _ChatScreenState extends State _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, @@ -3381,28 +3401,28 @@ class _ChatScreenState extends State _suppressHistoryAutoload = true; try { var stable = 0; - for (var iter = 0; iter < 48; iter++) { + 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 (boxObj is RenderBox && boxObj.attached && listObj is RenderBox) { + 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 p = _scrollController.position; final target = (p.pixels + delta).clamp( p.minScrollExtent, p.maxScrollExtent, ); - if (delta.abs() <= 4.0 || (target - p.pixels).abs() <= 1.0) { + if (delta.abs() <= 2.0 || (target - p.pixels).abs() <= 1.0) { stable++; - if (stable >= 3) return; - await Future.delayed(const Duration(milliseconds: 130)); + if (stable >= 4) return; + await Future.delayed(const Duration(milliseconds: 60)); continue; } stable = 0; @@ -3417,19 +3437,20 @@ class _ChatScreenState extends State (it) => it is _MessageItem && it.message.id == id, ); if (pos == -1) return; - var below = 0.0; - for (var i = pos + 1; i < items.length; i++) { - below += _estimatedItemExtent(items[i]); - } - final p = _scrollController.position; - final maxExtent = p.maxScrollExtent; - final viewportH = listObj is RenderBox ? listObj.size.height : 500.0; - var targetOffset = below.clamp(0.0, maxExtent).toDouble(); - if ((targetOffset - p.pixels).abs() < 4.0) { - targetOffset = (p.pixels + viewportH * 0.8).clamp(0.0, maxExtent); - if ((targetOffset - p.pixels).abs() < 4.0) return; - } - _scrollController.jumpTo(targetOffset); + + 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 { @@ -3437,48 +3458,6 @@ class _ChatScreenState extends State } } - double _estimatedItemExtent(Object item) { - if (item is! _MessageItem) return 44.0; - final msg = item.message; - var base = 0.0; - final atts = msg.attachments; - if (atts != null && atts.isNotEmpty) { - for (final a in atts) { - switch (a.type) { - case AttachmentType.photo: - case AttachmentType.video: - int? w; - int? h; - if (a is PhotoAttachment) { - w = a.width; - h = a.height; - } else if (a is VideoAttachment) { - w = a.width; - h = a.height; - } - base += (w != null && h != null && w > 0) - ? (236.0 * h / w).clamp(120.0, 360.0) - : 260.0; - base += 12; - case AttachmentType.sticker: - base += 160; - case AttachmentType.audio: - base += 72; - case AttachmentType.file: - base += 80; - case AttachmentType.share: - base += 96; - default: - base += 60; - } - } - } - final textLen = msg.text?.length ?? 0; - if (textLen > 0) base += 24.0 + (textLen ~/ 34) * 20.0; - if (base <= 0) base = _avgMessageHeight; - return base.clamp(44.0, 1200.0).toDouble(); - } - Future _openSearchResult(MessageSearchResult result) async { _closeSearch(); await WidgetsBinding.instance.endOfFrame; @@ -5597,9 +5576,16 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { 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; @@ -5684,7 +5670,10 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { if (widget.isSelectionActive()) { widget.onToggleSelection(); } else { - _openMenu(); + _openTimer?.cancel(); + _openTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted && !widget.isSelectionActive()) _openMenu(); + }); } } diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index aee381d..1b8e11d 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -87,7 +87,11 @@ Widget _emptyState(ColorScheme cs, String label, IconData icon) { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48), + Icon( + icon, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + size: 48, + ), const SizedBox(height: 12), Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), ], @@ -220,7 +224,10 @@ void _notifySave(BuildContext context, MediaSaveResult result) { result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', ); } else { - showCustomNotification(context, 'Не удалось сохранить: ${result.error ?? ''}'); + showCustomNotification( + context, + 'Не удалось сохранить: ${result.error ?? ''}', + ); } } @@ -452,6 +459,7 @@ class SharedMediaTab extends StatefulWidget { final String emptyLabel; final IconData emptyIcon; final void Function(String messageId, int time) onGoToMessage; + final ScrollController? scrollController; const SharedMediaTab({ super.key, @@ -462,6 +470,7 @@ class SharedMediaTab extends StatefulWidget { required this.emptyLabel, required this.emptyIcon, required this.onGoToMessage, + this.scrollController, }); @override @@ -474,15 +483,33 @@ class _SharedMediaTabState extends State { bool _loading = true; bool _loadingMore = false; bool _canLoadMore = false; + int _total = 0; final List _items = []; final Set _seen = {}; @override void initState() { super.initState(); + widget.scrollController?.addListener(_onScroll); _load(widget.anchorMessageId, initial: true); } + @override + void dispose() { + widget.scrollController?.removeListener(_onScroll); + super.dispose(); + } + + void _onScroll() { + if (!_hasMore || _loadingMore || _loading) return; + final controller = widget.scrollController; + if (controller == null || !controller.hasClients) return; + final position = controller.position; + if (position.pixels >= position.maxScrollExtent - 800) { + _loadMore(); + } + } + Future _load(String anchor, {required bool initial}) async { final page = await sharedContentModule.fetchMedia( chatId: widget.chatId, @@ -493,7 +520,6 @@ class _SharedMediaTabState extends State { ); if (!mounted) return; - final pageMessageIds = page.items.map((e) => e.messageId).toSet(); var added = 0; for (final item in page.items) { if (_seen.add(item.dedupKey)) { @@ -502,16 +528,29 @@ class _SharedMediaTabState extends State { } } _items.sort((a, b) => b.time.compareTo(a.time)); + _total = page.total > _total ? page.total : _total; setState(() { - _canLoadMore = added > 0 && pageMessageIds.length >= _pageSize; + _canLoadMore = added > 0 && _items.length < _total; _loading = false; _loadingMore = false; }); + + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoLoad()); + } + + void _maybeAutoLoad() { + if (!mounted || !_hasMore || _loadingMore) return; + final controller = widget.scrollController; + if (controller == null || !controller.hasClients) return; + final position = controller.position; + if (position.maxScrollExtent - position.pixels <= 800) { + _loadMore(); + } } Future _loadMore() async { - if (_loadingMore || _items.isEmpty) return; + if (_loadingMore || _loading || _items.isEmpty) return; setState(() => _loadingMore = true); await _load(_items.last.messageId, initial: false); } @@ -589,7 +628,10 @@ class _SharedMediaTabState extends State { ); } - return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: children); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); } Widget _mediaGrid(ColorScheme cs, List items) { @@ -633,7 +675,9 @@ class _MediaTile extends StatelessWidget { final att = item.attachment; final video = att is VideoAttachment ? att : null; final duration = video?.duration ?? 0; - final thumb = att.baseUrl?.isNotEmpty == true ? att.baseUrl : att.previewData; + final thumb = att.baseUrl?.isNotEmpty == true + ? att.baseUrl + : att.previewData; return GestureDetector( onTap: () => _open(context), @@ -771,7 +815,9 @@ class _FileRow extends StatelessWidget { ), const SizedBox(height: 2), Text( - ext.isEmpty ? formatBytes(size) : '$ext • ${formatBytes(size)}', + ext.isEmpty + ? formatBytes(size) + : '$ext • ${formatBytes(size)}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], @@ -1094,7 +1140,10 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { child: Container( width: 46, height: 46, - decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle), + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), child: _loadingAudio ? const Padding( padding: EdgeInsets.all(13), diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 801a8e4..8cee0f3 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -728,8 +728,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } Rect _reactionAnchorRect() { - if (_effectiveStyle == MessageActionsStyle.list && - _menuRect != Rect.zero) { + if (_effectiveStyle == MessageActionsStyle.list && _menuRect != Rect.zero) { return _menuRect; } if (_buttonHitRects.isNotEmpty) { @@ -749,7 +748,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; final safeTop = padding.top + 8; - final safeBottom = size.height - math.max(padding.bottom, keyboardInset) - 8; + final safeBottom = + size.height - math.max(padding.bottom, keyboardInset) - 8; final quick = widget.quickReactions; const chevronCell = 38.0; @@ -762,8 +762,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final maxPillWidth = size.width - 16; double cell = maxCell; if (pillWidth > maxPillWidth) { - cell = ((maxPillWidth - pillPad * 2 - chevronCell) / quick.length) - .clamp(28.0, maxCell); + cell = ((maxPillWidth - pillPad * 2 - chevronCell) / quick.length).clamp( + 28.0, + maxCell, + ); pillWidth = pillPad * 2 + quick.length * cell + chevronCell; } @@ -777,7 +779,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> double pillTop = pillAbove ? anchor.top - gap - pillHeight : anchor.bottom + gap; - pillTop = pillTop.clamp(safeTop, math.max(safeTop, safeBottom - pillHeight)); + pillTop = pillTop.clamp( + safeTop, + math.max(safeTop, safeBottom - pillHeight), + ); final collapsed = Rect.fromLTWH(pillLeft, pillTop, pillWidth, pillHeight); final panelWidth = math.min(size.width - 24, 300.0); @@ -796,13 +801,16 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> panelTop = math.max(safeTop, panelBottom - desiredHeight); panelHeight = panelBottom - panelTop; } - final expanded = Rect.fromLTWH(panelLeft, panelTop, panelWidth, panelHeight); + final expanded = Rect.fromLTWH( + panelLeft, + panelTop, + panelWidth, + panelHeight, + ); final morph = Rect.lerp(collapsed, expanded, e)!; final radius = ui.lerpDouble(pillHeight / 2, 20.0, e)!; - final entryAlign = pillAbove - ? Alignment.bottomLeft - : Alignment.topLeft; + final entryAlign = pillAbove ? Alignment.bottomLeft : Alignment.topLeft; return IgnorePointer( ignoring: t < 0.5, @@ -810,8 +818,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> opacity: t, child: Stack( children: [ - if (e < 0.999) - _buildCloudTail(collapsed, pillAbove, cs, t, e), + if (e < 0.999) _buildCloudTail(collapsed, pillAbove, cs, t, e), Positioned.fromRect( rect: morph, child: Transform.scale( @@ -979,10 +986,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> child: Opacity( opacity: fade, child: Stack( - children: [ - _tailCircle(big, 8.0, cs), - _tailCircle(small, 5.0, cs), - ], + children: [_tailCircle(big, 8.0, cs), _tailCircle(small, 5.0, cs)], ), ), ); @@ -1429,42 +1433,45 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Column( - children: [ - _buildSearchField(cs), - Expanded( - child: !_loaded - ? const Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.4), + return Material( + type: MaterialType.transparency, + child: Column( + children: [ + _buildSearchField(cs), + Expanded( + child: !_loaded + ? const Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.4), + ), + ) + : _results.isEmpty + ? const SizedBox.shrink() + : GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + addAutomaticKeepAlives: false, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 40, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + itemCount: _results.length, + itemBuilder: (context, i) { + final emoji = _results[i]; + return _EmojiCell( + emoji: emoji, + onTap: () => widget.onPick(emoji), + ); + }, ), - ) - : _results.isEmpty - ? const SizedBox.shrink() - : GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - addAutomaticKeepAlives: false, - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 40, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - ), - itemCount: _results.length, - itemBuilder: (context, i) { - final emoji = _results[i]; - return _EmojiCell( - emoji: emoji, - onTap: () => widget.onPick(emoji), - ); - }, - ), - ), - ], + ), + ], + ), ); } @@ -1533,9 +1540,7 @@ class _EmojiCell extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Center( - child: Text(emoji, style: const TextStyle(fontSize: 22)), - ), + child: Center(child: Text(emoji, style: const TextStyle(fontSize: 22))), ); } }