diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d87454dc..b7f2709c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,5 +1,6 @@ name: example description: A new Flutter project. + version: 1.0.41+43 environment: diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 32e812e7..89891ace 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -14,6 +15,7 @@ import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; +import 'package:substring_highlight/substring_highlight.dart'; import '../stream_chat_flutter.dart'; import 'stream_channel.dart'; @@ -168,7 +170,8 @@ class MessageInputState extends State { bool _messageIsPresent = false; bool _typingStarted = false; bool _commandEnabled = false; - OverlayEntry _commandsOverlay, _mentionsOverlay; + OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; + Iterable _emojiNames; Command _chosenCommand; bool _actionsShrunk = false; @@ -364,42 +367,14 @@ class MessageInputState extends State { _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; + _emojiOverlay?.remove(); + _emojiOverlay = null; - if (s.startsWith('/')) { - var matchedCommandsList = StreamChannel.of(context) - .channel - .config - .commands - .where((element) => element.name == s.substring(1)) - .toList(); + _checkCommands(s.trimLeft(), context); - if (matchedCommandsList.length == 1) { - _chosenCommand = matchedCommandsList[0]; - textEditingController.clear(); - _messageIsPresent = false; - setState(() { - _commandEnabled = true; - }); - _commandsOverlay?.remove(); - _commandsOverlay = null; - } else { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - } - } + _checkMentions(s, context); - if (textEditingController.selection.isCollapsed && - s.isNotEmpty && - (s[textEditingController.selection.start - 1] == '@' || - textEditingController.text - .substring( - 0, textEditingController.selection.start) - .split(' ') - .last - .contains('@'))) { - _mentionsOverlay = _buildMentionsOverlayEntry(); - Overlay.of(context).insert(_mentionsOverlay); - } + _checkEmoji(s, context); }, onTap: () { setState(() { @@ -465,42 +440,73 @@ class MessageInputState extends State { ); } - Positioned _buildBorder(BuildContext context) { - return Positioned.fill( - child: Container( - width: MediaQuery.of(context).size.width, - padding: EdgeInsets.all(2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.0), - gradient: _getGradient(context), - ), - child: Container( - decoration: BoxDecoration( - color: StreamChatTheme.of(context) - .channelTheme - .inputBackground - .withAlpha(255), - borderRadius: BorderRadius.circular(10.0), - ), - child: Container( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).channelTheme.inputBackground, - borderRadius: BorderRadius.circular(10.0), - border: Border.all( - color: _typingStarted - ? Colors.transparent - : Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(.2) - : Colors.black.withOpacity(.2)), - ), - ), - ), - ), - ); + void _checkEmoji(String s, BuildContext context) { + if (textEditingController.selection.isCollapsed && + (s.isNotEmpty && s[textEditingController.selection.start - 1] == ':' || + textEditingController.text + .substring( + 0, + textEditingController.selection.start, + ) + .contains(':'))) { + final textToSelection = textEditingController.text + .substring(0, textEditingController.value.selection.start); + final splits = textToSelection.split(':'); + final query = splits[splits.length - 2]?.toLowerCase(); + final emoji = Emoji.byName(query); + + if (textToSelection.endsWith(':') && emoji != null) { + _chooseEmoji(splits.sublist(0, splits.length - 1), emoji); + } else { + _emojiOverlay = _buildEmojiOverlay(); + + if (_emojiOverlay != null) { + Overlay.of(context).insert(_emojiOverlay); + } + } + } + } + + void _checkMentions(String s, BuildContext context) { + if (textEditingController.selection.isCollapsed && + (s.isNotEmpty && s[textEditingController.selection.start - 1] == '@' || + textEditingController.text + .substring(0, textEditingController.selection.start) + .split(' ') + .last + .contains('@'))) { + _mentionsOverlay = _buildMentionsOverlayEntry(); + Overlay.of(context).insert(_mentionsOverlay); + } + } + + void _checkCommands(String s, BuildContext context) { + if (s.startsWith('/')) { + var matchedCommandsList = StreamChannel.of(context) + .channel + .config + .commands + .where((element) => element.name == s.substring(1)) + .toList(); + + if (matchedCommandsList.length == 1) { + _chosenCommand = matchedCommandsList[0]; + textEditingController.clear(); + _messageIsPresent = false; + setState(() { + _commandEnabled = true; + }); + _commandsOverlay.remove(); + _commandsOverlay = null; + } else { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } + } } OverlayEntry _buildCommandsOverlayEntry() { - final text = textEditingController.text; + final text = textEditingController.text.trimLeft(); final commands = StreamChannel.of(context) .channel .config @@ -721,6 +727,125 @@ class MessageInputState extends State { }); } + OverlayEntry _buildEmojiOverlay() { + final splits = textEditingController.text + .substring(0, textEditingController.value.selection.start) + .split(':'); + final query = splits.last.toLowerCase(); + + if (query.isEmpty) { + return null; + } + + final emojis = _emojiNames + .where((e) => e.contains(query)) + .map((e) => Emoji.byName(e)) + .where((e) => e != null); + + if (emojis.isEmpty) { + return null; + } + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry(builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Card( + margin: EdgeInsets.all(8.0), + elevation: 2.0, + color: StreamChatTheme.of(context).primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + clipBehavior: Clip.antiAlias, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(200)), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5.0, + offset: Offset(0, -4), + ), + ], + color: StreamChatTheme.of(context).primaryColor, + ), + child: ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: emojis.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.only(left: 8.0, top: 8.0), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: Icon( + StreamIcons.smile, + color: StreamChatTheme.of(context).accentColor, + ), + ), + Flexible( + child: Text( + 'Emoji matching "$query"', + style: TextStyle( + color: Colors.black.withOpacity(.5), + ), + ), + ) + ], + ), + ); + } + + final emoji = emojis.elementAt(i - 1); + return ListTile( + title: SubstringHighlight( + text: "${emoji.char} ${emoji.name.replaceAll('_', ' ')}", + term: query, + textStyleHighlight: + Theme.of(context).textTheme.headline6.copyWith( + fontSize: 14.5, + fontWeight: FontWeight.bold, + ), + textStyle: Theme.of(context).textTheme.headline6.copyWith( + fontSize: 14.5, + ), + ), + onTap: () { + _chooseEmoji(splits, emoji); + }, + ); + }), + ), + ), + ); + }); + } + + void _chooseEmoji(List splits, Emoji emoji) { + final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char; + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text + .substring(textEditingController.selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + + _emojiOverlay?.remove(); + _emojiOverlay = null; + } + void _setCommand(Command c) { textEditingController.clear(); setState(() { @@ -1275,11 +1400,13 @@ class MessageInputState extends State { void initState() { super.initState(); + _emojiNames = Emoji.all().map((e) => e.name); + if (!kIsWeb) { _keyboardListener = KeyboardVisibility.onChange.listen((visible) { if (visible) { if (_commandsOverlay != null) { - if (textEditingController.text.startsWith('/')) { + if (textEditingController.text.trimLeft().startsWith('/')) { WidgetsBinding.instance.addPostFrameCallback((_) { _commandsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_commandsOverlay); @@ -1295,6 +1422,15 @@ class MessageInputState extends State { }); } } + + if (_emojiOverlay != null) { + if (textEditingController.text.contains(':')) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _emojiOverlay = _buildEmojiOverlay(); + Overlay.of(context).insert(_emojiOverlay); + }); + } + } } else { if (_commandsOverlay != null) { _commandsOverlay.remove(); @@ -1302,6 +1438,9 @@ class MessageInputState extends State { if (_mentionsOverlay != null) { _mentionsOverlay.remove(); } + if (_emojiOverlay != null) { + _emojiOverlay.remove(); + } } }); } @@ -1330,6 +1469,7 @@ class MessageInputState extends State { @override void dispose() { _commandsOverlay?.remove(); + _emojiOverlay?.remove(); _mentionsOverlay?.remove(); _keyboardListener?.cancel(); super.dispose(); diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index ad574fc8..476217f3 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +import 'package:emojis/emoji.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -729,37 +730,68 @@ class _MessageWidgetState extends State { return SizedBox(); } + Widget _wrapTextInBubble({ + BuildContext context, + Widget child, + }) { + return Material( + shape: widget.shape ?? + RoundedRectangleBorder( + side: widget.borderSide ?? + BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withAlpha(24) + : Colors.black.withAlpha(24), + ), + borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: child, + ); + } + Widget _buildTextBubble(BuildContext context) { + final isOnlyEmoji = + widget.message.text.characters.every((c) => Emoji.byChar(c) != null); + + Widget child = Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Padding( + padding: widget.textPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + getFailedMessageWidget(context), + widget.textBuilder != null + ? widget.textBuilder(context, widget.message) + : MessageText( + onLinkTap: widget.onLinkTap, + message: widget.message, + onMentionTap: widget.onMentionTap, + messageTheme: isOnlyEmoji + ? widget.messageTheme.copyWith( + messageText: + widget.messageTheme.messageText.copyWith( + fontSize: 40, + )) + : widget.messageTheme, + ), + ], + ), + ), + ); + + if (!isOnlyEmoji) { + child = _wrapTextInBubble( + context: context, + child: child, + ); + } return GestureDetector( onTap: () => retryMessage(context), onLongPress: () => onLongPress(context), - child: Material( - shape: widget.shape ?? - RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withAlpha(24) - : Colors.black.withAlpha(24), - ), - borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, - ), - color: _getBackgroundColor(), - child: Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Padding( - padding: widget.textPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - getFailedMessageWidget(context), - _buildText(context), - ], - ), - ), - ), - ), + child: child, ); } @@ -793,15 +825,4 @@ class _MessageWidgetState extends State { return; } } - - Widget _buildText(BuildContext context) { - return widget.textBuilder != null - ? widget.textBuilder(context, widget.message) - : MessageText( - onLinkTap: widget.onLinkTap, - message: widget.message, - onMentionTap: widget.onMentionTap, - messageTheme: widget.messageTheme, - ); - } } diff --git a/pubspec.yaml b/pubspec.yaml index 60bf32a9..0e768587 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,9 +27,11 @@ dependencies: image_picker: ^0.6.7+2 flutter_keyboard_visibility: ^3.2.1 stream_chat: ^0.2.10+1 + emojis: ^0.9.3 mime: ^0.9.6+3 visibility_detector: ^0.1.5 http_parser: ^3.1.4 + substring_highlight: ^0.1.2 flutter_slidable: ^0.5.4 carousel_slider: ^2.2.1 clipboard: ^0.1.2+8