feat(#289): swap channels in place so the pinned panel stays put

Selecting a channel from the nav panel pushed a replacement route, so the
whole screen was rebuilt: a pinned panel was torn down and re-created on every
switch. Now the conversation swaps in place and the panel stays docked, which
is the point of pinning it.

- The open channel moves from widget.channel to a _currentChannel state field.
- Per-channel binding (mark active, unread divider, jump-to-oldest-unread) is
  extracted from initState into _activateChannel, reused on every switch.
- Per-channel view state (reply target, message keys, unread divider, composer
  text) is cleared on switch so it cannot leak between channels.
- The back stack is untouched, so system back still returns to the channel
  list rather than walking back through visited channels.

Same code path on phone and wide: switching is instant either way, with no
route animation.

flutter analyze clean, dart format applied. Not yet run on hardware.
pull/324/head
Strycher 4 days ago
parent 5c95a768e0
commit e5456aa707

@ -70,6 +70,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
bool _communitiesLoaded = false; bool _communitiesLoaded = false;
/// The channel on screen. Starts as the pushed one, then changes in place
/// when another is picked from the nav panel, so the panel stays docked and
/// only the conversation swaps.
late Channel _currentChannel = widget.channel;
MeshCoreConnector? _connector; MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt; DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false; bool _channelSkipNextBottomSnap = false;
@ -87,38 +92,45 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom); _scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings;
final idx = widget.channel.index;
final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel);
_loadCommunities(); _loadCommunities();
ChannelMessage? anchor; _activateChannel(widget.initialUnreadCount);
if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread);
}
setState(() {
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
});
connector.setActiveChannel(idx);
_connector = connector;
if (anchor != null && settings.jumpToOldestUnread) {
_channelSkipNextBottomSnap = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.jumpToEstimatedOffset(
unreadCount: unread,
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
},
);
});
}
}); });
} }
/// Bind the screen to [_currentChannel]: mark it active, place the unread
/// divider, and optionally jump to the oldest unread. Runs on first build and
/// again each time the channel is switched from the nav panel.
void _activateChannel(int unread) {
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().settings;
final messages = connector.getChannelMessages(_currentChannel);
ChannelMessage? anchor;
if (unread > 0) {
anchor = _findOldestUnreadChannelAnchor(messages, unread);
}
setState(() {
_unreadDividerMessageId = anchor?.messageId;
});
connector.setActiveChannel(_currentChannel.index);
_connector = connector;
if (anchor != null && settings.jumpToOldestUnread) {
_channelSkipNextBottomSnap = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.jumpToEstimatedOffset(
unreadCount: unread,
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
},
);
});
}
}
// TODO: Reload communities when returning from another screen // TODO: Reload communities when returning from another screen
Future<void> _loadCommunities() async { Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
@ -166,7 +178,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isLoadingOlder = true); setState(() => _isLoadingOlder = true);
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
await connector.loadOlderChannelMessages(widget.channel.index); await connector.loadOlderChannelMessages(_currentChannel.index);
if (mounted) { if (mounted) {
setState(() => _isLoadingOlder = false); setState(() => _isLoadingOlder = false);
@ -273,24 +285,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
/// Switch to another channel from the drawer without backing out to the /// Switch to another channel picked from the nav panel.
/// channel list. pushReplacement keeps the back stack one deep, so system ///
/// back still returns to the channel list rather than the previous channel. /// Swaps the conversation in place rather than pushing a route, so a pinned
/// panel stays docked and only the chat area changes. The back stack is
/// untouched, so system back still returns to the channel list.
void _switchChannel(Channel channel) { void _switchChannel(Channel channel) {
if (channel.index == widget.channel.index) { final scaffold = Scaffold.maybeOf(context);
if (scaffold?.isDrawerOpen ?? false) {
Navigator.pop(context); Navigator.pop(context);
return;
} }
if (channel.index == _currentChannel.index) return;
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final unread = connector.getUnreadCountForChannelIndex(channel.index); final unread = connector.getUnreadCountForChannelIndex(channel.index);
connector.markChannelRead(channel.index); connector.markChannelRead(channel.index);
Navigator.pushReplacement(
context, // Drop per-channel view state before rebinding.
MaterialPageRoute( _replyingToMessage = null;
builder: (context) => _messageKeys.clear();
ChannelChatScreen(channel: channel, initialUnreadCount: unread), _unreadDividerMessageId = null;
), _channelSkipNextBottomSnap = false;
); _textController.clear();
setState(() => _currentChannel = channel);
_activateChannel(unread);
} }
@override @override
@ -300,7 +319,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// pinnable here, which is the wide-screen point - keeping the channel // pinnable here, which is the wide-screen point - keeping the channel
// list and its unread counts visible while reading a channel. // list and its unread counts visible while reading a channel.
drawerContent: ChannelDrawerList( drawerContent: ChannelDrawerList(
currentChannelIndex: widget.channel.index, currentChannelIndex: _currentChannel.index,
onChannelSelected: _switchChannel, onChannelSelected: _switchChannel,
), ),
appBarBuilder: (context, pinned) => AppBar( appBarBuilder: (context, pinned) => AppBar(
@ -319,25 +338,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
title: Row( title: Row(
children: [ children: [
_channelIcon(widget.channel), _channelIcon(_currentChannel),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
widget.channel.name.isEmpty _currentChannel.name.isEmpty
? context.l10n.channels_channelIndex( ? context.l10n.channels_channelIndex(
widget.channel.index, _currentChannel.index,
) )
: widget.channel.name, : _currentChannel.name,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Consumer<MeshCoreConnector>( Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final unreadCount = connector final unreadCount = connector
.getUnreadCountForChannelIndex(widget.channel.index); .getUnreadCountForChannelIndex(_currentChannel.index);
final privacy = widget.channel.isPublicChannel final privacy = _currentChannel.isPublicChannel
? context.l10n.channels_public ? context.l10n.channels_public
: context.l10n.channels_private; : context.l10n.channels_private;
return Text( return Text(
@ -361,7 +380,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onSelected: (value) { onSelected: (value) {
if (value == 'clearChat') { if (value == 'clearChat') {
context.read<MeshCoreConnector>().clearMessagesForChannel( context.read<MeshCoreConnector>().clearMessagesForChannel(
widget.channel.index, _currentChannel.index,
); );
} }
}, },
@ -390,7 +409,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Expanded( Expanded(
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(
_currentChannel,
);
final blockService = context.watch<BlockService>(); final blockService = context.watch<BlockService>();
final visibleMessages = messages final visibleMessages = messages
.where( .where(
@ -405,7 +426,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
widget.channel.isPublicChannel _currentChannel.isPublicChannel
? Icons.public ? Icons.public
: Icons.tag, : Icons.tag,
size: 64, size: 64,
@ -536,14 +557,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _markAsUnread(ChannelMessage message) { void _markAsUnread(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(_currentChannel);
var count = 0; var count = 0;
var found = false; var found = false;
for (final m in messages) { for (final m in messages) {
if (m.messageId == message.messageId) found = true; if (m.messageId == message.messageId) found = true;
if (found && !m.isOutgoing) count++; if (found && !m.isOutgoing) count++;
} }
connector.setChannelUnreadCount(widget.channel.index, count); connector.setChannelUnreadCount(_currentChannel.index, count);
} }
/// Block the sender of a channel post. Resolves the claimed name to known /// Block the sender of a channel post. Resolves the claimed name to known
@ -890,7 +911,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurface; : colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel final channelColor = _currentChannel.isPublicChannel
? Colors.orange ? Colors.orange
: Colors.blue; : Colors.blue;
@ -905,7 +926,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final selfName = context.read<MeshCoreConnector>().selfName ?? 'Me'; final selfName = context.read<MeshCoreConnector>().selfName ?? 'Me';
final fromName = isOutgoing ? selfName : senderName; final fromName = isOutgoing ? selfName : senderName;
final key = buildSharedMarkerKey( final key = buildSharedMarkerKey(
sourceId: 'channel:${widget.channel.index}', sourceId: 'channel:${_currentChannel.index}',
label: poi.label, label: poi.label,
fromName: fromName, fromName: fromName,
flags: poi.flags, flags: poi.flags,
@ -1166,13 +1187,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onSubmitted: (_) => _sendMessage(), onSubmitted: (_) => _sendMessage(),
encoder: encoder:
(connector.isChannelSmazEnabled( (connector.isChannelSmazEnabled(
widget.channel.index, _currentChannel.index,
) || ) ||
connector.isChannelCyr2LatEnabled( connector.isChannelCyr2LatEnabled(
widget.channel.index, _currentChannel.index,
)) ))
? (text) => connector.prepareChannelOutboundText( ? (text) => connector.prepareChannelOutboundText(
widget.channel.index, _currentChannel.index,
text, text,
) )
: null, : null,
@ -1214,7 +1235,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Recent senders in this channel, keyed to their most recent timestamp. // Recent senders in this channel, keyed to their most recent timestamp.
final recentTime = <String, DateTime>{}; final recentTime = <String, DateTime>{};
for (final message in connector.getChannelMessages(widget.channel)) { for (final message in connector.getChannelMessages(_currentChannel)) {
if (message.isOutgoing) continue; if (message.isOutgoing) continue;
final name = message.senderName.trim(); final name = message.senderName.trim();
if (name.isEmpty || name == 'Unknown') continue; if (name.isEmpty || name == 'Unknown') continue;
@ -1304,7 +1325,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final maxBytes = maxChannelMessageBytes(connector.selfName); final maxBytes = maxChannelMessageBytes(connector.selfName);
final outboundText = connector.prepareChannelOutboundText( final outboundText = connector.prepareChannelOutboundText(
widget.channel.index, _currentChannel.index,
messageText, messageText,
); );
if (utf8.encode(outboundText).length > maxBytes) { if (utf8.encode(outboundText).length > maxBytes) {
@ -1319,7 +1340,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// but we getting messages doubles in chat screen (source text and transformed). // but we getting messages doubles in chat screen (source text and transformed).
// To prevent, we'll perform transform of source before pass to main sender logic. // To prevent, we'll perform transform of source before pass to main sender logic.
// We can pass whole text, senderName will be kept intact // We can pass whole text, senderName will be kept intact
if (connector.isChannelCyr2LatEnabled(widget.channel.index)) { if (connector.isChannelCyr2LatEnabled(_currentChannel.index)) {
messageText = Cyr2Lat.encode(messageText); messageText = Cyr2Lat.encode(messageText);
} }
// end transform // end transform
@ -1328,7 +1349,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_cancelReply(); _cancelReply();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
connector.sendChannelMessage( connector.sendChannelMessage(
widget.channel, _currentChannel,
messageText, messageText,
originalText: originalText, originalText: originalText,
translatedLanguageCode: translatedLanguageCode, translatedLanguageCode: translatedLanguageCode,
@ -1349,7 +1370,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
var path = _formatPathPrefixes(message.pathBytes, w); var path = _formatPathPrefixes(message.pathBytes, w);
var truncated = false; var truncated = false;
String outbound() => connector.prepareChannelOutboundText( String outbound() => connector.prepareChannelOutboundText(
widget.channel.index, _currentChannel.index,
'$prefix$path${truncated ? '' : ''}', '$prefix$path${truncated ? '' : ''}',
); );
while (path.isNotEmpty && utf8.encode(outbound()).length > maxBytes) { while (path.isNotEmpty && utf8.encode(outbound()).length > maxBytes) {
@ -1358,10 +1379,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
var text = '$prefix$path${truncated ? '' : ''}'; var text = '$prefix$path${truncated ? '' : ''}';
if (connector.isChannelCyr2LatEnabled(widget.channel.index)) { if (connector.isChannelCyr2LatEnabled(_currentChannel.index)) {
text = Cyr2Lat.encode(text); text = Cyr2Lat.encode(text);
} }
connector.sendChannelMessage(widget.channel, text); connector.sendChannelMessage(_currentChannel, text);
} }
void _ensureDateFormats(BuildContext context) { void _ensureDateFormats(BuildContext context) {
@ -1583,7 +1604,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
unawaited( unawaited(
context.read<MeshCoreConnector>().translateChannelMessage( context.read<MeshCoreConnector>().translateChannelMessage(
widget.channel.index, _currentChannel.index,
message, message,
manualTranslation: true, manualTranslation: true,
), ),
@ -1650,7 +1671,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.text, message.text,
); );
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex); final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
connector.sendChannelMessage(widget.channel, reactionText); connector.sendChannelMessage(_currentChannel, reactionText);
} }
void _copyMessageText(String text) { void _copyMessageText(String text) {

Loading…
Cancel
Save

Powered by TurnKey Linux.