diff --git a/README.md b/README.md index fb2bdf34..4d792a77 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Flutter applications with [Flexible Backend Integration][chatViewConnect]. - One-on-one and group chat support - Message reactions with emoji - Reply to messages functionality +- User mentions/tagging with @ symbol - Link preview for URLs - Voice messages support - Image sharing capabilities diff --git a/doc/documentation.md b/doc/documentation.md index 29f73003..8e0fa704 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -29,6 +29,7 @@ Flutter applications with [Flexible Backend Integration](https://pub.dev/package - One-on-one and group chat support - Message reactions with emoji - Reply to messages functionality +- User mentions/tagging with @ symbol - Link preview - Voice messages - Image sharing @@ -1305,6 +1306,88 @@ textFieldConfig: TextFieldConfiguration( ), ``` +### User Mentions/Tagging + +ChatView now supports user mentions (tagging) with @ symbol, similar to platforms like Slack or WhatsApp. When users type @ in the text field, a searchable list of users appears, and selecting a user inserts their mention into the message. Mentions are visually distinct in sent messages. + +#### Setting Up Mentions + +Configure mentions through `TextFieldConfiguration`: + +```dart +sendMessageConfig: SendMessageConfiguration( + textFieldConfig: TextFieldConfiguration( + onMentionTriggered: (searchText) { + // Filter users based on search text + final users = chatController.otherUsers + .where((user) => user.name + .toLowerCase() + .contains(searchText.toLowerCase())) + .toList(); + + // Convert users to suggestions + final suggestions = users.map((user) { + return SuggestionItemData( + text: user.name, + config: const SuggestionItemConfig( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + textStyle: TextStyle(color: Colors.white), + ), + ); + }).toList(); + + // Update suggestions in chat controller + chatController.newSuggestions.value = suggestions; + }, + mentionTriggerCharacter: '@', // Default is '@' + mentionTextStyle: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), +), +``` + +#### Handling Mention Selection + +Configure `ReplySuggestionsConfig` to handle mention insertion: + +```dart +replySuggestionsConfig: ReplySuggestionsConfig( + onTap: (item) { + // Get the SendMessageWidget state + final sendMessageWidgetKey = context.findAncestorStateOfType(); + if (sendMessageWidgetKey != null) { + // Insert mention at cursor position + sendMessageWidgetKey.insertMention(item.text); + // Clear suggestions after selection + chatController.removeReplySuggestions(); + } + }, +), +``` + +#### Visual Styling + +Mentions in messages are automatically styled based on `mentionTextStyle`. By default: +- Outgoing messages: mentions are bold and yellow +- Incoming messages: mentions are bold and blue + +You can customize this by setting `mentionTextStyle` in `TextFieldConfiguration`. + +#### Key Features + +- **Auto-detection**: Typing @ automatically triggers mention mode +- **Live filtering**: User list filters as you type +- **Smart insertion**: Mentions are inserted at cursor position +- **Visual distinction**: @mentions are highlighted in messages +- **Customizable**: Configure trigger character and styling +- **Non-intrusive**: Only activates when @ is typed + # Contributors ## Main Contributors diff --git a/example/lib/data.dart b/example/lib/data.dart index 4b933fcb..0390df8f 100644 --- a/example/lib/data.dart +++ b/example/lib/data.dart @@ -182,6 +182,13 @@ class Data { ]; static getMessageList({bool isExampleOne = true}) => [ + Message( + id: '0', + message: "Hey @John, can you help @Sarah with the project?", + createdAt: DateTime.now().subtract(const Duration(minutes: 5)), + sentBy: '1', + status: MessageStatus.read, + ), Message( id: '1', message: "How's it going?", diff --git a/example/lib/main.dart b/example/lib/main.dart index 99fa02a3..483685da 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -421,6 +421,9 @@ class _ExampleOneChatScreenState extends State { otherUsers: Data.otherUsers, ); + // Track if we're in mention mode to handle suggestion taps appropriately + bool _isMentionMode = false; + @override Widget build(BuildContext context) { return Scaffold( @@ -750,6 +753,50 @@ class _ExampleOneChatScreenState extends State { ), ), ], + onMentionTriggered: (searchText) { + // Update mention mode flag + _isMentionMode = searchText.isNotEmpty; + + if (searchText.isEmpty) { + // Clear suggestions when mention mode ends + _chatController.removeReplySuggestions(); + return; + } + + // Filter users based on search text + final users = _chatController.otherUsers + .where((user) => user.name + .toLowerCase() + .contains(searchText.toLowerCase())) + .toList(); + + // Convert users to suggestions + final suggestions = users.map((user) { + return SuggestionItemData( + text: user.name, + config: const SuggestionItemConfig( + padding: EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.uiOnePurple, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + textStyle: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ); + }).toList(); + + // Update suggestions in chat controller + _chatController.newSuggestions.value = suggestions; + }, + mentionTriggerCharacter: '@', + mentionTextStyle: const TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.uiOnePurple, + ), ), ), chatBubbleConfig: ChatBubbleConfiguration( @@ -883,11 +930,25 @@ class _ExampleOneChatScreenState extends State { ), textStyle: TextStyle(color: _theme.textColor), ), - onTap: (item) => _onSendTap( - item.text, - const ReplyMessage(), - MessageType.text, - ), + onTap: (item) { + if (_isMentionMode) { + // In mention mode, try to insert the mention + final sendMessageWidgetKey = + context.findAncestorStateOfType(); + if (sendMessageWidgetKey != null) { + sendMessageWidgetKey.insertMention(item.text); + _chatController.removeReplySuggestions(); + _isMentionMode = false; + } + } else { + // Normal suggestion behavior - send as message + _onSendTap( + item.text, + const ReplyMessage(), + MessageType.text, + ); + } + }, ), ), ), diff --git a/lib/src/models/config_models/send_message_configuration.dart b/lib/src/models/config_models/send_message_configuration.dart index bcc586be..fde66208 100644 --- a/lib/src/models/config_models/send_message_configuration.dart +++ b/lib/src/models/config_models/send_message_configuration.dart @@ -168,6 +168,9 @@ class TextFieldConfiguration { this.hintMaxLines, this.trailingActions, this.leadingActions, + this.onMentionTriggered, + this.mentionTriggerCharacter = '@', + this.mentionTextStyle, }); /// Used to give max lines in text field. @@ -239,6 +242,17 @@ class TextFieldConfiguration { /// /// Default is `true`. final bool hideLeadingActionsOnType; + + /// Callback when user types the mention trigger character (default '@'). + /// Provides the search text after the trigger character for filtering users. + final MentionCallback? onMentionTriggered; + + /// Character that triggers mention suggestions. Default is '@'. + final String mentionTriggerCharacter; + + /// Text style for mentions in the text field. + /// If provided, mentions will be styled differently in the message text. + final TextStyle? mentionTextStyle; } class ImagePickerConfiguration { diff --git a/lib/src/values/typedefs.dart b/lib/src/values/typedefs.dart index a0ecec67..0ed07c96 100644 --- a/lib/src/values/typedefs.dart +++ b/lib/src/values/typedefs.dart @@ -191,3 +191,6 @@ typedef EmojiPickerActionCallback = void Function( String? emoji, ReplyMessage? replyMessage, ); +typedef MentionCallback = void Function( + String searchText, +); diff --git a/lib/src/widgets/chatui_textfield.dart b/lib/src/widgets/chatui_textfield.dart index 2d327583..0ad124e7 100644 --- a/lib/src/widgets/chatui_textfield.dart +++ b/lib/src/widgets/chatui_textfield.dart @@ -460,5 +460,74 @@ class _ChatUITextFieldState extends State { composingStatus.value = TypeWriterStatus.typing; }); _isTextNotEmptyNotifier.value = inputText.trim().isNotEmpty; + _detectMention(inputText); + } + + void _detectMention(String text) { + final mentionTrigger = + textFieldConfig?.mentionTriggerCharacter ?? '@'; + final onMentionTriggered = textFieldConfig?.onMentionTriggered; + + if (onMentionTriggered == null) return; + + final selection = widget.textEditingController.selection; + if (!selection.isValid || selection.baseOffset <= 0) return; + + // Get text up to cursor position + final textBeforeCursor = text.substring(0, selection.baseOffset); + + // Find the last occurrence of mention trigger + final lastTriggerIndex = textBeforeCursor.lastIndexOf(mentionTrigger); + + if (lastTriggerIndex == -1) { + // No trigger found, clear suggestions + onMentionTriggered(''); + return; + } + + // Check if there's a space between trigger and cursor + // This ensures we only suggest mentions while typing a single word + final textAfterTrigger = textBeforeCursor.substring(lastTriggerIndex + 1); + if (textAfterTrigger.contains(' ')) { + // Space found, user has finished typing the mention word + onMentionTriggered(''); + return; + } + + // Valid mention detected, trigger callback with search text + onMentionTriggered(textAfterTrigger); + } + + /// Inserts a mention into the text field at the current cursor position. + /// This method is called when a user selects a mention from the suggestions. + void insertMention(String mention) { + final mentionTrigger = + textFieldConfig?.mentionTriggerCharacter ?? '@'; + final controller = widget.textEditingController; + final text = controller.text; + final selection = controller.selection; + + if (!selection.isValid) return; + + // Get text up to cursor position + final textBeforeCursor = text.substring(0, selection.baseOffset); + + // Find the last occurrence of mention trigger + final lastTriggerIndex = textBeforeCursor.lastIndexOf(mentionTrigger); + + if (lastTriggerIndex == -1) return; + + // Replace from trigger to cursor with the mention + final newText = text.replaceRange( + lastTriggerIndex, + selection.baseOffset, + '$mentionTrigger$mention ', + ); + + // Update text and cursor position + final newCursorPos = lastTriggerIndex + mentionTrigger.length + mention.length + 1; + controller + ..text = newText + ..selection = TextSelection.collapsed(offset: newCursorPos); } } diff --git a/lib/src/widgets/message_view.dart b/lib/src/widgets/message_view.dart index 41405ee5..48a3fb04 100644 --- a/lib/src/widgets/message_view.dart +++ b/lib/src/widgets/message_view.dart @@ -232,6 +232,7 @@ class _MessageViewState extends State highlightColor: widget.highlightColor, highlightMessage: widget.shouldHighlight, featureActiveConfig: chatViewIW?.featureActiveConfig, + mentionTextStyle: null, // Can be customized via config in future ); } else if (widget.message.messageType.isVoice) { return VoiceMessageView( diff --git a/lib/src/widgets/send_message_widget.dart b/lib/src/widgets/send_message_widget.dart index 7d1d0852..07d808b3 100644 --- a/lib/src/widgets/send_message_widget.dart +++ b/lib/src/widgets/send_message_widget.dart @@ -74,6 +74,9 @@ class SendMessageWidgetState extends State { final GlobalKey _selectedImageViewWidgetKey = GlobalKey(); + + final GlobalKey<_ChatUITextFieldState> _chatUITextFieldKey = GlobalKey(); + ReplyMessage _replyMessage = const ReplyMessage(); ReplyMessage get replyMessage => _replyMessage; @@ -172,6 +175,7 @@ class SendMessageWidgetState extends State { sendMessageConfig: widget.sendMessageConfig, ), ChatUITextField( + key: _chatUITextFieldKey, focusNode: _focusNode, textEditingController: _textEditingController, onPressed: _onPressed, @@ -284,6 +288,12 @@ class SendMessageWidgetState extends State { } } + /// Inserts a mention into the text field. + /// This can be called from outside to insert a user mention. + void insertMention(String mention) { + _chatUITextFieldKey.currentState?.insertMention(mention); + } + double get _bottomPadding => (!kIsWeb && Platform.isIOS) ? (_focusNode.hasFocus ? bottomPadding1 diff --git a/lib/src/widgets/text_message_view.dart b/lib/src/widgets/text_message_view.dart index 1e0f3521..fb7b1d55 100644 --- a/lib/src/widgets/text_message_view.dart +++ b/lib/src/widgets/text_message_view.dart @@ -40,8 +40,12 @@ class TextMessageView extends StatelessWidget { this.highlightMessage = false, this.highlightColor, this.featureActiveConfig, + this.mentionTextStyle, }) : super(key: key); + /// Regular expression pattern for detecting mentions in text + static final RegExp _mentionRegex = RegExp(r'(@\w+)'); + /// Represents current message is sent by current user. final bool isMessageBySender; @@ -69,6 +73,9 @@ class TextMessageView extends StatelessWidget { /// Provides configuration of active features in chat. final FeatureActiveConfig? featureActiveConfig; + /// Text style to apply to @mentions in the message. + final TextStyle? mentionTextStyle; + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; @@ -81,25 +88,38 @@ class TextMessageView extends StatelessWidget { ? outgoingChatBubbleConfig?.textSelectionConfig : inComingChatBubbleConfig?.textSelectionConfig; final extractedUrls = textMessage.extractedUrls; - final baseWidget = extractedUrls.isNotEmpty - ? LinkPreview( - linkPreviewConfig: _linkPreviewConfig, - textMessage: textMessage, - extractedUrls: extractedUrls, - normalTextStyle: _textStyle ?? - textTheme.bodyMedium?.copyWith( - color: Colors.white, - fontSize: 16, - ), - ) - : Text( - textMessage, - style: _textStyle ?? - textTheme.bodyMedium?.copyWith( - color: Colors.white, - fontSize: 16, - ), - ); + + final Widget baseWidget; + if (extractedUrls.isNotEmpty) { + baseWidget = LinkPreview( + linkPreviewConfig: _linkPreviewConfig, + textMessage: textMessage, + extractedUrls: extractedUrls, + normalTextStyle: _textStyle ?? + textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ); + } else if (_hasMentions(textMessage)) { + baseWidget = _buildTextWithMentions( + textMessage, + _textStyle ?? + textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ); + } else { + baseWidget = Text( + textMessage, + style: _textStyle ?? + textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ); + } return Stack( clipBehavior: Clip.none, children: [ @@ -167,4 +187,54 @@ class TextMessageView extends StatelessWidget { Color get _color => isMessageBySender ? outgoingChatBubbleConfig?.color ?? Colors.purple : inComingChatBubbleConfig?.color ?? Colors.grey.shade500; + + bool _hasMentions(String text) { + return _mentionRegex.hasMatch(text); + } + + Widget _buildTextWithMentions(String text, TextStyle? baseStyle) { + final mentionStyle = mentionTextStyle ?? + baseStyle?.copyWith( + fontWeight: FontWeight.bold, + color: isMessageBySender ? Colors.yellow : Colors.blue, + ) ?? + TextStyle( + fontWeight: FontWeight.bold, + color: isMessageBySender ? Colors.yellow : Colors.blue, + ); + + final spans = []; + final matches = _mentionRegex.allMatches(text); + + int lastMatchEnd = 0; + for (final match in matches) { + // Add text before mention + if (match.start > lastMatchEnd) { + spans.add(TextSpan( + text: text.substring(lastMatchEnd, match.start), + style: baseStyle, + )); + } + + // Add mention with special style + spans.add(TextSpan( + text: match.group(0), + style: mentionStyle, + )); + + lastMatchEnd = match.end; + } + + // Add remaining text after last mention + if (lastMatchEnd < text.length) { + spans.add(TextSpan( + text: text.substring(lastMatchEnd), + style: baseStyle, + )); + } + + return RichText( + text: TextSpan(children: spans), + ); + } }