diff --git a/packages/flutter_widgets/lib/src/channel_preview.dart b/packages/flutter_widgets/lib/src/channel_preview.dart index eac6e3c2..3564be7a 100644 --- a/packages/flutter_widgets/lib/src/channel_preview.dart +++ b/packages/flutter_widgets/lib/src/channel_preview.dart @@ -96,12 +96,16 @@ class ChannelPreview extends StatelessWidget { Flexible(child: _buildSubtitle(context)), Builder( builder: (context) { - if (channel.state.lastMessage?.user?.id == + final lastMessage = channel.state.messages.lastWhere( + (m) => !m.isDeleted && m.shadowed != true, + orElse: () => null, + ); + if (lastMessage?.user?.id == StreamChat.of(context).user.id) { return Padding( padding: const EdgeInsets.only(right: 4.0), child: SendingIndicator( - message: channel.state.lastMessage, + message: lastMessage, size: StreamChatTheme.of(context) .channelPreviewTheme .indicatorIconSize, @@ -110,8 +114,7 @@ class ChannelPreview extends StatelessWidget { element.user.id != channel.client.state.user.id) ?.where((element) => element.lastRead - .isAfter(channel - .state.lastMessage.createdAt)) + .isAfter(lastMessage.createdAt)) ?.isNotEmpty == true, ), @@ -201,16 +204,15 @@ class ChannelPreview extends StatelessWidget { stream: channel.state.messagesStream, initialData: channel.state.messages, builder: (context, snapshot) { - final lastMessage = snapshot.data - ?.lastWhere((m) => m.shadowed != true, orElse: () => null); + final lastMessage = snapshot.data?.lastWhere( + (m) => m.shadowed != true && !m.isDeleted, + orElse: () => null); if (lastMessage == null) { return SizedBox(); } var text = lastMessage.text; - if (lastMessage.isDeleted) { - text = 'This message was deleted.'; - } else if (lastMessage.attachments != null) { + if (lastMessage.attachments != null) { final parts = [ ...lastMessage.attachments.map((e) { if (e.type == 'image') { diff --git a/packages/flutter_widgets/lib/src/message_actions_modal.dart b/packages/flutter_widgets/lib/src/message_actions_modal.dart index 9896edc4..95ecdea5 100644 --- a/packages/flutter_widgets/lib/src/message_actions_modal.dart +++ b/packages/flutter_widgets/lib/src/message_actions_modal.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -7,13 +8,14 @@ import 'package:stream_chat/stream_chat.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_svg_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; import 'message_input.dart'; import 'message_widget.dart'; import 'stream_chat.dart'; import 'stream_chat_theme.dart'; -class MessageActionsModal extends StatelessWidget { +class MessageActionsModal extends StatefulWidget { final Widget Function(BuildContext, Message) editMessageInputBuilder; final void Function(Message) onThreadReplyTap; final void Function(Message) onReplyTap; @@ -26,6 +28,7 @@ class MessageActionsModal extends StatelessWidget { final bool showResendMessage; final bool showReplyMessage; final bool showThreadReplyMessage; + final bool showFlagButton; final bool reverse; final ShapeBorder messageShape; final DisplayWidget showUserAvatar; @@ -43,22 +46,32 @@ class MessageActionsModal extends StatelessWidget { this.showReplyMessage = true, this.showResendMessage = true, this.showThreadReplyMessage = true, + this.showFlagButton = true, this.showUserAvatar = DisplayWidget.show, this.editMessageInputBuilder, this.messageShape, this.reverse = false, }) : super(key: key); + @override + _MessageActionsModalState createState() => _MessageActionsModalState(); +} + +class _MessageActionsModalState extends State { @override Widget build(BuildContext context) { + return _showMessageOptionsModal(); + } + + Widget _showMessageOptionsModal() { final size = MediaQuery.of(context).size; final user = StreamChat.of(context).user; final roughMaxSize = 2 * size.width / 3; - var messageTextLength = message.text.length; - if (message.quotedMessage != null) { - var quotedMessageLength = message.quotedMessage.text.length + 40; - if (message.quotedMessage.attachments?.isNotEmpty == true) { + var messageTextLength = widget.message.text.length; + if (widget.message.quotedMessage != null) { + var quotedMessageLength = widget.message.quotedMessage.text.length + 40; + if (widget.message.quotedMessage.attachments?.isNotEmpty == true) { quotedMessageLength += 40; } if (quotedMessageLength > messageTextLength) { @@ -66,8 +79,8 @@ class MessageActionsModal extends StatelessWidget { } } final roughSentenceSize = - messageTextLength * messageTheme.messageText.fontSize * 1.2; - final divFactor = message.attachments?.isNotEmpty == true + messageTextLength * widget.messageTheme.messageText.fontSize * 1.2; + final divFactor = widget.message.attachments?.isNotEmpty == true ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); @@ -92,22 +105,22 @@ class MessageActionsModal extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( - crossAxisAlignment: reverse + crossAxisAlignment: widget.reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (showReactions && - (message.status == MessageSendingStatus.SENT || - message.status == null)) + if (widget.showReactions && + (widget.message.status == MessageSendingStatus.SENT || + widget.message.status == null)) Align( alignment: Alignment( - user.id == message.user.id + user.id == widget.message.user.id ? (divFactor > 1.0 ? 0.0 : (1.0 - divFactor)) : (divFactor > 1.0 ? 0.0 : -(1.0 - divFactor)), 0.0), child: ReactionPicker( - message: message, - messageTheme: messageTheme, + message: widget.message, + messageTheme: widget.messageTheme, ), ), TweenAnimationBuilder( @@ -119,27 +132,28 @@ class MessageActionsModal extends StatelessWidget { child: IgnorePointer( child: MessageWidget( key: Key('MessageWidget'), - reverse: reverse, - message: message.copyWith( - text: message.text.length > 200 - ? '${message.text.substring(0, 200)}...' - : message.text, + reverse: widget.reverse, + message: widget.message.copyWith( + text: widget.message.text.length > 200 + ? '${widget.message.text.substring(0, 200)}...' + : widget.message.text, ), - messageTheme: messageTheme, + messageTheme: widget.messageTheme, showReactions: false, showUsername: false, showThreadReplyIndicator: false, showReplyMessage: false, - showUserAvatar: showUserAvatar, + showUserAvatar: widget.showUserAvatar, showTimestamp: false, translateUserAvatar: false, - showReactionPickerIndicator: showReactions && - (message.status == - MessageSendingStatus.SENT || - message.status == null), + showReactionPickerIndicator: + widget.showReactions && + (widget.message.status == + MessageSendingStatus.SENT || + widget.message.status == null), showInChannelIndicator: false, showSendingIndicator: false, - shape: messageShape, + shape: widget.messageShape, ), ), ); @@ -153,13 +167,13 @@ class MessageActionsModal extends StatelessWidget { transform: Matrix4.identity() ..scale(val) ..rotateZ(-1.0 + val), - alignment: reverse + alignment: widget.reverse ? Alignment.topRight : Alignment.topLeft, child: Padding( padding: EdgeInsets.only( - right: reverse ? 16 : 0, - left: reverse ? 0 : 48, + right: widget.reverse ? 16 : 0, + left: widget.reverse ? 0 : 48, ), child: SizedBox( width: MediaQuery.of(context).size.width * 0.75, @@ -180,25 +194,29 @@ class MessageActionsModal extends StatelessWidget { .greyWhisper, context: context, tiles: [ - if (showReplyMessage && - (message.status == + if (widget.showReplyMessage && + (widget.message.status == MessageSendingStatus.SENT || - message.status == null) && - message.parentId == null) + widget.message.status == + null) && + widget.message.parentId == null) _buildReplyButton(context), - if (showThreadReplyMessage && - (message.status == + if (widget.showThreadReplyMessage && + (widget.message.status == MessageSendingStatus.SENT || - message.status == null) && - message.parentId == null) + widget.message.status == + null) && + widget.message.parentId == null) _buildThreadReplyButton(context), - if (showResendMessage) + if (widget.showResendMessage) _buildResendMessage(context), - if (showEditMessage) + if (widget.showEditMessage) _buildEditMessage(context), - if (showCopyMessage) + if (widget.showCopyMessage) _buildCopyButton(context), - if (showDeleteMessage) + if (widget.showFlagButton) + _buildFlagButton(context), + if (widget.showDeleteMessage) _buildDeleteButton(context), ], ).toList(), @@ -218,6 +236,188 @@ class MessageActionsModal extends StatelessWidget { ); } + void _showFlagDialog() async { + final client = StreamChat.of(context).client; + + var answer = await showConfirmationDialog(context, + title: 'Flag Message', + icon: StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + question: + 'Do you want to send a copy of this message to a\nmoderator for further investigation?', + okText: 'FLAG', + cancelText: 'CANCEL'); + + if (answer) { + try { + await client.flagMessage(widget.message.id); + _showDismissAlert(); + } catch (err) { + if (json.decode(err?.body ?? {})['code'] == 4) { + _showDismissAlert(); + } else { + _showErrorAlert(); + } + } + } + } + + void _showDeleteDialog() async { + var answer = await showConfirmationDialog(context, + title: 'Delete message', + icon: StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + question: 'Are you sure you want to permanently delete this\nmessage?', + okText: 'DELETE', + cancelText: 'CANCEL'); + + if (answer) { + try { + Navigator.pop(context); + StreamChat.of(context).client.deleteMessage( + widget.message, + StreamChannel.of(context).channel.cid, + ); + } catch (err) { + _showErrorAlert(); + } + } + } + + void _showDismissAlert() { + showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + SizedBox( + height: 26.0, + ), + Text( + 'Message flagged', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text('The message has been reported to a moderator.'), + SizedBox( + height: 36.0, + ), + Container( + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text( + 'OK', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); + } + + void _showErrorAlert() { + showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + StreamSvgIcon.error( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + SizedBox( + height: 26.0, + ), + Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text('The operation couldn\'t be completed.'), + SizedBox( + height: 36.0, + ), + Container( + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text( + 'OK', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); + } + Widget _buildReplyButton(BuildContext context) { return ListTile( dense: true, @@ -235,15 +435,37 @@ class MessageActionsModal extends StatelessWidget { ), onTap: () { Navigator.pop(context); - if (onReplyTap != null) { - onReplyTap(message); + if (widget.onReplyTap != null) { + widget.onReplyTap(widget.message); } }, ); } + Widget _buildFlagButton(BuildContext context) { + return ListTile( + dense: true, + title: Row( + children: [ + StreamSvgIcon.icon_flag( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Flag', + style: StreamChatTheme.of(context).textTheme.headline, + ), + ], + ), + onTap: () { + _showFlagDialog(); + }, + ); + } + Widget _buildDeleteButton(BuildContext context) { - final isDeleteFailed = message.status == MessageSendingStatus.FAILED_DELETE; + final isDeleteFailed = + widget.message.status == MessageSendingStatus.FAILED_DELETE; return ListTile( dense: true, title: Row( @@ -262,11 +484,7 @@ class MessageActionsModal extends StatelessWidget { ], ), onTap: () { - Navigator.pop(context); - StreamChat.of(context).client.deleteMessage( - message, - StreamChannel.of(context).channel.cid, - ); + _showDeleteDialog(); }, ); } @@ -288,7 +506,7 @@ class MessageActionsModal extends StatelessWidget { ], ), onTap: () async { - await Clipboard.setData(ClipboardData(text: message.text)); + await Clipboard.setData(ClipboardData(text: widget.message.text)); Navigator.pop(context); }, ); @@ -317,7 +535,8 @@ class MessageActionsModal extends StatelessWidget { } Widget _buildResendMessage(BuildContext context) { - final isUpdateFailed = message.status == MessageSendingStatus.FAILED_UPDATE; + final isUpdateFailed = + widget.message.status == MessageSendingStatus.FAILED_UPDATE; return ListTile( dense: true, title: Row( @@ -337,9 +556,9 @@ class MessageActionsModal extends StatelessWidget { final client = StreamChat.of(context).client; final channel = StreamChannel.of(context).channel; if (isUpdateFailed) { - client.updateMessage(message, channel.cid); + client.updateMessage(widget.message, channel.cid); } else { - channel.sendMessage(message); + channel.sendMessage(widget.message); } }, ); @@ -396,10 +615,10 @@ class MessageActionsModal extends StatelessWidget { padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: editMessageInputBuilder != null - ? editMessageInputBuilder(context, message) + child: widget.editMessageInputBuilder != null + ? widget.editMessageInputBuilder(context, widget.message) : MessageInput( - editMessage: message, + editMessage: widget.message, preMessageSending: (m) { FocusScope.of(context).unfocus(); Navigator.pop(context); @@ -431,8 +650,8 @@ class MessageActionsModal extends StatelessWidget { ), onTap: () { Navigator.pop(context); - if (onThreadReplyTap != null) { - onThreadReplyTap(message); + if (widget.onThreadReplyTap != null) { + widget.onThreadReplyTap(widget.message); } }, ); diff --git a/packages/flutter_widgets/lib/src/message_list_view.dart b/packages/flutter_widgets/lib/src/message_list_view.dart index cda0d64c..bece4e7c 100644 --- a/packages/flutter_widgets/lib/src/message_list_view.dart +++ b/packages/flutter_widgets/lib/src/message_list_view.dart @@ -851,6 +851,7 @@ class _MessageListViewState extends State { showEditMessage: isMyMessage, showDeleteMessage: isMyMessage, showThreadReplyMessage: !isThreadMessage, + showFlagButton: !isMyMessage, borderSide: isMyMessage ? BorderSide.none : null, onThreadTap: _onThreadTap, onReplyTap: widget.onReplyTap, diff --git a/packages/flutter_widgets/lib/src/message_widget.dart b/packages/flutter_widgets/lib/src/message_widget.dart index e9ea4873..8118af01 100644 --- a/packages/flutter_widgets/lib/src/message_widget.dart +++ b/packages/flutter_widgets/lib/src/message_widget.dart @@ -132,6 +132,7 @@ class MessageWidget extends StatefulWidget { final bool showDeleteMessage; final bool showResendMessage; + final bool showFlagButton; final Map attachmentBuilders; /// Center user avatar with bottom of the message @@ -170,6 +171,7 @@ class MessageWidget extends StatefulWidget { this.showThreadReplyMessage = true, this.showResendMessage = true, this.showCopyMessage = true, + this.showFlagButton = true, this.onUserAvatarTap, this.onLinkTap, this.onMessageActions, @@ -689,6 +691,7 @@ class _MessageWidgetState extends State { void _showMessageActionModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; + showDialog( context: context, barrierColor: StreamChatTheme.of(context).colorTheme.overlay, @@ -725,6 +728,7 @@ class _MessageWidgetState extends State { showThreadReplyMessage: widget.showThreadReplyMessage && !isFailedState && widget.onThreadTap != null, + showFlagButton: widget.showFlagButton, ), ); }); diff --git a/packages/flutter_widgets/lib/src/stream_svg_icon.dart b/packages/flutter_widgets/lib/src/stream_svg_icon.dart index fcef384f..0d705d31 100644 --- a/packages/flutter_widgets/lib/src/stream_svg_icon.dart +++ b/packages/flutter_widgets/lib/src/stream_svg_icon.dart @@ -889,4 +889,16 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); } + + factory StreamSvgIcon.icon_flag({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_flag.svg', + color: color, + width: size, + height: size, + ); + } } diff --git a/packages/flutter_widgets/lib/src/utils.dart b/packages/flutter_widgets/lib/src/utils.dart index be10263e..0b59e54c 100644 --- a/packages/flutter_widgets/lib/src/utils.dart +++ b/packages/flutter_widgets/lib/src/utils.dart @@ -67,22 +67,29 @@ Future showConfirmationDialog( FlatButton( child: Text( cancelText, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5), - fontWeight: FontWeight.w400), + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5)), ), onPressed: () { - Navigator.of(context).pop(); + Navigator.of(context).pop(false); }, ), FlatButton( child: Text( okText, - style: TextStyle( - color: Colors.red, fontWeight: FontWeight.w400), + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed), ), onPressed: () { Navigator.pop(context, true); @@ -95,6 +102,77 @@ Future showConfirmationDialog( }); } +Future showInfoDialog( + BuildContext context, { + String title, + Widget icon, + String question, + String okText, + StreamChatThemeData theme, +}) { + return showModalBottomSheet( + backgroundColor: + theme.colorTheme.white ?? StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + if (icon != null) icon, + SizedBox( + height: 26.0, + ), + Text( + title, + style: theme.textTheme.headlineBold ?? + StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text(question), + SizedBox( + height: 36.0, + ), + Container( + color: theme.colorTheme.black.withOpacity(.08) ?? + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlatButton( + child: Text( + okText, + style: TextStyle( + color: theme.colorTheme.black.withOpacity(0.5) ?? + StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + fontWeight: FontWeight.w400), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); +} + /// Get random png with initials String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; diff --git a/packages/flutter_widgets/lib/svgs/icon_flag.svg b/packages/flutter_widgets/lib/svgs/icon_flag.svg new file mode 100644 index 00000000..0f382e94 --- /dev/null +++ b/packages/flutter_widgets/lib/svgs/icon_flag.svg @@ -0,0 +1,3 @@ + + +