From 359509ec52310328bd948d1102928481c44ca353 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 19:51:58 -0500 Subject: [PATCH] refactor chat interaction handling, improve gesture detection, restore chat delay input indicator --- lib/screens/channel/chat/chat.dart | 131 ++++++++-------- .../channel/chat/widgets/chat_bottom_bar.dart | 141 +++++++++--------- .../channel/chat/widgets/chat_tabs.dart | 17 ++- 3 files changed, 151 insertions(+), 138 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 165f6274..944ba3d7 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -31,18 +31,21 @@ class Chat extends StatelessWidget { return Column( children: [ Expanded( - child: GestureDetector( - onTap: () { - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } else if (chatStore.textFieldFocusNode.hasFocus) { - chatStore.unfocusInput(); - } - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - MediaQuery( + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + // Wrap only the message list with GestureDetector + // so taps on ChatBottomBar don't trigger unfocus + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; + } else if (chatStore.textFieldFocusNode.hasFocus) { + chatStore.unfocusInput(); + } + }, + child: MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: chatStore.settings.messageScale.textScaler, ), @@ -93,68 +96,66 @@ class Chat extends StatelessWidget { ), ), ), - // Prevents accidental chat scrolling when swiping down from the top edge - // to access system UI (Notification Center/Control Center) in landscape mode. - if (context.isLandscape) - Positioned( - top: 0, - left: 0, - right: 0, - height: 24, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onVerticalDragStart: (_) {}, - ), - ), + ), + // Prevents accidental chat scrolling when swiping down from the top edge + // to access system UI (Notification Center/Control Center) in landscape mode. + if (context.isLandscape) Positioned( + top: 0, left: 0, right: 0, - bottom: 0, - child: ChatBottomBar( - chatStore: chatStore, - onAddChat: onAddChat, + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) {}, ), ), - AnimatedPadding( - duration: const Duration(milliseconds: 200), - padding: EdgeInsets.only( - left: 4, - top: 4, - right: 4, - bottom: - 68 + - (chatStore.assetsStore.showEmoteMenu - ? 0 - : MediaQuery.of(context).padding.bottom), - ), - child: Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.autoScroll - ? null - : ElevatedButton.icon( - onPressed: chatStore.resumeScroll, - icon: const Icon( - Icons.arrow_downward_rounded, - ), - label: Text( - chatStore.messageBuffer.isNotEmpty - ? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}' - : 'Resume scroll', - style: const TextStyle( - fontFeatures: [ - FontFeature.tabularFigures(), - ], - ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ChatBottomBar( + chatStore: chatStore, + onAddChat: onAddChat, + ), + ), + AnimatedPadding( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + left: 4, + top: 4, + right: 4, + bottom: + 68 + + (chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom), + ), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.autoScroll + ? null + : ElevatedButton.icon( + onPressed: chatStore.resumeScroll, + icon: const Icon(Icons.arrow_downward_rounded), + label: Text( + chatStore.messageBuffer.isNotEmpty + ? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}' + : 'Resume scroll', + style: const TextStyle( + fontFeatures: [ + FontFeature.tabularFigures(), + ], ), ), - ), + ), ), ), - ], - ), + ), + ], ), ), AnimatedContainer( diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index ff20e7d3..4150c701 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -84,10 +84,10 @@ class ChatBottomBar extends StatelessWidget { final isFullscreenOverlay = chatStore.settings.fullScreen && context.isLandscape; + // Check if chat delay is active (for indicator only, doesn't block input) final hasChatDelay = chatStore.settings.showVideo && chatStore.settings.chatDelay > 0; - final delayTooltipMessage = - 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)'; + const loginTooltipMessage = 'Log in to chat'; final bottomBarContent = Column( @@ -128,40 +128,44 @@ class ChatBottomBar extends StatelessWidget { ), ), ], + // Wrap autocomplete sections with TextFieldTapRegion so taps + // on them don't trigger TextField's onTapOutside callback. if (chatStore.settings.autocomplete && chatStore.showEmoteAutocomplete && matchingEmotes.isNotEmpty) ...[ const Divider(), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.all(4), - itemCount: matchingEmotes.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => InkWell( - onTap: () => chatStore.addEmote( - matchingEmotes[index], - autocompleteMode: true, - ), - onLongPress: () { - HapticFeedback.lightImpact(); + TextFieldTapRegion( + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.all(4), + itemCount: matchingEmotes.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => InkWell( + onTap: () => chatStore.addEmote( + matchingEmotes[index], + autocompleteMode: true, + ), + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: matchingEmotes[index], - launchExternal: chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: matchingEmotes[index].url, - useFade: false, - height: - matchingEmotes[index].height?.toDouble() ?? - defaultEmoteSize, - width: matchingEmotes[index].width?.toDouble(), + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: matchingEmotes[index], + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: matchingEmotes[index].url, + useFade: false, + height: + matchingEmotes[index].height?.toDouble() ?? + defaultEmoteSize, + width: matchingEmotes[index].width?.toDouble(), + ), ), ), ), @@ -173,30 +177,32 @@ class ChatBottomBar extends StatelessWidget { chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[ const Divider(), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.all(4), - itemCount: matchingChatters.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - ), - onPressed: () { - final split = chatStore.textController.text.split(' ') - ..removeLast() - ..add('@${matchingChatters[index]} '); + TextFieldTapRegion( + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.all(4), + itemCount: matchingChatters.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: () { + final split = chatStore.textController.text.split(' ') + ..removeLast() + ..add('@${matchingChatters[index]} '); - chatStore.textController.text = split.join(' '); - chatStore.textController.selection = - TextSelection.fromPosition( - TextPosition( - offset: chatStore.textController.text.length, - ), - ); - }, - child: Text(matchingChatters[index]), + chatStore.textController.text = split.join(' '); + chatStore.textController.selection = + TextSelection.fromPosition( + TextPosition( + offset: chatStore.textController.text.length, + ), + ); + }, + child: Text(matchingChatters[index]), + ), ), ), ), @@ -211,16 +217,14 @@ class ChatBottomBar extends StatelessWidget { context.isLandscape) Builder( builder: (context) { - final isDisabled = - !chatStore.auth.isLoggedIn || hasChatDelay; + final isDisabled = !chatStore.auth.isLoggedIn; return GestureDetector( onTap: isDisabled ? () { - final message = hasChatDelay - ? delayTooltipMessage - : loginTooltipMessage; - chatStore.updateNotification(message); + chatStore.updateNotification( + loginTooltipMessage, + ); } : null, child: IconButton( @@ -242,16 +246,14 @@ class ChatBottomBar extends StatelessWidget { builder: (context) { final isDisabled = !chatStore.auth.isLoggedIn || - chatStore.isSendingMessage || - hasChatDelay; + chatStore.isSendingMessage; return GestureDetector( onTap: isDisabled && !chatStore.isSendingMessage ? () { - final message = hasChatDelay - ? delayTooltipMessage - : loginTooltipMessage; - chatStore.updateNotification(message); + chatStore.updateNotification( + loginTooltipMessage, + ); } : null, child: TextField( @@ -259,7 +261,7 @@ class ChatBottomBar extends StatelessWidget { focusNode: chatStore.textFieldFocusNode, minLines: 1, maxLines: 3, - // Disable text field when sending message, when not logged in, or when chat delay is active + // Disable text field when sending message or when not logged in enabled: !isDisabled, decoration: InputDecoration( prefixIcon: @@ -283,6 +285,8 @@ class ChatBottomBar extends StatelessWidget { ? 'Sending...' : chatStore.replyingToMessage != null ? 'Reply' + : hasChatDelay + ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' : 'Chat' : loginTooltipMessage, ), @@ -317,8 +321,7 @@ class ChatBottomBar extends StatelessWidget { : const Icon(Icons.send_rounded), onPressed: chatStore.auth.isLoggedIn && - !chatStore.isSendingMessage && - !hasChatDelay + !chatStore.isSendingMessage ? () => chatStore.sendMessage( chatStore.textController.text, ) diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 274fe808..3f81faff 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -74,14 +74,23 @@ class ChatTabs extends StatelessWidget { onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // If pressing the back button on Android while the emote menu - // is open, close it instead of going back to the streams list. + // On Android back gesture, close overlays first before navigating back final activeStore = chatTabsStore.activeChatStore; + + // Priority 1: Close emote menu if (activeStore.assetsStore.showEmoteMenu) { activeStore.assetsStore.showEmoteMenu = false; - } else { - Navigator.of(context).pop(); + return; } + + // Priority 2: Unfocus keyboard + if (activeStore.textFieldFocusNode.hasFocus) { + activeStore.unfocusInput(); + return; + } + + // Priority 3: Navigate back + Navigator.of(context).pop(); }, child: Stack( children: [