feat: скрол к непрочитанным

This commit is contained in:
Jganenokk
2026-07-08 17:09:49 +07:00
parent 431bcedd84
commit eb2f108d2a
2 changed files with 495 additions and 74 deletions
+39 -5
View File
@@ -405,6 +405,40 @@ class ChatsModule {
_bump();
}
Future<void> markReadUpTo(
Api api,
int accountId,
int chatId,
String messageId,
int mark, {
required int remaining,
}) async {
final msgIdNum = int.tryParse(messageId);
if (msgIdNum != null && !KometSettings.antiRead.value) {
try {
await api.sendRequest(Opcode.chatMark, {
'type': 'READ_MESSAGE',
'chatId': chatId,
'messageId': msgIdNum,
'mark': mark,
});
} catch (_) {}
}
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final cached = CachedChat.fromDbRow(rows.first);
final next = remaining < 0 ? 0 : remaining;
final currentMark = cached.participants[accountId] ?? 0;
final nextMark = mark > currentMark ? mark : currentMark;
if (cached.unreadCount == next && nextMark == currentMark) return;
final participants = Map<int, int>.from(cached.participants)
..[accountId] = nextMark;
final updated = cached.copyWith(unreadCount: next, participants: participants);
await AppDatabase.saveChats([updated.toDbRow()]);
_bump();
}
Future<int?> markUnread(Api api, int accountId, int chatId, int mark) async {
int? unread;
try {
@@ -420,11 +454,11 @@ class ChatsModule {
}
if (unread == null) return null;
await _updateChat(
accountId,
chatId,
(chat) => chat.copyWith(unreadCount: unread),
);
await _updateChat(accountId, chatId, (chat) {
final participants = Map<int, int>.from(chat.participants)
..[accountId] = mark - 1;
return chat.copyWith(unreadCount: unread, participants: participants);
});
return unread;
}
+456 -69
View File
@@ -97,6 +97,10 @@ class _MessageItem {
const _MessageItem(this.message, this.index);
}
class _UnreadSeparatorItem {
const _UnreadSeparatorItem();
}
class _FrostedPanel extends StatelessWidget {
final Color tint;
final Border? border;
@@ -199,6 +203,15 @@ class _ChatScreenState extends State<ChatScreen>
double _keyboardReserve = 0;
bool _keyboardWasOpen = false;
final ScrollController _scrollController = ScrollController();
bool _userDidScroll = false;
String? _pinnedMessageId;
double _pinnedAlignment = 0;
int? _unreadAnchorTime;
bool _awaitingPosition = false;
bool _initialPositionDone = false;
bool _positioningInFlight = false;
int _readMarkTime = 0;
Timer? _readMarkTimer;
final GlobalKey _listKey = GlobalKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
@@ -324,7 +337,6 @@ class _ChatScreenState extends State<ChatScreen>
late final CurvedAnimation _floatingDateCurved;
final Map<int, GlobalKey> _separatorKeys = {};
String? _lastSentId;
String? _lastMarkedId;
final ValueNotifier<int> _otherUnread = ValueNotifier(0);
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
@@ -344,6 +356,8 @@ class _ChatScreenState extends State<ChatScreen>
_messageFocusNode.addListener(_onComposerFocusChanged);
_scrollController.addListener(_onScrollForDate);
_scrollController.addListener(_maybeLoadMoreHistory);
_scrollController.addListener(_recordScrollPixels);
_scrollController.addListener(_scheduleReadMarker);
AppVisualStyle.current.addListener(_onVisualStyleChanged);
AppChatChrome.current.addListener(_onVisualStyleChanged);
_shimmerController = AnimationController(
@@ -453,20 +467,21 @@ class _ChatScreenState extends State<ChatScreen>
unawaited(_loadWallpaper());
unawaited(_refreshBadge());
chats
.getChat(_myId, widget.chatId)
.then((value) {
if (mounted && value.isNotEmpty) {
setState(() {
chat = value.first;
});
_bumpMessages();
_seedPresenceFromChat();
_recomputeHeaderStatus();
_syncOtherReadTime();
}
})
.catchError((_) {});
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) {
@@ -474,10 +489,9 @@ class _ChatScreenState extends State<ChatScreen>
_messages = List<CachedMessage>.of(cached.messages);
_hasMoreHistory = !cached.reachedStart;
_messagesRev.value++;
_isLoading = false;
_onLoadingFinished();
});
_syncReactionNotifiersFromMessages();
_revealOrHoldInitial();
return;
}
@@ -495,12 +509,57 @@ class _ChatScreenState extends State<ChatScreen>
setState(() {
_messages = first;
_messagesRev.value++;
_isLoading = false;
_onLoadingFinished();
});
_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) {
@@ -542,20 +601,202 @@ class _ChatScreenState extends State<ChatScreen>
void _onLoadingFinished() {
_shimmerStartTimer?.cancel();
_shimmerStartTimer = null;
if (_shimmerController.isAnimating) _shimmerController.stop();
_markRead();
_applyInitialPositioning();
}
void _markRead() {
if (_myId == 0 || _messages.isEmpty) return;
final newest = _messages.last;
if (newest.id == _lastMarkedId) return;
_lastMarkedId = newest.id;
unawaited(
chats.markRead(api, _myId, widget.chatId, newest.id, newest.time),
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 (_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();
}
Future<void> _loadUntilUnreadReady() async {
var guard = 0;
while (mounted && guard < 80 && _hasMoreHistory) {
if (_unreadAnchorTime == null) _resolveCountBasedAnchor();
final ua = _unreadAnchorTime;
if (ua != null && _messages.indexWhere((m) => m.time > ua) > 0) break;
guard++;
final before = _messages.isEmpty ? 0 : _messages.first.time;
await _loadMoreHistory();
if (!mounted) return;
final after = _messages.isEmpty ? 0 : _messages.first.time;
if (after == before) break;
}
if (!mounted) return;
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 (!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<void> _markMessageUnread(CachedMessage message) async {
final unread = await chats.markUnread(
api,
@@ -818,6 +1059,7 @@ class _ChatScreenState extends State<ChatScreen>
_syncReactionNotifiersFromMessages();
_pruneReactionNotifiers();
_chatController.persistSessionCache();
_reapplyPinIfNeeded();
}
}
@@ -889,6 +1131,9 @@ class _ChatScreenState extends State<ChatScreen>
_messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate);
_scrollController.removeListener(_maybeLoadMoreHistory);
_scrollController.removeListener(_recordScrollPixels);
_scrollController.removeListener(_scheduleReadMarker);
_readMarkTimer?.cancel();
AppVisualStyle.current.removeListener(_onVisualStyleChanged);
AppChatChrome.current.removeListener(_onVisualStyleChanged);
_composerHeight.dispose();
@@ -1754,14 +1999,19 @@ class _ChatScreenState extends State<ChatScreen>
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();
_scrollToBottom();
if (nearBottom) {
_scrollToBottom();
_scheduleReadMarker();
} else {
_reapplyPinIfNeeded();
}
_prank.checkTrigger(message);
_markRead();
case MessageEditedEvent(:final message):
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx == -1) return;
@@ -2853,6 +3103,11 @@ class _ChatScreenState extends State<ChatScreen>
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return true;
return _scrollController.position.pixels <= 120;
}
void _startReply(CachedMessage message) {
_replyTo.value = message;
_messageFocusNode.requestFocus();
@@ -2965,53 +3220,108 @@ class _ChatScreenState extends State<ChatScreen>
_scrollToLoadedMessage(result.id);
}
void _scrollToLoadedMessage(String messageId) {
if (!_scrollController.hasClients) return;
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) {
showCustomNotification(context, 'Сообщение не загружено');
if (notifyIfMissing) {
showCustomNotification(context, 'Сообщение не загружено');
}
onSettled?.call();
return;
}
var below = 0.0;
for (var i = pos + 1; i < items.length; i++) {
below += items[i] is _DateSeparatorItem ? 44.0 : _avgMessageHeight;
}
final maxExtent = _scrollController.position.maxScrollExtent;
final estimate = below.clamp(0.0, maxExtent).toDouble();
_scrollController.jumpTo(estimate);
_highlightTimer?.cancel();
_highlightMessageId.value = messageId;
_highlightTimer = Timer(const Duration(milliseconds: 1600), () {
if (!mounted) return;
if (_highlightMessageId.value == messageId) {
_highlightMessageId.value = null;
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(
(_) => _ensureVisibleRetry(messageId, 0),
(_) => _alignLoadedMessage(messageId, alignment, 0, onSettled: onSettled),
);
}
void _ensureVisibleRetry(String messageId, int attempt) {
if (!mounted) return;
final ctx = _keyForMessage(messageId).currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOut,
alignment: 0.4,
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;
}
if (attempt >= 6) 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(
(_) => _ensureVisibleRetry(messageId, attempt + 1),
(_) => _alignLoadedMessage(
messageId,
alignment,
attempt + 1,
onSettled: onSettled,
),
);
}
@@ -3034,11 +3344,23 @@ class _ChatScreenState extends State<ChatScreen>
return null;
}
int _firstUnreadIndex() {
final anchor = _unreadAnchorTime;
if (anchor == null) return -1;
return _messages.indexWhere((m) => m.time > anchor);
}
List<Object> _buildCombinedItems() {
final key = Object.hash(_messagesRev.value, _messages.length);
final key = Object.hash(
_messagesRev.value,
_messages.length,
_unreadAnchorTime,
);
final cached = _combinedItemsCache;
if (cached != null && _combinedItemsKey == key) return cached;
final unreadIndex = _firstUnreadIndex();
final List<Object> items = [];
final Set<int> usedDates = {};
@@ -3075,6 +3397,10 @@ class _ChatScreenState extends State<ChatScreen>
);
}
if (i == unreadIndex) {
items.add(const _UnreadSeparatorItem());
}
items.add(_MessageItem(msg, i));
}
@@ -3192,6 +3518,48 @@ class _ChatScreenState extends State<ChatScreen>
);
}
Widget _buildUnreadSeparatorWidget(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final accent = cs.primary;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 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
@@ -3280,11 +3648,7 @@ class _ChatScreenState extends State<ChatScreen>
Positioned.fill(
child: ChatWallpaperView(wallpaper: _wallpaper!),
),
Positioned.fill(
child: _isLoading && _messages.isEmpty
? ShimmerLoading(shimmer: _shimmerController)
: _buildMessagesList(),
),
Positioned.fill(child: _buildMessagesArea()),
Positioned(
left: 0,
right: 0,
@@ -3322,9 +3686,7 @@ class _ChatScreenState extends State<ChatScreen>
if (_wallpaper != null)
Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)),
Positioned.fill(
child: _isLoading && _messages.isEmpty
? ShimmerLoading(shimmer: _shimmerController)
: _buildMessagesList(),
child: _buildMessagesArea(),
),
SearchOverlay(
cs: cs,
@@ -3409,6 +3771,27 @@ class _ChatScreenState extends State<ChatScreen>
);
}
Widget _buildMessagesArea() {
final showShimmer = _messages.isEmpty ? _isLoading : _awaitingPosition;
return Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: showShimmer ? 0.0 : 1.0,
child: NotificationListener<ScrollEndNotification>(
onNotification: (_) {
_updateReadMarker();
return false;
},
child: _buildMessagesList(),
),
),
if (showShimmer)
Positioned.fill(child: ShimmerLoading(shimmer: _shimmerController)),
],
);
}
Widget _buildMessagesList() =>
_messageListWidget ??= _ChatMessageList(this, key: _messageListKey);
@@ -3496,6 +3879,10 @@ class _ChatScreenState extends State<ChatScreen>
);
}
if (item is _UnreadSeparatorItem) {
return _buildUnreadSeparatorWidget(context);
}
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;