mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-01 22:50:37 +10:00
fix(chat): fix jump-to-oldest-unread scroll not reaching target message
- Pass initialUnreadCount to chat screens before markRead clears it - Use two-phase scroll: jumpTo estimated offset to build lazy items, then ensureVisible for precise positioning - Await ensureVisible before clearing scroll guard to prevent scrollToBottomIfAtBottom from overriding the animation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,25 @@ class ChatScrollController extends ScrollController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jumps toward an off-screen message so that lazy ListView.builder builds
|
||||||
|
/// items near it. Only visible + cacheExtent items have real heights, so we
|
||||||
|
/// use proportion of maxScrollExtent (itself an estimate from built items'
|
||||||
|
/// avg height). Call [onJumped] on the next frame to ensureVisible/scroll
|
||||||
|
/// to the exact target.
|
||||||
|
void jumpToEstimatedOffset({
|
||||||
|
required int unreadCount,
|
||||||
|
required int totalMessages,
|
||||||
|
required VoidCallback onJumped,
|
||||||
|
}) {
|
||||||
|
if (!hasClients || totalMessages == 0) return;
|
||||||
|
final maxExtent = position.maxScrollExtent;
|
||||||
|
final jumpOffset = maxExtent * (unreadCount / totalMessages);
|
||||||
|
if (jumpOffset > 100) {
|
||||||
|
jumpTo(jumpOffset);
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => onJumped());
|
||||||
|
}
|
||||||
|
|
||||||
void scrollToBottomIfAtBottom() {
|
void scrollToBottomIfAtBottom() {
|
||||||
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
||||||
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ import 'map_screen.dart';
|
|||||||
|
|
||||||
class ChannelChatScreen extends StatefulWidget {
|
class ChannelChatScreen extends StatefulWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
|
final int initialUnreadCount;
|
||||||
|
|
||||||
const ChannelChatScreen({super.key, required this.channel});
|
const ChannelChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.channel,
|
||||||
|
this.initialUnreadCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||||
@@ -66,13 +71,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
final settings = context.read<AppSettingsService>().settings;
|
||||||
final idx = widget.channel.index;
|
final idx = widget.channel.index;
|
||||||
final unread = connector.getUnreadCountForChannelIndex(idx);
|
final unread = widget.initialUnreadCount;
|
||||||
|
final messages = connector.getChannelMessages(widget.channel);
|
||||||
ChannelMessage? anchor;
|
ChannelMessage? anchor;
|
||||||
if (settings.jumpToOldestUnread && unread > 0) {
|
if (settings.jumpToOldestUnread && unread > 0) {
|
||||||
anchor = _findOldestUnreadChannelAnchor(
|
anchor = _findOldestUnreadChannelAnchor(messages, unread);
|
||||||
connector.getChannelMessages(widget.channel),
|
|
||||||
unread,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
connector.setActiveChannel(idx);
|
connector.setActiveChannel(idx);
|
||||||
_connector = connector;
|
_connector = connector;
|
||||||
@@ -80,7 +83,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
_channelSkipNextBottomSnap = true;
|
_channelSkipNextBottomSnap = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_scrollToMessage(anchor!.messageId);
|
_scrollController.jumpToEstimatedOffset(
|
||||||
|
unreadCount: unread,
|
||||||
|
totalMessages: messages.length,
|
||||||
|
onJumped: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
_scrollToMessage(anchor!.messageId);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -492,13 +492,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
final unread =
|
||||||
|
connector.getUnreadCountForChannelIndex(channel.index);
|
||||||
connector.markChannelRead(channel.index);
|
connector.markChannelRead(channel.index);
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ChannelChatScreen(channel: channel),
|
builder: (context) => ChannelChatScreen(
|
||||||
|
channel: channel,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,13 @@ import 'telemetry_screen.dart';
|
|||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
final int initialUnreadCount;
|
||||||
|
|
||||||
const ChatScreen({super.key, required this.contact});
|
const ChatScreen({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
this.initialUnreadCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -75,13 +80,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
final settings = context.read<AppSettingsService>().settings;
|
||||||
final keyHex = widget.contact.publicKeyHex;
|
final keyHex = widget.contact.publicKeyHex;
|
||||||
final unread = connector.getUnreadCountForContactKey(keyHex);
|
final unread = widget.initialUnreadCount;
|
||||||
|
final messages = connector.getMessages(widget.contact);
|
||||||
Message? anchor;
|
Message? anchor;
|
||||||
if (settings.jumpToOldestUnread && unread > 0) {
|
if (settings.jumpToOldestUnread && unread > 0) {
|
||||||
anchor = _findOldestUnreadAnchor(
|
anchor = _findOldestUnreadAnchor(messages, unread);
|
||||||
connector.getMessages(widget.contact),
|
|
||||||
unread,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
connector.setActiveContact(keyHex);
|
connector.setActiveContact(keyHex);
|
||||||
_connector = connector;
|
_connector = connector;
|
||||||
@@ -89,15 +92,24 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
setState(() => _pendingUnreadScrollTarget = anchor);
|
setState(() => _pendingUnreadScrollTarget = anchor);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ctx = _unreadScrollKey.currentContext;
|
_scrollController.jumpToEstimatedOffset(
|
||||||
if (ctx != null) {
|
unreadCount: unread,
|
||||||
Scrollable.ensureVisible(
|
totalMessages: messages.length,
|
||||||
ctx,
|
onJumped: () async {
|
||||||
duration: const Duration(milliseconds: 350),
|
if (!mounted) return;
|
||||||
alignment: 0.15,
|
final ctx = _unreadScrollKey.currentContext;
|
||||||
);
|
if (ctx != null) {
|
||||||
}
|
await Scrollable.ensureVisible(
|
||||||
setState(() => _pendingUnreadScrollTarget = null);
|
ctx,
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
alignment: 0.15,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _pendingUnreadScrollTarget = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1305,11 +1317,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openChat(BuildContext context, Contact contact) {
|
void _openChat(BuildContext context, Contact contact) {
|
||||||
// Check if this is a repeater
|
final connector = context.read<MeshCoreConnector>();
|
||||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
final unread =
|
||||||
|
connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||||
|
connector.markContactRead(contact.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatScreen(
|
||||||
|
contact: contact,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -930,10 +930,18 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
} else if (contact.type == advTypeRoom) {
|
} else if (contact.type == advTypeRoom) {
|
||||||
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
||||||
} else {
|
} else {
|
||||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final unread =
|
||||||
|
connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
||||||
|
connector.markContactRead(contact.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatScreen(
|
||||||
|
contact: contact,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -988,7 +996,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
builder: (context) => RoomLoginDialog(
|
builder: (context) => RoomLoginDialog(
|
||||||
room: room,
|
room: room,
|
||||||
onLogin: (password, isAdmin) {
|
onLogin: (password, isAdmin) {
|
||||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final unread =
|
||||||
|
connector.getUnreadCountForContactKey(room.publicKeyHex);
|
||||||
|
connector.markContactRead(room.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -999,7 +1010,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
password: password,
|
password: password,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
)
|
)
|
||||||
: ChatScreen(contact: room),
|
: ChatScreen(
|
||||||
|
contact: room,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1391,11 +1391,18 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
room: room,
|
room: room,
|
||||||
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
||||||
onLogin: (password, _) {
|
onLogin: (password, _) {
|
||||||
// Navigate to chat screen after successful login
|
final connector = context.read<MeshCoreConnector>();
|
||||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
final unread =
|
||||||
|
connector.getUnreadCountForContactKey(room.publicKeyHex);
|
||||||
|
connector.markContactRead(room.publicKeyHex);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ChatScreen(
|
||||||
|
contact: room,
|
||||||
|
initialUnreadCount: unread,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user