fix/feat: фикс перехода к медиа, и пагинация

This commit is contained in:
Jganenokk
2026-07-09 16:30:42 +07:00
parent 49d78487b5
commit 3a7ec23d03
4 changed files with 175 additions and 128 deletions
@@ -63,6 +63,7 @@ class ChatInfoScreen extends StatefulWidget {
class _ChatInfoScreenState extends State<ChatInfoScreen> {
final _tabScrollController = ScrollController();
final _bodyScrollController = ScrollController();
int _myId = 0;
bool _isLoading = true;
@@ -93,6 +94,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
@override
void dispose() {
_tabScrollController.dispose();
_bodyScrollController.dispose();
super.dispose();
}
@@ -251,6 +253,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
Widget _buildScrollBody(ColorScheme cs) {
return CustomScrollView(
controller: _bodyScrollController,
slivers: [
SliverAppBar(
backgroundColor: Colors.transparent,
@@ -762,6 +765,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
emptyLabel: emptyLabel,
emptyIcon: emptyIcon,
onGoToMessage: _goToMessage,
scrollController: _bodyScrollController,
);
}
+54 -65
View File
@@ -313,8 +313,7 @@ class _ChatScreenState extends State<ChatScreen>
final total = counters.values.fold<int>(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<ChatScreen>
navigator.popUntil(
(r) => r == chatRoute,
);
_requestGoToMessage(messageId, time);
_requestGoToMessage(
messageId,
time,
);
},
),
),
@@ -3371,6 +3373,24 @@ class _ChatScreenState extends State<ChatScreen>
_finishTargetNavigation();
}
({int min, int max})? _laidOutMessageRange(List<Object> 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<void> _scrollToMessagePrecise(
String id, {
double alignment = 0.32,
@@ -3381,28 +3401,28 @@ class _ChatScreenState extends State<ChatScreen>
_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<ChatScreen>
(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<ChatScreen>
}
}
double _estimatedItemExtent(Object item) {
if (item is! _MessageItem) return 44.0;
final msg = item.message;
var base = 0.0;
final atts = msg.attachments;
if (atts != null && atts.isNotEmpty) {
for (final a in atts) {
switch (a.type) {
case AttachmentType.photo:
case AttachmentType.video:
int? w;
int? h;
if (a is PhotoAttachment) {
w = a.width;
h = a.height;
} else if (a is VideoAttachment) {
w = a.width;
h = a.height;
}
base += (w != null && h != null && w > 0)
? (236.0 * h / w).clamp(120.0, 360.0)
: 260.0;
base += 12;
case AttachmentType.sticker:
base += 160;
case AttachmentType.audio:
base += 72;
case AttachmentType.file:
base += 80;
case AttachmentType.share:
base += 96;
default:
base += 60;
}
}
}
final textLen = msg.text?.length ?? 0;
if (textLen > 0) base += 24.0 + (textLen ~/ 34) * 20.0;
if (base <= 0) base = _avgMessageHeight;
return base.clamp(44.0, 1200.0).toDouble();
}
Future<void> _openSearchResult(MessageSearchResult result) async {
_closeSearch();
await WidgetsBinding.instance.endOfFrame;
@@ -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();
});
}
}
@@ -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<SharedMediaTab> {
bool _loading = true;
bool _loadingMore = false;
bool _canLoadMore = false;
int _total = 0;
final List<SharedMediaItem> _items = [];
final Set<String> _seen = {};
@override
void initState() {
super.initState();
widget.scrollController?.addListener(_onScroll);
_load(widget.anchorMessageId, initial: true);
}
@override
void dispose() {
widget.scrollController?.removeListener(_onScroll);
super.dispose();
}
void _onScroll() {
if (!_hasMore || _loadingMore || _loading) return;
final controller = widget.scrollController;
if (controller == null || !controller.hasClients) return;
final position = controller.position;
if (position.pixels >= position.maxScrollExtent - 800) {
_loadMore();
}
}
Future<void> _load(String anchor, {required bool initial}) async {
final page = await sharedContentModule.fetchMedia(
chatId: widget.chatId,
@@ -493,7 +520,6 @@ class _SharedMediaTabState extends State<SharedMediaTab> {
);
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<SharedMediaTab> {
}
}
_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<void> _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<SharedMediaTab> {
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: children);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
}
Widget _mediaGrid(ColorScheme cs, List<SharedMediaItem> 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),
@@ -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))),
);
}
}