diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index c5f1e85b..a90d828e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - example + ChatSample CFBundlePackageType APPL CFBundleShortVersionString diff --git a/example/lib/main.dart b/example/lib/main.dart index 667b1e0f..ca727cf7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -98,7 +98,9 @@ class MyApp extends StatelessWidget { return MaterialApp( theme: ThemeData.light(), darkTheme: ThemeData.dark(), - themeMode: ThemeMode.system, + themeMode: ThemeMode.light, + + ///TODO change to system once dark theme is implemented builder: (context, widget) { return StreamChat( child: widget, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 842f471c..0cba8d30 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,7 @@ name: example description: A new Flutter project. -version: 1.0.39+41 + +version: 1.0.43+45 environment: sdk: ">=2.2.2 <3.0.0" @@ -18,7 +19,13 @@ dev_dependencies: sdk: flutter flutter_driver: sdk: flutter + flutter_launcher_icons: ^0.8.0 test: any flutter: uses-material-design: true + +flutter_icons: + android: true + ios: true + image_path: "assets/icon.png" diff --git a/lib/src/giphy_attachment.dart b/lib/src/giphy_attachment.dart index f913b557..bfa21239 100644 --- a/lib/src/giphy_attachment.dart +++ b/lib/src/giphy_attachment.dart @@ -41,195 +41,211 @@ class GiphyAttachment extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Card( - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(0.0), - topLeft: Radius.circular(16.0), - bottomLeft: Radius.circular(16.0), + DecoratedBox( + decoration: BoxDecoration(), + child: Card( + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(0.0), + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - ); - })); - }, - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navigator.push(context, + MaterialPageRoute(builder: (_) { + return FullScreenImage( + url: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl, + ); + })); + }, + child: CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, + ), ), - fit: BoxFit.cover, - ), - ), - ), - Positioned( - left: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - bottomRight: Radius.circular(16.0), - )), - child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 8.0, - top: 8.0, - bottom: 4.0, - ), - child: Row( - children: [ - Icon( - StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor, - size: 16.0, - ), - Text( - 'GIPHY', - style: TextStyle( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold, - fontSize: 11.0, + Positioned( + left: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(16.0), + )), + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + top: 8.0, + bottom: 4.0, + ), + child: Row( + children: [ + Icon( + StreamIcons.lightning, + color: StreamChatTheme.of(context).accentColor, + size: 16.0, + ), + Text( + 'GIPHY', + style: TextStyle( + color: + StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + fontSize: 11.0, + ), + ), + ], ), ), - ], + ), ), - ), + ], ), - ), - if (attachment.title != null) - Container( - alignment: Alignment.bottomCenter, - child: Material( - color: Colors.white, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Card( - child: IconButton( - icon: Icon( - StreamIcons.left, - size: 24.0, + if (attachment.title != null) + Container( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.left, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, ), - splashRadius: 24, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, + shape: CircleBorder(), ), - shape: CircleBorder(), - ), - Expanded( - child: Center( - child: Text( - '"${attachment.title}"', - style: TextStyle( - fontStyle: FontStyle.italic, + Expanded( + child: Center( + child: Text( + '"${attachment.title}"', + style: TextStyle( + fontStyle: FontStyle.italic, + ), ), ), ), - ), - Card( - child: IconButton( - icon: Icon( - StreamIcons.right, - size: 24.0, + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.right, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, ), - splashRadius: 24, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, + shape: CircleBorder(), ), - shape: CircleBorder(), - ), - ], + ], + ), ), ), + SizedBox( + height: 4.0, ), - SizedBox( - height: 4.0, - ), - Container( - color: Colors.black.withOpacity(0.2), - width: double.infinity, - height: 0.5, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'cancel', - }); - }, - child: Text( - 'Cancel', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.5)), + Container( + color: Colors.black.withOpacity(0.2), + width: double.infinity, + height: 0.5, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'cancel', + }); + }, + child: Text( + 'Cancel', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.5)), + ), ), ), - ), - Container( - width: 0.5, - color: Colors.black.withOpacity(0.2), - height: 50.0, - ), - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'send', - }); - }, - child: Text( - 'Send', - style: TextStyle( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold), + Container( + width: 0.5, + color: Colors.black.withOpacity(0.2), + height: 50.0, + ), + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'send', + }); + }, + child: Text( + 'Send', + style: TextStyle( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold), + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), SizedBox( @@ -313,7 +329,10 @@ class GiphyAttachment extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only( + top: 8.0, + bottom: 8, + ), child: Row( children: [ Row( diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 74ed535a..2ba93347 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -1,11 +1,13 @@ 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'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; @@ -14,6 +16,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 +171,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; @@ -227,22 +231,66 @@ class MessageInputState extends State { } Widget _buildDmCheckbox() { - return Row( - children: [ - Checkbox( - value: _sendAsDm, - onChanged: (val) => setState( - () { - _sendAsDm = val; - }, + return Container( + height: 36, + padding: const EdgeInsets.only( + left: 12, + bottom: 12, + top: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: Colors.black.withOpacity(.5), + width: 2, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? StreamChatTheme.of(context).accentColor + : Colors.white, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Icon( + StreamIcons.check, + size: 16.0, + color: Colors.white, + ), + secondChild: SizedBox( + height: 16, + width: 16, + ), + ), + ), + ), + ), ), - activeColor: StreamChatTheme.of(context).accentColor, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text('Send also as direct message'), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Send also as direct message'), + ), + ], + ), ); } @@ -326,42 +374,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(() { @@ -427,8 +447,73 @@ class MessageInputState extends State { ); } + 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 @@ -456,13 +541,6 @@ class MessageInputState extends State { child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( - // boxShadow: [ - // BoxShadow( - // spreadRadius: -8, - // blurRadius: 5.0, - // offset: Offset(0, -4), - // ), - // ], color: StreamChatTheme.of(context).primaryColor, borderRadius: BorderRadius.circular(8.0)), child: ListView( @@ -480,25 +558,19 @@ class MessageInputState extends State { child: Icon(StreamIcons.lightning, color: StreamChatTheme.of(context).accentColor), ), - Text('Instant Commands') + Text( + 'Instant Commands', + style: TextStyle( + color: Colors.black.withOpacity(.5), + ), + ) ], ), ), ...commands .map( (c) => ListTile( - leading: c.name == 'giphy' - ? CircleAvatar( - backgroundColor: Colors.black, - child: Image.asset( - 'images/giphy_icon.png', - package: 'stream_chat_flutter', - width: 16.0, - height: 16.0, - ), - maxRadius: 12.0, - ) - : null, + leading: c.name == 'giphy' ? _buildGiphyIcon() : null, title: Text.rich( TextSpan( text: '${c.name.capitalize()}', @@ -641,12 +713,12 @@ class MessageInputState extends State { case 0: return GridView.builder( itemCount: _mediaData.item2.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), itemBuilder: (context, position) { return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 1.0, vertical: 1.0), + padding: + const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), child: InkWell( child: Stack( children: [ @@ -655,13 +727,13 @@ class MessageInputState extends State { child: Image.memory( _mediaData.item2[position], fit: _mediaData.item1[position].height > - _mediaData.item1[position].width + _mediaData.item1[position].width ? BoxFit.fitWidth : BoxFit.fitHeight, ), ), if (_attachments.any((element) => - element.id == _mediaData.item1[position].id)) + element.id == _mediaData.item1[position].id)) Container( color: Colors.black.withOpacity(0.5), child: Center( @@ -676,11 +748,11 @@ class MessageInputState extends State { ), onTap: () { if (!_attachments.any((element) => - element.id == _mediaData.item1[position].id)) { + element.id == _mediaData.item1[position].id)) { _addAttachment(_mediaData.item1[position]); } else { _attachments.removeWhere((element) => - element.id == _mediaData.item1[position].id); + element.id == _mediaData.item1[position].id); setState(() {}); } }, @@ -775,10 +847,36 @@ class MessageInputState extends State { } setState(() { - _mediaData = Tuple2, List>(resultList, resultThumbnailList); + _mediaData = + Tuple2, List>(resultList, resultThumbnailList); }); } + CircleAvatar _buildGiphyIcon() { + if (kIsWeb) { + return CircleAvatar( + backgroundColor: Colors.black, + child: Image.asset( + 'images/giphy_icon.png', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + ), + radius: 12, + ); + } else { + return CircleAvatar( + child: SvgPicture.asset( + 'svgs/giphy_icon.svg', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + ), + radius: 12, + ); + } + } + OverlayEntry _buildMentionsOverlayEntry() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) @@ -819,13 +917,6 @@ class MessageInputState extends State { child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - spreadRadius: -8, - blurRadius: 5.0, - offset: Offset(0, -4), - ), - ], color: StreamChatTheme.of(context).primaryColor, ), child: FutureBuilder>( @@ -838,6 +929,12 @@ class MessageInputState extends State { children: snapshot.data .map((m) => ListTile( leading: UserAvatar( + constraints: BoxConstraints.tight( + Size( + 40, + 40, + ), + ), user: m.user, ), title: Text( @@ -878,6 +975,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(() { @@ -1433,11 +1649,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); @@ -1453,6 +1671,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(); @@ -1460,6 +1687,9 @@ class MessageInputState extends State { if (_mentionsOverlay != null) { _mentionsOverlay.remove(); } + if (_emojiOverlay != null) { + _emojiOverlay.remove(); + } } }); } @@ -1494,6 +1724,7 @@ class MessageInputState extends State { @override void dispose() { _commandsOverlay?.remove(); + _emojiOverlay?.remove(); _mentionsOverlay?.remove(); _keyboardListener?.cancel(); super.dispose(); diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index bb0f98c0..946ad66f 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; import 'package:visibility_detector/visibility_detector.dart'; -import 'package:widgets_visibility_provider/widgets_visibility_provider.dart'; import '../stream_chat_flutter.dart'; import 'date_divider.dart'; @@ -106,7 +107,10 @@ class MessageListView extends StatefulWidget { this.onThreadTap, this.dateDividerBuilder, this.scrollPhysics = const AlwaysScrollableScrollPhysics(), - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollController, + this.itemPositionListener, }) : super(key: key); /// Function used to build a custom message widget @@ -131,95 +135,101 @@ class MessageListView extends StatefulWidget { /// Builder used to render date dividers final Widget Function(DateTime) dateDividerBuilder; + /// Index of an item to initially align within the viewport. + final int initialScrollIndex; + + /// Determines where the leading edge of the item at [initialScrollIndex] + /// should be placed. + final double initialAlignment; + + /// Controller for jumping or scrolling to an item. + final ItemScrollController scrollController; + + /// Provides a listenable iterable of [itemPositions] of items that are on + /// screen and their locations. + final ItemPositionsListener itemPositionListener; + /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; - /// The [ScrollViewKeyboardDismissBehavior] used by the ListView - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - @override _MessageListViewState createState() => _MessageListViewState(); } class _MessageListViewState extends State { - static const _newMessageLoadingOffset = 100; - final ScrollController _scrollController = ScrollController(); - bool _bottomWasVisible = true; + ItemScrollController _scrollController; + bool _bottomWasVisible = false; bool _topWasVisible = false; - List _messages = []; - List _newMessageList = []; Function _onThreadTap; bool _showScrollToBottom = false; + ItemPositionsListener _itemPositionListener; @override Widget build(BuildContext context) { final streamChannel = StreamChannel.of(context); - /// TODO: find a better solution when (https://github.com/flutter/flutter/issues/21023) is fixed - return WidgetsVisibilityProvider( - condition: (positionData) => - positionData.endPosition >= 20 && - positionData.startPosition <= positionData.viewportSize, - child: Stack( - alignment: Alignment.center, - children: [ - NotificationListener( - onNotification: (_) { - if (_scrollController.offset < 150 && - _newMessageList.isNotEmpty) { - setState(() { - _messages.insertAll(0, _newMessageList); - _newMessageList.clear(); - }); - } - return true; - }, - child: ListView.custom( - key: Key('messageListView'), - physics: widget.scrollPhysics, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - controller: _scrollController, - reverse: true, - childrenDelegate: SliverChildBuilderDelegate( - (context, i) { - if (i == _messages.length + 1) { - if (widget.parentMessage != null) { - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder( - context, - widget.parentMessage, - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildParentMessage(widget.parentMessage), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 32), - child: Container( - padding: const EdgeInsets.all(8), - child: Text( - 'Start of thread', - textAlign: TextAlign.center, - ), - color: - Theme.of(context).accentColor.withAlpha(50), - ), - ), - ], - ); - } + final messagesStream = widget.parentMessage != null + ? streamChannel.channel.state.threadsStream + .where((threads) => threads.containsKey(widget.parentMessage.id)) + .map((threads) => threads[widget.parentMessage.id]) + : streamChannel.channel.state.messagesStream; + + return StreamBuilder>( + stream: messagesStream, + initialData: widget.parentMessage != null + ? streamChannel.channel.state.threads[widget.parentMessage.id] + : streamChannel.channel.state.messages, + builder: (context, snapshot) { + final messages = snapshot.data?.reversed?.toList() ?? []; + return Stack( + alignment: Alignment.center, + children: [ + ScrollablePositionedList.builder( + itemPositionsListener: _itemPositionListener, + addAutomaticKeepAlives: true, + key: Key('messageListView'), + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + physics: widget.scrollPhysics, + itemScrollController: _scrollController, + reverse: true, + itemCount: messages.length + + 1 + + (widget.parentMessage != null ? 1 : 0), + itemBuilder: (context, i) { + if (i == messages.length + 1) { + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder( + context, + widget.parentMessage, + ); } else { - return SizedBox(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildParentMessage(widget.parentMessage), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Container( + padding: const EdgeInsets.all(8), + child: Text( + 'Start of thread', + textAlign: TextAlign.center, + ), + color: + Theme.of(context).accentColor.withAlpha(50), + ), + ), + ], + ); } } - if (i == _messages.length) { + if (i == messages.length) { return _buildLoadingIndicator(streamChannel); } - final message = _messages[i]; - final nextMessage = i > 0 ? _messages[i - 1] : null; + final message = messages[i]; + final nextMessage = i > 0 ? messages[i - 1] : null; Widget messageWidget; @@ -227,14 +237,14 @@ class _MessageListViewState extends State { messageWidget = _buildBottomMessage( context, message, - _messages, + messages, streamChannel, ); - } else if (i == _messages.length - 1) { + } else if (i == messages.length - 1) { messageWidget = _buildTopMessage( context, message, - _messages, + messages, streamChannel, ); } else { @@ -246,13 +256,13 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, + messages, i, ), - _messages), + messages), ); } else { - messageWidget = buildMessage(message, _messages, i); + messageWidget = buildMessage(message, messages, i); } } @@ -265,84 +275,74 @@ class _MessageListViewState extends State { messageWidget, Padding( padding: const EdgeInsets.symmetric(vertical: 12.0), - child: VisibleNotifierWidget( - condition: ( - ScrollNotification previousNotification, - PositionData previousPositionData, - ScrollNotification currentNotification, - PositionData currentPositionData, - ) { - print( - 'currentPositionData.endPosition: ${currentPositionData.endPosition}'); - return true; - }, - data: i, - child: widget.dateDividerBuilder != null - ? widget.dateDividerBuilder( - nextMessage.createdAt.toLocal()) - : DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ), - ), + child: widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + nextMessage.createdAt.toLocal()) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ), ), ], ); } - return VisibleNotifierWidget( - child: messageWidget, - data: i, - ); - }, - childCount: _messages.length + 2, - findChildIndexCallback: (key) { - final ValueKey valueKey = key; - final index = _messages - .indexWhere((m) => 'MESSAGE-${m.id}' == valueKey.value); - return index != -1 ? index : null; + return messageWidget; }, ), - ), - ), - if (widget.showScrollToBottom) - StreamBuilder( - stream: streamChannel.channel.on( - EventType.messageNew, - ), - builder: (context, _) { - if (!_showScrollToBottom || - streamChannel.channel.state.unreadCount == 0) { - return SizedBox(); - } - return _buildScrollToBottom(streamChannel); - }), - Positioned( - top: 20.0, - child: WidgetsVisibilityConsumer( - listener: ( - context, - event, - ) {}, - builder: (context, event) { - if (event.positionDataList == null || - event.positionDataList.isEmpty) { - return Container(); - } + if (widget.showScrollToBottom) + StreamBuilder( + stream: streamChannel.channel.state.unreadCountStream, + builder: (context, snapshot) { + if (!_showScrollToBottom || + !snapshot.hasData || + snapshot.data == 0) { + return SizedBox(); + } + return _buildScrollToBottom(snapshot.data); + }), + Positioned( + top: 20.0, + child: ValueListenableBuilder>( + valueListenable: _itemPositionListener.itemPositions, + builder: (context, values, _) { + final items = _itemPositionListener.itemPositions?.value; + if (items.isEmpty || messages.isEmpty) { + return SizedBox(); + } - return DateDivider( - dateTime: _messages[event.positionDataList.last.data] - .createdAt - .toLocal(), - ); - }, - ), - ), - ], - ), - ); + var index = _getTopElement(values).index; + + if (index > messages.length) { + return SizedBox(); + } + + if (index == messages.length) { + index = max(index - 1, 0); + } + + return widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + messages[index].createdAt.toLocal(), + ) + : DateDivider( + dateTime: messages[index].createdAt.toLocal(), + ); + }, + ), + ), + ], + ); + }); } - Widget _buildScrollToBottom(StreamChannelState streamChannel) { + ItemPosition _getTopElement(Iterable values) { + return values + .where((ItemPosition position) => position.itemLeadingEdge < 0.9) + .reduce((ItemPosition max, ItemPosition position) => + position.itemLeadingEdge > max.itemLeadingEdge ? position : max); + } + + Widget _buildScrollToBottom(int unreadCount) { return Positioned( bottom: 8, right: 8, @@ -358,13 +358,11 @@ class _MessageListViewState extends State { color: Colors.black, ), onPressed: () { - Future.delayed(Duration(milliseconds: 500), () { - setState(() { - _showScrollToBottom = false; - }); + setState(() { + _showScrollToBottom = false; }); - _scrollController.animateTo( - 0, + _scrollController.scrollTo( + index: 0, duration: Duration(seconds: 1), curve: Curves.easeInOut, ); @@ -377,7 +375,7 @@ class _MessageListViewState extends State { top: -10, child: CircleAvatar( radius: 20, - child: Text(streamChannel.channel.state.unreadCount.toString()), + child: Text(unreadCount.toString()), ), ), ], @@ -387,7 +385,9 @@ class _MessageListViewState extends State { Container _buildLoadingIndicator(StreamChannelState streamChannel) { return Container( + key: Key('LOADING-INDICATOR'), height: 50, + width: double.infinity, child: StreamBuilder( stream: streamChannel.queryMessage, initialData: false, @@ -401,7 +401,7 @@ class _MessageListViewState extends State { ); } if (!snapshot.data) { - return Container(); + return SizedBox(); } return Center( child: Padding( @@ -428,14 +428,14 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, - _messages.length - 1, + messages, + messages.length - 1, ), - _messages, + messages, ), ); } else { - messageWidget = buildMessage(message, messages, _messages.length - 1); + messageWidget = buildMessage(message, messages, messages.length - 1); } return VisibilityDetector( @@ -470,10 +470,10 @@ class _MessageListViewState extends State { MessageDetails( context, message, - _messages, + messages, 0, ), - _messages, + messages, ), ); } else { @@ -492,9 +492,11 @@ class _MessageListViewState extends State { } } _bottomWasVisible = isVisible; - setState(() { - _showScrollToBottom = !isVisible; - }); + if (mounted) { + setState(() { + _showScrollToBottom = !isVisible; + }); + } }, child: messageWidget, ); @@ -541,6 +543,7 @@ class _MessageListViewState extends State { ) { if (message.type == 'system' && message.text?.isNotEmpty == true) { return SystemMessage( + key: ValueKey('MESSAGE-${message.id}'), message: message, ); } @@ -563,6 +566,7 @@ class _MessageListViewState extends State { final allRead = readList.length >= (channel.memberCount ?? 0) - 1; return MessageWidget( + key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, @@ -600,64 +604,45 @@ class _MessageListViewState extends State { ); } - StreamSubscription _streamListener; + StreamSubscription _messageNewListener; @override void initState() { - super.initState(); + _scrollController = widget.scrollController ?? ItemScrollController(); + _itemPositionListener = + widget.itemPositionListener ?? ItemPositionsListener.create(); final streamChannel = StreamChannel.of(context); - Stream> stream; - - if (widget.parentMessage == null) { - stream = streamChannel.channel.state.messagesStream; - } else { - streamChannel.getReplies(widget.parentMessage.id); - stream = streamChannel.channel.state.threadsStream - .where((threads) => threads.containsKey(widget.parentMessage.id)) - .map((threads) => threads[widget.parentMessage.id]); - } - - _streamListener = stream - .map((messages) => - messages - ?.where((m) => - !(m.status == MessageSendingStatus.FAILED && m.isDeleted)) - ?.toList() ?? - []) - .listen((newMessages) { - newMessages = newMessages.reversed.toList(); - if (_messages.isEmpty || - newMessages.isEmpty || - newMessages.first.id != _messages.first.id) { - if (!_scrollController.hasClients || - _scrollController.offset < _newMessageLoadingOffset) { - if (streamChannel.channel.state.unreadCount > 0) { - streamChannel.channel.markRead(); - } - setState(() { - _messages = newMessages; - }); - } else if (newMessages.first.user.id == - streamChannel.channel.client.state.user.id) { - _scrollController.jumpTo(0); - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _messages = newMessages; - }); - }); - } else { - _newMessageList = newMessages; - } - } else { - setState(() { - _messages = newMessages; + _messageNewListener = + streamChannel.channel.on(EventType.messageNew).listen((event) { + final firstElementInViewport = + _itemPositionListener.itemPositions.value.first; + if (event.message.user.id == streamChannel.channel.client.state.user.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.jumpTo( + index: 0, + ); }); + } else { + if (firstElementInViewport.index != 0) { + _scrollController.jumpTo( + index: firstElementInViewport.index + 1, + alignment: firstElementInViewport.itemLeadingEdge, + ); + } + } + if (firstElementInViewport.index == 0) { + streamChannel.channel.markRead(); } }); + if (widget.parentMessage != null) { + streamChannel.getReplies(widget.parentMessage.id); + } + _getOnThreadTap(); + super.initState(); } void _getOnThreadTap() { @@ -696,7 +681,7 @@ class _MessageListViewState extends State { @override void dispose() { - _streamListener.cancel(); + _messageNewListener?.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 70b8188a..6b2aeff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,9 @@ dependencies: flutter_app_badger: ^1.1.2 photo_view: ^0.10.1 rxdart: ^0.24.1 + scrollable_positioned_list: ^0.1.8 jiffy: ^3.0.1 + flutter_svg: ^0.19.1 flutter_portal: ^0.3.0 cached_network_image: ^2.2.0+1 flutter_markdown: ^0.5.0 @@ -25,18 +27,20 @@ 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 - widgets_visibility_provider: ^2.0.2 photo_gallery: ^0.3.0 flutter: assets: - - images/giphy_icon.png + - images/ + - svgs/ fonts: - family: stream-icons fonts: diff --git a/svgs/giphy_icon.svg b/svgs/giphy_icon.svg new file mode 100644 index 00000000..e1a1d4bb --- /dev/null +++ b/svgs/giphy_icon.svg @@ -0,0 +1,8 @@ + + + + + + + +