diff --git a/example/android/settings_aar.gradle b/example/android/settings_aar.gradle new file mode 100644 index 00000000..e7b4def4 --- /dev/null +++ b/example/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 134db5c4..07bb3fa8 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,10 +1,22 @@ PODS: + - file_picker (0.0.1): + - Flutter - Flutter (1.0.0) + - flutter_plugin_android_lifecycle (0.0.1): + - Flutter - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) + - image_picker (0.0.1): + - Flutter + - keyboard_visibility (0.5.0): + - Flutter + - Reachability - path_provider (0.0.1): - Flutter + - path_provider_macos (0.0.1): + - Flutter + - Reachability (3.2) - screen (0.0.1): - Flutter - sqflite (0.0.1): @@ -22,8 +34,13 @@ PODS: - Flutter DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_plugin_android_lifecycle (from `.symlinks/plugins/flutter_plugin_android_lifecycle/ios`) + - image_picker (from `.symlinks/plugins/image_picker/ios`) + - keyboard_visibility (from `.symlinks/plugins/keyboard_visibility/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) + - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) - screen (from `.symlinks/plugins/screen/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher (from `.symlinks/plugins/url_launcher/ios`) @@ -35,12 +52,23 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - Reachability EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_plugin_android_lifecycle: + :path: ".symlinks/plugins/flutter_plugin_android_lifecycle/ios" + image_picker: + :path: ".symlinks/plugins/image_picker/ios" + keyboard_visibility: + :path: ".symlinks/plugins/keyboard_visibility/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" + path_provider_macos: + :path: ".symlinks/plugins/path_provider_macos/ios" screen: :path: ".symlinks/plugins/screen/ios" sqflite: @@ -57,9 +85,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_web/ios" SPEC CHECKSUMS: + file_picker: 408623be2125b79a4539cf703be3d4b3abe5e245 Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + flutter_plugin_android_lifecycle: 47de533a02850f070f5696a623995e93eddcdb9b FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + image_picker: e3eacd46b94694dde7cf2705955cece853aa1a8f + keyboard_visibility: 96a24de806fe6823c3ad956c01ba2ec6d056616f path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d + path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 + Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 screen: abd91ca7bf3426e1cc3646d27e9b2358d6bf07b0 sqflite: 4001a31ff81d210346b500c55b17f4d6c7589dd0 url_launcher: a1c0cc845906122c4784c542523d8cacbded5626 diff --git a/lib/src/channel_preview.dart b/lib/src/channel_preview.dart index 92664339..8174c290 100644 --- a/lib/src/channel_preview.dart +++ b/lib/src/channel_preview.dart @@ -89,7 +89,7 @@ class ChannelPreview extends StatelessWidget { initialData: channel.state.typingEvents, builder: (context, snapshot) { final typings = snapshot.data; - final opacity = channel.state.unreadCount > .0 ? 1.0 : 0.5; + final opacity = channel.state.unreadCount > 0 ? 1.0 : 0.5; return typings.isNotEmpty ? _buildTypings(typings, context, opacity) : _buildLastMessage(context, opacity); diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 6db8a574..8df9670c 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -4,14 +4,18 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:keyboard_visibility/keyboard_visibility.dart'; 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 '../stream_chat_flutter.dart'; import 'stream_channel.dart'; +/// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input_paint.png) +/// Focused state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2_paint.png) /// @@ -55,8 +59,12 @@ class MessageInput extends StatefulWidget { Key key, this.onMessageSent, this.parentMessage, + this.editMessage, }) : super(key: key); + /// Message to edit + final Message editMessage; + /// Function called after sending the message final void Function(Message) onMessageSent; @@ -68,10 +76,13 @@ class MessageInput extends StatefulWidget { } class _MessageInputState extends State { - final _textController = TextEditingController(); + final List<_SendingAttachment> _attachments = []; + final _focusNode = FocusNode(); + + TextEditingController _textController; bool _messageIsPresent = false; bool _typingStarted = false; - final List<_SendingAttachment> _attachments = []; + OverlayEntry _commandsOverlay; @override Widget build(BuildContext context) { @@ -81,84 +92,12 @@ class _MessageInputState extends State { child: Stack( overflow: Overflow.visible, children: [ - Positioned.fill( - child: Container( - width: MediaQuery.of(context).size.width, - padding: EdgeInsets.all(2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.0), - gradient: _typingStarted - ? StreamChatTheme.of(context).channelTheme.inputGradient - : null, - ), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - 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 - : Colors.black.withOpacity(.2)), - ), - ), - ), - ), - ), + _buildBorder(context), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAttachments(), - Flex( - direction: Axis.horizontal, - children: [ - _buildAttachmentButton(), - Expanded( - child: TextField( - minLines: null, - maxLines: null, - onSubmitted: (_) { - _sendMessage(context); - }, - controller: _textController, - onChanged: (s) { - StreamChannel.of(context).channel.keyStroke(); - setState(() { - _messageIsPresent = s.trim().isNotEmpty; - }); - }, - onTap: () { - setState(() { - _typingStarted = true; - }); - }, - style: Theme.of(context).textTheme.body1, - autofocus: false, - decoration: InputDecoration( - hintText: 'Write a message', - prefixText: ' ', - border: InputBorder.none, - ), - ), - ), - AnimatedCrossFade( - crossFadeState: - (_messageIsPresent || _attachments.isNotEmpty) - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: _buildSendButton(context), - secondChild: SizedBox(), - duration: Duration(milliseconds: 300), - alignment: Alignment.center, - ), - ], - ), + _buildTextField(context), ], ), ], @@ -167,6 +106,196 @@ class _MessageInputState extends State { ); } + Flex _buildTextField(BuildContext context) { + return Flex( + direction: Axis.horizontal, + children: [ + _buildAttachmentButton(), + _buildTextInput(context), + _animateSendButton(context), + ], + ); + } + + AnimatedCrossFade _animateSendButton(BuildContext context) { + return AnimatedCrossFade( + crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: _buildSendButton(context), + secondChild: SizedBox(), + duration: Duration(milliseconds: 300), + alignment: Alignment.center, + ); + } + + Expanded _buildTextInput(BuildContext context) { + return Expanded( + child: TextField( + minLines: null, + maxLines: null, + onSubmitted: (_) { + _sendMessage(context); + }, + controller: _textController, + focusNode: _focusNode, + onChanged: (s) { + StreamChannel.of(context).channel.keyStroke(); + + setState(() { + _messageIsPresent = s.trim().isNotEmpty; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + + if (s.startsWith('/')) { + _commandsOverlay = _buildOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } + }, + onTap: () { + setState(() { + _typingStarted = true; + }); + }, + style: Theme.of(context).textTheme.body1, + autofocus: false, + decoration: InputDecoration( + hintText: 'Write a message', + prefixText: ' ', + border: InputBorder.none, + ), + ), + ); + } + + 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: Colors.white, + 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 + : Colors.black.withOpacity(.2)), + ), + ), + ), + ), + ); + } + + OverlayEntry _buildOverlayEntry() { + final text = _textController.text; + final commands = StreamChannel.of(context) + .channel + .config + .commands + .where((c) => c.name.startsWith(text.replaceFirst('/', ''))) + .toList(); + + 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: Material( + color: StreamChatTheme.of(context).primaryColor, + 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: Flex( + mainAxisSize: MainAxisSize.min, + direction: Axis.vertical, + children: [ + ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: commands + .map((c) => ListTile( + title: Text.rich( + TextSpan( + text: '${c.name}', + style: TextStyle(fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' ${c.args}', + style: TextStyle( + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ), + subtitle: Text(c.description), + onTap: () { + _setCommand(c); + }, + )) + .toList(), + ), + ], + ), + ), + ), + ); + }); + } + + void _setCommand(Command c) { + setState(() { + _textController.value = TextEditingValue( + text: '/${c.name} ', + selection: TextSelection.fromPosition( + TextPosition( + offset: c.name.length + 2, + ), + ), + ); + }); + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + + Gradient _getGradient(BuildContext context) { + if (_typingStarted) { + if (widget.editMessage == null) { + return StreamChatTheme.of(context).channelTheme.inputGradient; + } + return LinearGradient( + colors: [Colors.lightGreen, Colors.green], + ); + } else { + return null; + } + } + Widget _buildAttachments() { return Wrap( direction: Axis.horizontal, @@ -234,10 +363,15 @@ class _MessageInputState extends State { Widget _buildAttachment(_SendingAttachment attachment) { switch (attachment.type) { case FileType.IMAGE: - return Image.file( - attachment.file, - fit: BoxFit.cover, - ); + return attachment.file != null + ? Image.file( + attachment.file, + fit: BoxFit.cover, + ) + : Image.network( + attachment.url, + fit: BoxFit.cover, + ); break; case FileType.VIDEO: return Container( @@ -262,71 +396,7 @@ class _MessageInputState extends State { color: Colors.transparent, child: IconButton( onPressed: () { - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), - ), - context: context, - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - 'Add a file', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: Icon(Icons.image), - title: Text('Upload a photo'), - onTap: () { - _pickFile(FileType.IMAGE, false); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.video_library), - title: Text('Upload a video'), - onTap: () { - _pickFile(FileType.VIDEO, false); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.camera_alt), - title: Text('Photo from camera'), - onTap: () { - ImagePicker.pickImage(source: ImageSource.camera); - _pickFile(FileType.IMAGE, true); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.videocam), - title: Text('Video from camera'), - onTap: () { - _pickFile(FileType.VIDEO, true); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.insert_drive_file), - title: Text('Upload a file'), - onTap: () { - _pickFile(FileType.ANY, false); - Navigator.pop(context); - }, - ), - ], - ); - }); + _showAttachmentModal(); }, icon: Icon( Icons.add_circle_outline, @@ -335,6 +405,74 @@ class _MessageInputState extends State { ); } + void _showAttachmentModal() { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Add a file', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: Icon(Icons.image), + title: Text('Upload a photo'), + onTap: () { + _pickFile(FileType.IMAGE, false); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.video_library), + title: Text('Upload a video'), + onTap: () { + _pickFile(FileType.VIDEO, false); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.camera_alt), + title: Text('Photo from camera'), + onTap: () { + ImagePicker.pickImage(source: ImageSource.camera); + _pickFile(FileType.IMAGE, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.videocam), + title: Text('Video from camera'), + onTap: () { + _pickFile(FileType.VIDEO, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.insert_drive_file), + title: Text('Upload a file'), + onTap: () { + _pickFile(FileType.ANY, false); + Navigator.pop(context); + }, + ), + ], + ); + }); + } + void _pickFile(FileType type, bool camera) async { File file; @@ -412,52 +550,145 @@ class _MessageInputState extends State { _messageIsPresent = false; _typingStarted = false; }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + FocusScope.of(context).unfocus(); - StreamChannel.of(context) - .channel - .sendMessage( - Message( - parentId: widget.parentMessage?.id, - text: text, - attachments: attachments.map((attachment) { - String type; - switch (attachment.type) { - case FileType.IMAGE: - type = 'image'; - break; - case FileType.VIDEO: - type = 'video'; - break; - default: - type = 'file'; - } - return Attachment( - imageUrl: - attachment.type == FileType.IMAGE ? attachment.url : null, - assetUrl: attachment.url, - type: type, - ); - }).toList(), - ), - ) - .then((_) { - if (widget.onMessageSent != null) { - widget.onMessageSent(Message(text: text)); + if (widget.editMessage != null) { + final message = widget.editMessage.copyWith( + parentId: widget.parentMessage?.id, + text: text, + attachments: widget.editMessage.attachments + .where((attachment) => attachment.type == 'giphy') + .toList() + + _getAttachments(attachments).toList(), + ); + StreamChat.of(context).client.updateMessage(message).then((_) { + if (widget.onMessageSent != null) { + widget.onMessageSent(message); + } + }); + } else { + final message = Message( + parentId: widget.parentMessage?.id, + text: text, + attachments: _getAttachments(attachments).toList(), + ); + StreamChannel.of(context).channel.sendMessage(message).then((_) { + if (widget.onMessageSent != null) { + widget.onMessageSent(message); + } + }); + } + } + + Iterable _getAttachments(List<_SendingAttachment> attachments) { + return attachments.map((attachment) { + String type; + switch (attachment.type) { + case FileType.IMAGE: + type = 'image'; + break; + case FileType.VIDEO: + type = 'video'; + break; + default: + type = 'file'; } + return Attachment( + imageUrl: attachment.type == FileType.IMAGE ? attachment.url : null, + assetUrl: attachment.url, + type: type, + ); }); } + + int _keyboardListener; + + @override + void initState() { + super.initState(); + + _keyboardListener = KeyboardVisibilityNotification().addNewListener( + onHide: () { + if (_commandsOverlay != null) { + _commandsOverlay.remove(); + } + }, + onShow: () { + if (_commandsOverlay != null) { + if (_textController.text.startsWith('/')) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _commandsOverlay = _buildOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + }); + } + } + }, + ); + + if (widget.editMessage != null) { + _textController = TextEditingController(text: widget.editMessage.text); + + _typingStarted = true; + _messageIsPresent = true; + + widget.editMessage.attachments.forEach((attachment) { + if (attachment.type == 'image') { + _attachments.add(_SendingAttachment( + type: FileType.IMAGE, + url: attachment.imageUrl, + uploaded: true, + )); + } else if (attachment.type == 'video') { + _attachments.add(_SendingAttachment( + type: FileType.VIDEO, + url: attachment.assetUrl, + uploaded: true, + )); + } else if (attachment.type != 'giphy') { + _attachments.add(_SendingAttachment( + type: FileType.ANY, + url: attachment.assetUrl, + uploaded: true, + )); + } + }); + } else { + _textController = TextEditingController(); + } + } + + @override + void dispose() { + _commandsOverlay?.remove(); + KeyboardVisibilityNotification().removeListener(_keyboardListener); + super.dispose(); + } + + bool _initialized = false; + @override + void didChangeDependencies() { + if (widget.editMessage != null && !_initialized) { + FocusScope.of(context).requestFocus(_focusNode); + _initialized = true; + } + super.didChangeDependencies(); + } } class _SendingAttachment { final File file; final FileType type; String url; - bool uploaded = false; + bool uploaded; _SendingAttachment({ this.url, this.file, this.type, + this.uploaded = false, }); } diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 7cb737ad..7e81cd51 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -8,7 +8,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; +import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_channel.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; @@ -33,9 +35,13 @@ class MessageWidget extends StatefulWidget { @required this.message, @required this.nextMessage, this.onThreadTap, + this.onMessageActions, this.isParent = false, }) : super(key: key); + /// Function called on long press + final Function(BuildContext, Message) onMessageActions; + /// This message final Message message; @@ -63,6 +69,7 @@ class _MessageWidgetState extends State MessageTheme messageTheme; StreamChatState streamChat; StreamChannelState streamChannel; + bool isMyMessage; @override Widget build(BuildContext context) { @@ -75,9 +82,10 @@ class _MessageWidgetState extends State final messageUserId = widget.message.user.id; final previousUserId = widget.previousMessage?.user?.id; final nextUserId = widget.nextMessage?.user?.id; - final isMyMessage = messageUserId == currentUserId; final isLastUser = previousUserId == messageUserId; final isNextUser = nextUserId == messageUserId; + + isMyMessage = messageUserId == currentUserId; final alignment = isMyMessage ? Alignment.centerRight : Alignment.centerLeft; @@ -95,11 +103,11 @@ class _MessageWidgetState extends State crossAxisAlignment: isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - _buildBubble(context, isMyMessage, isLastUser), + _buildBubble(context, isLastUser), streamChannel.channel.config.replies - ? _buildThreadIndicator(context, isMyMessage) + ? _buildThreadIndicator(context) : SizedBox(), - isNextUser ? SizedBox() : _buildTimestamp(isMyMessage, alignment), + isNextUser ? SizedBox() : _buildTimestamp(alignment), ], ), isNextUser @@ -161,7 +169,7 @@ class _MessageWidgetState extends State ); } - Widget _buildThreadIndicator(BuildContext context, bool isMyMessage) { + Widget _buildThreadIndicator(BuildContext context) { var row = [ Text( 'Replies: ${widget.message.replyCount}', @@ -203,7 +211,6 @@ class _MessageWidgetState extends State Widget _buildBubble( BuildContext context, - bool isMyMessage, bool isLastUser, ) { var nOfAttachmentWidgets = 0; @@ -216,84 +223,134 @@ class _MessageWidgetState extends State Widget attachmentWidget; if (attachment.type == 'video') { - attachmentWidget = _buildVideo(attachment, isMyMessage, isLastUser); + attachmentWidget = _buildVideo(attachment, isLastUser); } else if (attachment.type == 'image' || attachment.type == 'giphy') { - attachmentWidget = _buildImage(isMyMessage, isLastUser, attachment); + attachmentWidget = _buildImage(isLastUser, attachment); } if (attachmentWidget != null) { - final boxDecoration = _buildBoxDecoration(isMyMessage, isLastUser) + final boxDecoration = _buildBoxDecoration(isLastUser) .copyWith(color: Color(0xffebebeb)); return Padding( padding: const EdgeInsets.only(bottom: 2.0), - child: ClipRRect( - borderRadius: boxDecoration.borderRadius, - child: Container( - decoration: boxDecoration, - constraints: BoxConstraints.loose(Size.fromWidth(300)), - child: Stack( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: boxDecoration.borderRadius, + child: Container( + decoration: boxDecoration, + constraints: BoxConstraints.loose(Size.fromWidth(300)), + child: Stack( children: [ - attachmentWidget, - attachment.title != null - ? Container( - constraints: - BoxConstraints.loose(Size(300, 500)), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - attachment.title, - overflow: TextOverflow.ellipsis, - style: - messageTheme.messageText.copyWith( - color: Colors.blue, - fontWeight: FontWeight.bold, - ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + attachmentWidget, + attachment.title != null + ? Container( + constraints: + BoxConstraints.loose(Size(300, 500)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + attachment.title, + overflow: TextOverflow.ellipsis, + style: messageTheme.messageText + .copyWith( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + Text( + Uri.parse(attachment.thumbUrl) + .authority + .split('.') + .reversed + .take(2) + .toList() + .reversed + .join('.'), + overflow: TextOverflow.ellipsis, + style: messageTheme.createdAt, + ), + ], ), - Text( - Uri.parse(attachment.thumbUrl) - .authority - .split('.') - .reversed - .take(2) - .toList() - .reversed - .join('.'), - overflow: TextOverflow.ellipsis, - style: messageTheme.createdAt, - ), - ], + ), + color: Color(0xffebebeb), + ) + : SizedBox(), + ], + ), + attachment.type == 'image' && + attachment.titleLink != null + ? Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + _launchURL(attachment.titleLink), ), ), - color: Color(0xffebebeb), ) : SizedBox(), ], ), - attachment.type == 'image' && attachment.titleLink != null - ? Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _launchURL(attachment.titleLink), - ), + margin: EdgeInsets.only( + top: nOfAttachmentWidgets > 1 ? 5 : 0, + ), + ), + ), + if (attachment.actions != null) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: attachment.actions?.map((action) { + if (action.style == 'primary') { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FlatButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ) - : SizedBox(), - ], - ), - margin: EdgeInsets.only( - top: nOfAttachmentWidgets > 1 ? 5 : 0, - ), - ), + child: Text('${action.text}'), + color: action.style == 'primary' + ? StreamChatTheme.of(context).accentColor + : null, + textColor: Colors.white, + onPressed: () { + streamChannel.channel + .sendAction(widget.message.id, { + action.name: action.value, + }); + }, + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: OutlineButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Text('${action.text}'), + color: StreamChatTheme.of(context).accentColor, + onPressed: () { + streamChannel.channel + .sendAction(widget.message.id, { + action.name: action.value, + }); + }, + ), + ); + })?.toList(), + ), + ], ), ); } @@ -306,85 +363,123 @@ class _MessageWidgetState extends State if (widget.message.text.trim().isNotEmpty) { column.children.addAll( [ - IntrinsicWidth( - child: Column( - crossAxisAlignment: isMyMessage - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - streamChannel.channel.config.reactions - ? Align( - child: _buildReactions(isMyMessage), - alignment: isMyMessage - ? Alignment.centerLeft - : Alignment.centerRight, - ) - : SizedBox(), - Stack( - overflow: Overflow.visible, - children: [ - widget.message.reactionCounts?.isNotEmpty == true - ? Positioned( - left: isMyMessage ? 8 : null, - right: !isMyMessage ? 8 : null, - top: -6, - child: CustomPaint( - painter: ReactionBubblePainter(), - ), - ) - : SizedBox(), - Padding( - padding: EdgeInsets.only( - right: isMyMessage ? 0.0 : 8.0, - left: isMyMessage ? 8.0 : 0.0, - ), - child: Container( - decoration: _buildBoxDecoration(isMyMessage, - isLastUser || nOfAttachmentWidgets > 0), - padding: EdgeInsets.all(10), - constraints: BoxConstraints.loose(Size.fromWidth(300)), - child: MarkdownBody( - data: '${widget.message.text}', - onTapLink: (link) { - _launchURL(link); - }, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context).copyWith( - textTheme: Theme.of(context).textTheme.apply( - bodyColor: messageTheme.messageText.color, - decoration: - messageTheme.messageText.decoration, - decorationColor: messageTheme - .messageText.decorationColor, - decorationStyle: messageTheme - .messageText.decorationStyle, - fontFamily: - messageTheme.messageText.fontFamily, - ), - ), - ).copyWith( - p: messageTheme.messageText, + Column( + crossAxisAlignment: + isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + (streamChannel.channel.config.reactions && + nOfAttachmentWidgets == 0) + ? Align( + child: _buildReactions(), + alignment: isMyMessage + ? Alignment.centerLeft + : Alignment.centerRight, + ) + : SizedBox(), + Stack( + overflow: Overflow.visible, + children: [ + nOfAttachmentWidgets == 0 + ? _buildReactionPaint() + : SizedBox(), + Padding( + padding: EdgeInsets.only( + right: isMyMessage ? 0.0 : 8.0, + left: isMyMessage ? 8.0 : 0.0, + ), + child: Container( + decoration: _buildBoxDecoration( + isLastUser || nOfAttachmentWidgets > 0), + padding: EdgeInsets.all(10), + constraints: BoxConstraints.loose(Size.fromWidth(300)), + child: MarkdownBody( + data: '${widget.message.text}', + onTapLink: (link) { + _launchURL(link); + }, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context).copyWith( + textTheme: Theme.of(context).textTheme.apply( + bodyColor: messageTheme.messageText.color, + decoration: + messageTheme.messageText.decoration, + decorationColor: + messageTheme.messageText.decorationColor, + decorationStyle: + messageTheme.messageText.decorationStyle, + fontFamily: + messageTheme.messageText.fontFamily, + ), ), + ).copyWith( + p: messageTheme.messageText, ), ), ), - ], - ), - ], - ), + ), + ], + ), + ], ), ], ); } + if (nOfAttachmentWidgets > 0) { + column.children.insert( + 0, + streamChannel.channel.config.reactions + ? Align( + child: _buildReactions(), + alignment: + isMyMessage ? Alignment.centerLeft : Alignment.centerRight, + ) + : SizedBox(), + ); + column.children[1] = Stack( + overflow: Overflow.visible, + children: [ + Padding( + padding: EdgeInsets.only( + right: isMyMessage ? 0.0 : 8.0, + left: isMyMessage ? 8.0 : 0.0, + ), + child: column.children[1], + ), + _buildReactionPaint(), + ], + ); + } + return GestureDetector( - child: column, + child: IntrinsicWidth(child: column), onLongPress: () { - _showMessageBottomSheet(context, isMyMessage); + if (widget.message.type == 'ephemeral') { + return; + } + + if (widget.onMessageActions != null) { + widget.onMessageActions(context, widget.message); + } else { + _showMessageBottomSheet(context); + } }); } - void _showMessageBottomSheet(BuildContext context, bool isMyMessage) { + Widget _buildReactionPaint() { + return widget.message.reactionCounts?.isNotEmpty == true + ? Positioned( + left: isMyMessage ? 8 : null, + right: !isMyMessage ? 8 : null, + top: -6, + child: CustomPaint( + painter: _ReactionBubblePainter(), + ), + ) + : SizedBox(); + } + + void _showMessageBottomSheet(BuildContext context) { if (!streamChannel.channel.config.reactions && !streamChannel.channel.config.replies) { return; @@ -399,11 +494,7 @@ class _MessageWidgetState extends State ), ), context: context, - builder: (context) { - final fontSize = 30.0; - final textStyle = TextStyle( - fontSize: fontSize, - ); + builder: (_) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -412,49 +503,10 @@ class _MessageWidgetState extends State Container( color: Colors.black87, child: streamChannel.channel.config.reactions - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: reactionToEmoji.keys.map((reactionType) { - final ownReactionIndex = widget.message.ownReactions - .indexWhere((reaction) => - reaction.type == reactionType); - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - iconSize: fontSize + 10, - icon: Text( - reactionToEmoji[reactionType], - style: textStyle, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction(reactionType); - } else { - sendReaction(reactionType); - } - }, - ), - ownReactionIndex != -1 - ? Padding( - padding: - const EdgeInsets.only(bottom: 4.0), - child: Text( - widget - .message - .ownReactions[ownReactionIndex] - .score - .toString(), - style: TextStyle(color: Colors.white), - ), - ) - : SizedBox(), - ], - ); - }).toList(), + ? ReactionPicker( + channel: StreamChannel.of(context).channel, + reactionToEmoji: reactionToEmoji, + message: widget.message, ) : SizedBox(), ), @@ -478,6 +530,22 @@ class _MessageWidgetState extends State }, ) : SizedBox(), + isMyMessage + ? FlatButton( + child: Padding( + padding: const EdgeInsets.all(28.0), + child: Text( + 'Edit message', + style: Theme.of(context).textTheme.headline, + ), + ), + onPressed: () async { + Navigator.pop(context); + + _showEditBottomSheet(context); + }, + ) + : SizedBox(), streamChannel.channel.config.replies ? FlatButton( child: Padding( @@ -499,10 +567,102 @@ class _MessageWidgetState extends State }); } - Widget _buildReactions(bool isMyMessage) { + void _showEditBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + elevation: 2, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + builder: (context) { + return StreamChannel( + channel: streamChannel.channel, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 16.0, + left: 16.0, + right: 16.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Edit message', + style: Theme.of(context).textTheme.title, + ), + Container( + height: 30, + padding: const EdgeInsets.all(2.0), + child: AspectRatio( + aspectRatio: 1, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: () { + Navigator.of(context).pop(); + }, + fillColor: Colors.black.withOpacity(.1), + padding: EdgeInsets.all(4), + child: Icon( + Icons.close, + size: 15, + color: Colors.black, + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: MessageInput( + editMessage: widget.message, + parentMessage: widget.isParent + ? StreamChannel.of(context) + .channel + .state + .messages + .firstWhere((message) => + message.id == widget.message.parentId) + : null, + onMessageSent: (_) { + Navigator.pop(context); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildReactions() { return GestureDetector( onTap: () { - _showMessageBottomSheet(context, isMyMessage); + if (widget.onMessageActions != null) { + widget.onMessageActions(context, widget.message); + } else { + _showMessageBottomSheet(context); + } }, child: Padding( padding: EdgeInsets.symmetric( @@ -527,26 +687,25 @@ class _MessageWidgetState extends State } Row _buildReactionRow() { - final List children = - widget.message.reactionCounts.keys.map((reactionType) { - return Text( - reactionToEmoji[reactionType] ?? '?', - ) as Widget; - }).toList(); - - children.add(Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - widget.message.reactionCounts.values - .fold(0, (t, v) => v + t) - .toString(), - style: TextStyle(color: Colors.white), - ), - )); return Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, - children: children, + children: [ + ...widget.message.reactionCounts.keys.map((reactionType) { + return Text( + reactionToEmoji[reactionType] ?? '?', + ); + }), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + widget.message.reactionCounts.values + .fold(0, (t, v) => v + t) + .toString(), + style: TextStyle(color: Colors.white), + ), + ), + ], ); } @@ -559,18 +718,7 @@ class _MessageWidgetState extends State 'wow': '😲', }; - void sendReaction(String reactionType) { - streamChannel.channel.sendReaction(widget.message.id, reactionType); - Navigator.of(context).pop(); - } - - void removeReaction(String reactionType) { - streamChannel.channel.deleteReaction(widget.message.id, reactionType); - Navigator.of(context).pop(); - } - Widget _buildImage( - bool isMyMessage, bool isLastUser, Attachment attachment, ) { @@ -578,12 +726,21 @@ class _MessageWidgetState extends State imageUrl: attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, fit: BoxFit.cover, + placeholderFadeInDuration: Duration(milliseconds: 300), + placeholder: (_, __) { + return SizedBox( + height: 200, + width: 300, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, ); } Widget _buildVideo( Attachment attachment, - bool isMyMessage, bool isLastUser, ) { VideoPlayerController videoController; @@ -653,7 +810,7 @@ class _MessageWidgetState extends State super.dispose(); } - Widget _buildTimestamp(bool isMyMessage, Alignment alignment) { + Widget _buildTimestamp(Alignment alignment) { return Padding( padding: const EdgeInsets.only(top: 5.0), child: Text( @@ -663,7 +820,7 @@ class _MessageWidgetState extends State ); } - BoxDecoration _buildBoxDecoration(bool isMyMessage, bool isLastUser) { + BoxDecoration _buildBoxDecoration(bool isLastUser) { return BoxDecoration( border: isMyMessage ? null : Border.all(color: Colors.black.withAlpha(8)), borderRadius: BorderRadius.only( @@ -682,7 +839,7 @@ class _MessageWidgetState extends State } } -class ReactionBubblePainter extends CustomPainter { +class _ReactionBubblePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = Colors.black; diff --git a/lib/src/reaction_picker.dart b/lib/src/reaction_picker.dart new file mode 100644 index 00000000..c5186a93 --- /dev/null +++ b/lib/src/reaction_picker.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../stream_chat_flutter.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker_paint.png) +/// +/// It shows a reaction picker +/// +/// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions]. +class ReactionPicker extends StatelessWidget { + const ReactionPicker({ + Key key, + @required this.reactionToEmoji, + @required this.message, + @required this.channel, + this.size = 40, + }) : super(key: key); + + final Map reactionToEmoji; + final Message message; + final double size; + final Channel channel; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: reactionToEmoji.keys.map((reactionType) { + final ownReactionIndex = message.ownReactions + .indexWhere((reaction) => reaction.type == reactionType); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + iconSize: size, + icon: Text( + reactionToEmoji[reactionType], + style: TextStyle( + fontSize: size - 10, + ), + ), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction(context, reactionType); + } else { + sendReaction(context, reactionType); + } + }, + ), + ownReactionIndex != -1 + ? Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + message.ownReactions[ownReactionIndex].score.toString(), + style: TextStyle(color: Colors.white), + ), + ) + : SizedBox(), + ], + ); + }).toList(), + ); + } + + void sendReaction(BuildContext context, String reactionType) { + channel.sendReaction(message.id, reactionType); + Navigator.of(context).pop(); + } + + void removeReaction(BuildContext context, String reactionType) { + channel.deleteReaction(message.id, reactionType); + Navigator.of(context).pop(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3662c3ce..cacce9fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: Stream Chat official Flutter SDK. Build your own chat experience us version: 0.1.6+4 environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.2.2 <3.0.0" dependencies: flutter: @@ -20,7 +20,8 @@ dependencies: chewie: ^0.9.8+1 file_picker: ^1.4.3+2 image_picker: ^0.6.3+4 - stream_chat: ^0.1.12 + keyboard_visibility: ^0.5.6 + stream_chat: ^0.1.13 dev_dependencies: pedantic: ^1.8.0+1 diff --git a/screenshots/reaction_picker.png b/screenshots/reaction_picker.png new file mode 100644 index 00000000..eafefa31 Binary files /dev/null and b/screenshots/reaction_picker.png differ diff --git a/screenshots/reaction_picker_paint.png b/screenshots/reaction_picker_paint.png new file mode 100644 index 00000000..20028f4c Binary files /dev/null and b/screenshots/reaction_picker_paint.png differ