Add Message Reply feature

Signed-off-by: Sahil Kumar <[email protected]>
This commit is contained in:
Sahil Kumar
2020-12-07 22:29:40 +05:30
parent ab89714d1c
commit 914a82bb49
12 changed files with 858 additions and 217 deletions
+31 -5
View File
@@ -415,10 +415,31 @@ class ChannelQuerySearchResultPage extends StatelessWidget {
}
}
class ChannelPage extends StatelessWidget {
const ChannelPage({
Key key,
}) : super(key: key);
class ChannelPage extends StatefulWidget {
@override
_ChannelPageState createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
Message _replyMessage;
FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _reply(Message message) {
setState(() => _replyMessage = message);
_focusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
@@ -433,6 +454,8 @@ class ChannelPage extends StatelessWidget {
child: Stack(
children: <Widget>[
MessageListView(
onMessageSwiped: _reply,
onReplyTap: _reply,
threadBuilder: (_, parentMessage) {
return ThreadPage(
parent: parentMessage,
@@ -458,7 +481,10 @@ class ChannelPage extends StatelessWidget {
],
),
),
MessageInput(),
MessageInput(
focusNode: _focusNode,
replyMessage: _replyMessage,
),
],
),
);
+5
View File
@@ -0,0 +1,5 @@
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${this.substring(1)}";
}
}
+1 -60
View File
@@ -32,7 +32,7 @@ class FileAttachment extends StatelessWidget {
child: Row(
children: [
Container(
child: _getFileTypeImage(attachment.extraData['mime_type']),
child: getFileTypeImage(attachment.extraData['mime_type']),
height: 40.0,
width: 33.33,
margin: EdgeInsets.all(8.0),
@@ -116,63 +116,4 @@ class FileAttachment extends StatelessWidget {
),
);
}
StreamSvgIcon _getFileTypeImage(String type) {
switch (type) {
case '7z':
return StreamSvgIcon.filetype_7z();
break;
case 'csv':
return StreamSvgIcon.filetype_csv();
break;
case 'doc':
return StreamSvgIcon.filetype_doc();
break;
case 'docx':
return StreamSvgIcon.filetype_docx();
break;
case 'html':
return StreamSvgIcon.filetype_html();
break;
case 'md':
return StreamSvgIcon.filetype_md();
break;
case 'odt':
return StreamSvgIcon.filetype_odt();
break;
case 'pdf':
return StreamSvgIcon.filetype_pdf();
break;
case 'ppt':
return StreamSvgIcon.filetype_ppt();
break;
case 'pptx':
return StreamSvgIcon.filetype_pptx();
break;
case 'rar':
return StreamSvgIcon.filetype_rar();
break;
case 'rtf':
return StreamSvgIcon.filetype_rtf();
break;
case 'tar':
return StreamSvgIcon.filetype_tar();
break;
case 'txt':
return StreamSvgIcon.filetype_txt();
break;
case 'xls':
return StreamSvgIcon.filetype_xls();
break;
case 'xlsx':
return StreamSvgIcon.filetype_xlsx();
break;
case 'zip':
return StreamSvgIcon.filetype_zip();
break;
default:
return StreamSvgIcon.filetype_Generic();
break;
}
}
}
+34 -5
View File
@@ -15,7 +15,8 @@ import 'stream_chat_theme.dart';
class MessageActionsModal extends StatelessWidget {
final Widget Function(BuildContext, Message) editMessageInputBuilder;
final void Function(Message) onThreadTap;
final void Function(Message) onThreadReplyTap;
final void Function(Message) onReplyTap;
final Message message;
final MessageTheme messageTheme;
final bool showReactions;
@@ -23,6 +24,7 @@ class MessageActionsModal extends StatelessWidget {
final bool showCopyMessage;
final bool showEditMessage;
final bool showReply;
final bool showThreadReply;
final bool reverse;
final ShapeBorder messageShape;
final DisplayWidget showUserAvatar;
@@ -34,9 +36,11 @@ class MessageActionsModal extends StatelessWidget {
this.showReactions = true,
this.showDeleteMessage = true,
this.showEditMessage = true,
this.onThreadTap,
this.onReplyTap,
this.onThreadReplyTap,
this.showCopyMessage = true,
this.showReply = true,
this.showThreadReply = true,
this.showUserAvatar = DisplayWidget.show,
this.editMessageInputBuilder,
this.messageShape,
@@ -114,6 +118,7 @@ class MessageActionsModal extends StatelessWidget {
showReactions: false,
showUsername: false,
showReplyIndicator: false,
showThreadReplyIndicator: false,
showUserAvatar: showUserAvatar,
showTimestamp: false,
translateUserAvatar: false,
@@ -158,6 +163,12 @@ class MessageActionsModal extends StatelessWidget {
message.status == null) &&
message.parentId == null)
_buildReplyButton(context),
if (showThreadReply &&
(message.status ==
MessageSendingStatus.SENT ||
message.status == null) &&
message.parentId == null)
_buildThreadReplyButton(context),
if (showEditMessage)
_buildEditMessage(context),
if (showDeleteMessage)
@@ -181,6 +192,24 @@ class MessageActionsModal extends StatelessWidget {
);
}
Widget _buildReplyButton(BuildContext context) {
return ListTile(
title: Text(
'Reply',
style: Theme.of(context).textTheme.headline6,
),
leading: StreamSvgIcon.reply(
color: StreamChatTheme.of(context).primaryIconTheme.color,
),
onTap: () {
Navigator.pop(context);
if (onReplyTap != null) {
onReplyTap(message);
}
},
);
}
Widget _buildDeleteButton(BuildContext context) {
return ListTile(
title: Text(
@@ -313,7 +342,7 @@ class MessageActionsModal extends StatelessWidget {
);
}
Widget _buildReplyButton(BuildContext context) {
Widget _buildThreadReplyButton(BuildContext context) {
return ListTile(
title: Text(
'Thread reply',
@@ -324,8 +353,8 @@ class MessageActionsModal extends StatelessWidget {
),
onTap: () {
Navigator.pop(context);
if (onThreadTap != null) {
onThreadTap(message);
if (onThreadReplyTap != null) {
onThreadReplyTap(message);
}
},
);
+141 -59
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart';
@@ -21,9 +22,10 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart';
import 'package:photo_manager/photo_manager.dart';
import 'extension.dart';
import '../stream_chat_flutter.dart';
import 'reply_message_widget.dart';
import 'stream_channel.dart';
typedef FileUploader = Future<String> Function(PlatformFile, Channel);
@@ -108,6 +110,7 @@ class MessageInput extends StatefulWidget {
this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders,
this.focusNode,
this.replyMessage,
}) : super(key: key);
/// Message to edit
@@ -156,6 +159,9 @@ class MessageInput extends StatefulWidget {
/// The focus node associated to the TextField
final FocusNode focusNode;
///
final Message replyMessage;
@override
MessageInputState createState() => MessageInputState();
@@ -167,7 +173,7 @@ class MessageInput extends StatefulWidget {
if (messageInputState == null) {
throw Exception(
'You must have a MessageInput widget as anchestor of your widget tree');
'You must have a MessageInput widget as ancestor of your widget tree');
}
return messageInputState;
@@ -197,9 +203,13 @@ class MessageInputState extends State<MessageInput> {
/// The editing controller passed to the input TextField
TextEditingController textEditingController;
Message _replyMessage;
bool get _hasReplyMessage => _replyMessage != null;
@override
Widget build(BuildContext context) {
return SafeArea(
Widget child = SafeArea(
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy > 0) {
@@ -214,8 +224,31 @@ class MessageInputState extends State<MessageInput> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_hasReplyMessage)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: StreamSvgIcon.reply(
color: Colors.black.withOpacity(0.2),
),
),
Text(
'Reply to Message',
style: TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.close_small(),
onPressed: () {
setState(() => _replyMessage = null);
},
),
],
),
Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _buildTextField(context),
),
if (widget.parentMessage != null)
@@ -228,6 +261,14 @@ class MessageInputState extends State<MessageInput> {
),
),
);
if (widget.editMessage == null) {
child = Material(
color: Colors.white,
elevation: 8,
child: child,
);
}
return child;
}
Flex _buildTextField(BuildContext context) {
@@ -332,6 +373,9 @@ class MessageInputState extends State<MessageInput> {
_actionsShrunk = false;
});
},
visualDensity: VisualDensity.compact,
splashRadius: 24,
padding: const EdgeInsets.all(0),
icon: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context).accentColor,
),
@@ -356,12 +400,13 @@ class MessageInputState extends State<MessageInput> {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.0),
border: Border.all(
color: Colors.grey,
color: Colors.black.withOpacity(0.16),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildRepyToMessage(),
_buildAttachments(),
LimitedBox(
maxHeight: widget.maxHeight,
@@ -436,6 +481,7 @@ class MessageInputState extends State<MessageInput> {
}
Timer _debounce;
void _onChanged(BuildContext context, String s) {
if (_debounce?.isActive == true) _debounce.cancel();
_debounce = Timer(
@@ -799,7 +845,7 @@ class MessageInputState extends State<MessageInput> {
Widget _buildPickerSection() {
var _attachmentContainsFile =
_attachments.any((element) => element.attachment.type == 'file');
_attachments.any((element) => element.attachment?.type == 'file');
switch (_filePickerIndex) {
case 0:
@@ -1232,6 +1278,25 @@ class MessageInputState extends State<MessageInput> {
_commandsOverlay = null;
}
Widget _buildRepyToMessage() {
if (!_hasReplyMessage) {
return Offstage();
}
final containsUrl = _replyMessage.attachments
?.any((element) => element.ogScrapeUrl != null) ==
true;
return Transform(
transform: Matrix4.rotationY(pi),
alignment: Alignment.center,
child: ReplyMessageWidget(
reverse: true,
showBorder: !containsUrl,
message: _replyMessage,
messageTheme: StreamChatTheme.of(context).otherMessageTheme,
),
);
}
Widget _buildAttachments() {
return _attachments.isEmpty
? Container()
@@ -1437,57 +1502,68 @@ class MessageInputState extends State<MessageInput> {
}
Widget _buildCommandButton() {
return InkWell(
child: Padding(
padding:
const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0),
child: StreamSvgIcon.lightning(
return Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
icon: StreamSvgIcon.lightning(
color: Color(0xFF000000).withAlpha(128),
),
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () {
if (_commandsOverlay == null) {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
} else {
_commandsOverlay?.remove();
_commandsOverlay = null;
}
},
),
onTap: () {
if (_commandsOverlay == null) {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
} else {
_commandsOverlay?.remove();
_commandsOverlay = null;
}
},
);
}
Widget _buildAttachmentButton() {
var padding = widget.editMessage == null ? 4.0 : 8.0;
return Center(
child: InkWell(
child: Padding(
padding:
EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0),
child: StreamSvgIcon.attach(
child: Padding(
padding:
EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0),
child: IconButton(
icon: StreamSvgIcon.attach(
color: _openFilePickerSection
? StreamChatTheme.of(context).accentColor
: Color(0xFF000000).withAlpha(128),
),
),
onTap: () async {
_emojiOverlay?.remove();
_emojiOverlay = null;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () async {
_emojiOverlay?.remove();
_emojiOverlay = null;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
if (_openFilePickerSection) {
setState(() {
_animateContainer = true;
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
} else {
showAttachmentModal();
}
},
if (_openFilePickerSection) {
setState(() {
_animateContainer = true;
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
} else {
showAttachmentModal();
}
},
),
),
);
}
@@ -1786,15 +1862,11 @@ class MessageInputState extends State<MessageInput> {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: InkWell(
onTap: () {
sendMessage();
},
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color: Colors.grey,
),
)),
),
);
}
@@ -1802,11 +1874,16 @@ class MessageInputState extends State<MessageInput> {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () {
sendMessage();
},
child: StreamSvgIcon(
child: IconButton(
onPressed: sendMessage,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(0),
splashRadius: 24,
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
icon: StreamSvgIcon(
assetName: _getSendIcon(),
color: StreamChatTheme.of(context).accentColor,
),
@@ -1918,6 +1995,8 @@ class MessageInputState extends State<MessageInput> {
void initState() {
super.initState();
_replyMessage = widget.replyMessage;
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames = Emoji.all().map((e) => e.name);
@@ -1970,6 +2049,7 @@ class MessageInputState extends State<MessageInput> {
}
bool _initialized = false;
@override
void didChangeDependencies() {
if (widget.editMessage != null && !_initialized) {
@@ -1978,6 +2058,14 @@ class MessageInputState extends State<MessageInput> {
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(MessageInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.replyMessage?.id != _replyMessage?.id) {
_replyMessage = widget.replyMessage;
}
}
}
class _SendingAttachment {
@@ -1994,12 +2082,6 @@ class _SendingAttachment {
});
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${this.substring(1)}";
}
}
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Returns the first item of the tuple
+60 -40
View File
@@ -13,6 +13,7 @@ import 'package:visibility_detector/visibility_detector.dart';
import '../stream_chat_flutter.dart';
import 'date_divider.dart';
import 'stream_channel.dart';
import 'swipeable.dart';
typedef MessageBuilder = Widget Function(
BuildContext,
@@ -26,6 +27,9 @@ typedef ParentMessageBuilder = Widget Function(
typedef ThreadBuilder = Widget Function(BuildContext context, Message parent);
typedef ThreadTapCallback = void Function(Message, Widget);
typedef OnMessageSwiped = void Function(Message);
typedef ReplyTapCallback = void Function(Message);
class MessageDetails {
/// True if the message belongs to the current user
bool isMyMessage;
@@ -106,12 +110,14 @@ class MessageListView extends StatefulWidget {
this.parentMessage,
this.threadBuilder,
this.onThreadTap,
this.onReplyTap,
this.dateDividerBuilder,
this.scrollPhysics = const AlwaysScrollableScrollPhysics(),
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollController,
this.itemPositionListener,
this.onMessageSwiped,
}) : super(key: key);
/// Function used to build a custom message widget
@@ -153,6 +159,12 @@ class MessageListView extends StatefulWidget {
/// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics;
/// Called when message item gets swiped
final OnMessageSwiped onMessageSwiped;
///
final ReplyTapCallback onReplyTap;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -273,7 +285,7 @@ class _MessageListViewState extends State<MessageListView> {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
builder: (context) => widget.messageBuilder(
context,
MessageDetails(
context,
@@ -540,6 +552,7 @@ class _MessageListViewState extends State<MessageListView> {
return MessageWidget(
showReplyIndicator: false,
showThreadReplyIndicator: false,
message: message,
reverse: isMyMessage,
showUsername: !isMyMessage,
@@ -597,47 +610,54 @@ class _MessageListViewState extends State<MessageListView> {
final allRead = readList.length >= (channel.memberCount ?? 0) - 1;
return MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
reverse: isMyMessage,
showReactions: !message.isDeleted,
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: index == 0 ? 30 : (isNextUser ? 5 : 10),
return Swipeable(
onSwipeEnd: () => widget.onMessageSwiped(message),
backgroundIcon: StreamSvgIcon.reply(
color: StreamChatTheme.of(context).accentColor,
),
showUsername: !isMyMessage && !isNextUser,
showSendingIndicator: isMyMessage &&
(index == 0 || message.status != MessageSendingStatus.SENT)
? DisplayWidget.show
: DisplayWidget.hide,
showTimestamp: !isNextUser || readList?.isNotEmpty == true,
showEditMessage: isMyMessage,
showDeleteMessage: isMyMessage,
borderSide: isMyMessage ? BorderSide.none : null,
onThreadTap: _onThreadTap,
attachmentBorderRadiusGeometry: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(!isNextUser ? 0 : 16),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
child: MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
reverse: isMyMessage,
showReactions: !message.isDeleted,
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: index == 0 ? 30 : (isNextUser ? 5 : 10),
),
showUsername: !isMyMessage && !isNextUser,
showSendingIndicator: isMyMessage &&
(index == 0 || message.status != MessageSendingStatus.SENT)
? DisplayWidget.show
: DisplayWidget.hide,
showTimestamp: !isNextUser || readList?.isNotEmpty == true,
showEditMessage: isMyMessage,
showDeleteMessage: isMyMessage,
borderSide: isMyMessage ? BorderSide.none : null,
onThreadTap: _onThreadTap,
onReplyTap: widget.onReplyTap,
attachmentBorderRadiusGeometry: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(!isNextUser ? 0 : 16),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
),
attachmentPadding: const EdgeInsets.all(2),
borderRadiusGeometry: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(!isNextUser ? 0 : 16),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
),
showUserAvatar: isMyMessage
? DisplayWidget.gone
: (isNextUser ? DisplayWidget.hide : DisplayWidget.show),
messageTheme: isMyMessage
? StreamChatTheme.of(context).ownMessageTheme
: StreamChatTheme.of(context).otherMessageTheme,
readList: readList,
allRead: allRead,
),
attachmentPadding: const EdgeInsets.all(2),
borderRadiusGeometry: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(!isNextUser ? 0 : 16),
topRight: Radius.circular(16),
bottomRight: Radius.circular(16),
),
showUserAvatar: isMyMessage
? DisplayWidget.gone
: (isNextUser ? DisplayWidget.hide : DisplayWidget.show),
messageTheme: isMyMessage
? StreamChatTheme.of(context).ownMessageTheme
: StreamChatTheme.of(context).otherMessageTheme,
readList: readList,
allRead: allRead,
);
}
+1 -1
View File
@@ -105,7 +105,7 @@ class MessageReactionsModal extends StatelessWidget {
showReactions: false,
showUsername: false,
showUserAvatar: showUserAvatar,
showReplyIndicator: false,
showThreadReplyIndicator: false,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: DisplayWidget.gone,
+43 -47
View File
@@ -11,11 +11,13 @@ import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/message_actions_modal.dart';
import 'package:stream_chat_flutter/src/message_reactions_modal.dart';
import 'package:stream_chat_flutter/src/reaction_bubble.dart';
import 'package:stream_chat_flutter/src/reply_message_widget.dart';
import 'package:stream_chat_flutter/src/url_attachment.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'image_group.dart';
import 'message_text.dart';
import 'extension.dart';
typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment);
@@ -46,6 +48,7 @@ class MessageWidget extends StatefulWidget {
/// The function called when tapping on replies
final void Function(Message) onThreadTap;
final void Function(Message) onReplyTap;
final Widget Function(BuildContext, Message) editMessageInputBuilder;
final Widget Function(BuildContext, Message) textBuilder;
@@ -55,6 +58,9 @@ class MessageWidget extends StatefulWidget {
/// The message
final Message message;
/// The replyMessage
final Message replyMessage;
/// The message theme
final MessageTheme messageTheme;
@@ -99,6 +105,9 @@ class MessageWidget extends StatefulWidget {
final bool allRead;
/// If true the widget will show the thread reply indicator
final bool showThreadReplyIndicator;
/// If true the widget will show the reply indicator
final bool showReplyIndicator;
@@ -127,6 +136,7 @@ class MessageWidget extends StatefulWidget {
Key key,
@required this.message,
@required this.messageTheme,
this.replyMessage,
this.reverse = false,
this.translateUserAvatar = true,
this.shape,
@@ -140,6 +150,8 @@ class MessageWidget extends StatefulWidget {
this.showUserAvatar = DisplayWidget.show,
this.showSendingIndicator = DisplayWidget.show,
this.showReplyIndicator = true,
this.showThreadReplyIndicator = true,
this.onReplyTap,
this.onThreadTap,
this.showUsername = true,
this.showTimestamp = true,
@@ -213,6 +225,8 @@ class MessageWidget extends StatefulWidget {
}
class _MessageWidgetState extends State<MessageWidget> {
bool get _hasReplyMessage => widget.replyMessage != null;
@override
Widget build(BuildContext context) {
var leftPadding = widget.showUserAvatar != DisplayWidget.gone
@@ -230,6 +244,9 @@ class _MessageWidgetState extends State<MessageWidget> {
widget.message.attachments?.any((element) => element.type == 'file') ==
true;
final isMyMessage =
widget.message.user.id == StreamChat.of(context).user.id;
return Portal(
child: Padding(
padding: widget.padding ?? EdgeInsets.all(8),
@@ -306,7 +323,8 @@ class _MessageWidgetState extends State<MessageWidget> {
clipBehavior: Clip.antiAlias,
shape: widget.shape ??
RoundedRectangleBorder(
side: isOnlyEmoji
side: isOnlyEmoji &&
!_hasReplyMessage
? BorderSide.none
: widget.borderSide ??
BorderSide(
@@ -333,6 +351,19 @@ class _MessageWidgetState extends State<MessageWidget> {
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (_hasReplyMessage)
ReplyMessageWidget(
message:
widget.replyMessage,
messageTheme: isMyMessage
? StreamChatTheme.of(
context)
.otherMessageTheme
: StreamChatTheme.of(
context)
.ownMessageTheme,
reverse: widget.reverse,
),
..._parseAttachments(context),
if (widget.message.text
.trim()
@@ -367,7 +398,7 @@ class _MessageWidgetState extends State<MessageWidget> {
),
],
),
if (widget.showReplyIndicator &&
if (widget.showThreadReplyIndicator &&
widget.message.replyCount > 0)
_buildReplyIndicator(leftPadding),
],
@@ -393,7 +424,7 @@ class _MessageWidgetState extends State<MessageWidget> {
var splitList = host.split('.');
var hostName = splitList.length == 3 ? splitList[1] : splitList[0];
var hostDisplayName = urlAttachment.authorName?.capitalize() ??
_getWebsiteName(hostName.toLowerCase()) ??
getWebsiteName(hostName.toLowerCase()) ??
hostName.capitalize();
return UrlAttachment(
@@ -562,14 +593,16 @@ class _MessageWidgetState extends State<MessageWidget> {
showDeleteMessage: widget.showDeleteMessage,
message: widget.message,
editMessageInputBuilder: widget.editMessageInputBuilder,
onThreadTap: widget.onThreadTap,
onReplyTap: widget.onReplyTap,
onThreadReplyTap: widget.onThreadTap,
showEditMessage: widget.showEditMessage &&
widget.message.attachments
?.any((element) => element.type == 'giphy') !=
true,
showReactions: widget.showReactions,
showReply:
widget.showReplyIndicator && widget.onThreadTap != null,
showReply: widget.showReplyIndicator,
showThreadReply:
widget.showThreadReplyIndicator && widget.onThreadTap != null,
),
);
});
@@ -878,6 +911,10 @@ class _MessageWidgetState extends State<MessageWidget> {
final isOnlyEmoji =
widget.message.text.characters.every((c) => Emoji.byChar(c) != null);
if (_hasReplyMessage) {
return widget.messageTheme.messageBackgroundColor;
}
if ((widget.message.status == MessageSendingStatus.FAILED ||
widget.message.status == MessageSendingStatus.FAILED_UPDATE ||
widget.message.status == MessageSendingStatus.FAILED_DELETE)) {
@@ -918,45 +955,4 @@ class _MessageWidgetState extends State<MessageWidget> {
return;
}
}
String _getWebsiteName(String hostName) {
switch (hostName) {
case 'reddit':
return 'Reddit';
case 'youtube':
return 'Youtube';
case 'wikipedia':
return 'Wikipedia';
case 'twitter':
return 'Twitter';
case 'facebook':
return 'Facebook';
case 'amazon':
return 'Amazon';
case 'yelp':
return 'Yelp';
case 'imdb':
return 'IMDB';
case 'pinterest':
return 'Pinterest';
case 'tripadvisor':
return 'TripAdvisor';
case 'instagram':
return 'Instagram';
case 'walmart':
return 'Walmart';
case 'craigslist':
return 'Craigslist';
case 'ebay':
return 'eBay';
case 'linkedin':
return 'LinkedIn';
case 'google':
return 'Google';
case 'apple':
return 'Apple';
default:
return null;
}
}
}
+264
View File
@@ -0,0 +1,264 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:emojis/emoji.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'attachment_error.dart';
import 'image_attachment.dart';
import 'message_text.dart';
import 'stream_chat_theme.dart';
import 'user_avatar.dart';
import 'utils.dart';
///
class ReplyMessageWidget extends StatelessWidget {
/// The message
final Message message;
/// The message theme
final MessageTheme messageTheme;
/// If true the widget will be mirrored
final bool reverse;
/// If true the message will show a grey border
final bool showBorder;
/// limit of the text message shown
final int textLimit;
final Map<String, Widget Function(Attachment attachment)> _attachmentBuilders;
///
ReplyMessageWidget({
Key key,
@required this.message,
@required this.messageTheme,
this.reverse = false,
this.showBorder = false,
this.textLimit = 170,
}) : _attachmentBuilders = {
'image': (attachment) {
return ImageAttachment(
attachment: attachment,
message: message,
messageTheme: messageTheme,
size: Size(32, 32),
);
},
'video': (attachment) {
final size = Size(32, 32);
if (attachment.thumbUrl != null) {
return Container(
height: size.height,
width: size.width,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: CachedNetworkImageProvider(
attachment.thumbUrl,
),
),
),
);
}
return AttachmentError(
attachment: attachment,
size: size,
);
},
'giphy': (attachment) {
final size = Size(32, 32);
return 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,
);
},
'file': (attachment) {
return Container(
height: 32,
width: 32,
child: getFileTypeImage(attachment.extraData['mime_type']),
);
},
},
super(key: key);
bool get _hasAttachments => message.attachments?.isNotEmpty == true;
bool get _containsScrapeUrl =>
message.attachments?.any((element) => element.ogScrapeUrl != null) ==
true;
bool get _containsText => message?.text?.isNotEmpty == true;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 6, right: 4, left: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _buildMessage(context)),
SizedBox(width: 4),
_buildUserAvatar(),
],
),
);
}
Widget _buildMessage(BuildContext context) {
final children = [
if (_hasAttachments) ...[
_parseAttachments(context),
SizedBox(width: 8),
],
Expanded(child: _buildTextMessage()),
];
return Container(
constraints: BoxConstraints(
minHeight: 48.0,
),
decoration: BoxDecoration(
color: _getBackgroundColor(),
border: showBorder ? Border.all(color: Colors.black.withOpacity(0.08)) : null,
borderRadius: BorderRadius.only(
topRight: Radius.circular(12),
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment:
reverse ? MainAxisAlignment.end : MainAxisAlignment.start,
children: reverse ? children.reversed.toList() : children,
),
);
}
Widget _buildTextMessage() {
final isOnlyEmoji =
message.text.characters.every((c) => Emoji.byChar(c) != null);
var msg = _hasAttachments && !_containsText
? message.copyWith(text: message.attachments.last?.title ?? 'File')
: message;
if (msg.text.length > textLimit) {
msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...');
}
return Transform(
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: MessageText(
message: msg,
messageTheme: isOnlyEmoji && _containsText
? messageTheme.copyWith(
messageText: messageTheme.messageText.copyWith(
fontSize: 24,
))
: messageTheme,
),
);
}
Widget _buildUrlAttachment(Attachment attachment) {
final size = Size(32, 32);
if (attachment.thumbUrl != null) {
return Container(
height: size.height,
width: size.width,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: CachedNetworkImageProvider(
attachment.imageUrl,
),
),
),
);
}
return AttachmentError(
attachment: attachment,
size: size,
);
}
Widget _parseAttachments(BuildContext context) {
Widget child;
Attachment attachment;
if (_containsScrapeUrl) {
attachment = message.attachments.firstWhere(
(element) => element.ogScrapeUrl != null,
);
child = _buildUrlAttachment(attachment);
} else {
attachment = message.attachments.last;
final attachmentBuilder = _attachmentBuilders[attachment.type];
if (attachmentBuilder == null) {
child = Offstage();
}
child = attachmentBuilder(attachment);
}
return Material(
clipBehavior: Clip.hardEdge,
color: Colors.transparent,
shape: attachment.type == 'file' ? null : _getDefaultShape(context),
child: child,
);
}
ShapeBorder _getDefaultShape(BuildContext context) {
return RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white.withAlpha(24)
: Colors.black.withAlpha(24),
),
borderRadius: BorderRadius.circular(8),
);
}
Widget _buildUserAvatar() {
return Transform(
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: UserAvatar(
user: message.user,
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
showOnlineStatus: false,
),
),
);
}
Color _getBackgroundColor() {
if (_containsScrapeUrl) {
return Color(0xFFE9F2FF);
}
return messageTheme.messageBackgroundColor;
}
}
+12
View File
@@ -326,6 +326,18 @@ class StreamSvgIcon extends StatelessWidget {
);
}
factory StreamSvgIcon.reply({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'Icon_curve_line_left_up_big.svg',
color: color,
width: size,
height: size,
);
}
factory StreamSvgIcon.edit({
double size,
Color color,
+162
View File
@@ -0,0 +1,162 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
///
class Swipeable extends StatefulWidget {
final Widget child;
final Widget backgroundIcon;
final VoidCallback onSwipeStart;
final VoidCallback onSwipeCancel;
final VoidCallback onSwipeEnd;
final double threshold;
///
const Swipeable({
@required this.child,
@required this.backgroundIcon,
this.onSwipeStart,
this.onSwipeCancel,
this.onSwipeEnd,
this.threshold = 82.0,
});
@override
State<StatefulWidget> createState() => _SwipeableState();
}
class _SwipeableState extends State<Swipeable> with TickerProviderStateMixin {
double _dragExtent = 0.0;
AnimationController _moveController;
AnimationController _iconMoveController;
Animation<Offset> _moveAnimation;
Animation<Offset> _iconTransitionAnimation;
Animation<double> _iconFadeAnimation;
bool _pastThreshold = false;
final _animationDuration = const Duration(milliseconds: 200);
@override
void initState() {
super.initState();
_moveController =
AnimationController(duration: _animationDuration, vsync: this);
_iconMoveController =
AnimationController(duration: _animationDuration, vsync: this);
_moveAnimation = Tween<Offset>(begin: Offset.zero, end: Offset(1.0, 0.0))
.animate(_moveController);
_iconTransitionAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset(0.4, 0.0))
.animate(_moveController);
_iconFadeAnimation =
Tween<double>(begin: 0.7, end: 1.0).animate(_iconMoveController);
final controllerValue = 0.0;
_moveController.animateTo(controllerValue);
_iconMoveController.animateTo(controllerValue);
}
@override
void dispose() {
_moveController.dispose();
_iconMoveController.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
if (widget.onSwipeStart != null) {
widget.onSwipeStart();
}
}
void _handleDragUpdate(DragUpdateDetails details) {
print(_moveAnimation.value.dx);
final delta = details.primaryDelta;
if (delta.isNegative) return;
_dragExtent += delta;
var movePastThresholdPixels = widget.threshold;
var newPos = _dragExtent.abs() / context.size.width;
if (_dragExtent.abs() > movePastThresholdPixels) {
// how many "thresholds" past the threshold we are. 1 = the threshold 2
// = two thresholds.
var n = _dragExtent.abs() / movePastThresholdPixels;
// Take the number of thresholds past the threshold, and reduce this
// number
var reducedThreshold = math.pow(n, 0.3);
var adjustedPixelPos = movePastThresholdPixels * reducedThreshold;
newPos = adjustedPixelPos / context.size.width;
if (_dragExtent > 0 && !_pastThreshold) {
_iconMoveController.value = 1;
_pastThreshold = true;
}
} else {
// Send a cancel event if the user has swiped back underneath the
// threshold
if (_pastThreshold && widget.onSwipeCancel != null) {
widget.onSwipeCancel();
}
_pastThreshold = false;
}
if (!_pastThreshold || newPos < _moveController.value) {
_iconMoveController.value = newPos;
}
_moveController.value = newPos;
}
void _handleDragEnd(DragEndDetails details) {
_moveController.animateTo(0.0, duration: _animationDuration);
_iconMoveController.animateTo(0.0, duration: _animationDuration);
_dragExtent = 0.0;
if (_pastThreshold && widget.onSwipeEnd != null) {
widget.onSwipeEnd();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
behavior: HitTestBehavior.opaque,
child: Stack(
alignment: Alignment.center,
fit: StackFit.passthrough,
children: [
SlideTransition(
position: _iconTransitionAnimation,
child: Row(
children: [
FadeTransition(
opacity: _iconFadeAnimation,
child: Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.black.withOpacity(0.08),
),
),
child: widget.backgroundIcon,
),
),
],
),
),
SlideTransition(
position: _moveAnimation,
child: widget.child,
),
],
),
);
}
}
+104
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:url_launcher/url_launcher.dart';
import 'stream_svg_icon.dart';
Future<void> launchURL(BuildContext context, String url) async {
if (await canLaunch(url)) {
await launch(url);
@@ -47,3 +49,105 @@ Future<bool> showConfirmationDialog(
/// Get random png with initials
String getRandomPicUrl(User user) =>
'https://getstream.io/random_png/?id=${user.id}&name=${user.name}';
/// Get websiteName from [hostName]
String getWebsiteName(String hostName) {
switch (hostName) {
case 'reddit':
return 'Reddit';
case 'youtube':
return 'Youtube';
case 'wikipedia':
return 'Wikipedia';
case 'twitter':
return 'Twitter';
case 'facebook':
return 'Facebook';
case 'amazon':
return 'Amazon';
case 'yelp':
return 'Yelp';
case 'imdb':
return 'IMDB';
case 'pinterest':
return 'Pinterest';
case 'tripadvisor':
return 'TripAdvisor';
case 'instagram':
return 'Instagram';
case 'walmart':
return 'Walmart';
case 'craigslist':
return 'Craigslist';
case 'ebay':
return 'eBay';
case 'linkedin':
return 'LinkedIn';
case 'google':
return 'Google';
case 'apple':
return 'Apple';
default:
return null;
}
}
///
StreamSvgIcon getFileTypeImage(String type) {
switch (type) {
case '7z':
return StreamSvgIcon.filetype_7z();
break;
case 'csv':
return StreamSvgIcon.filetype_csv();
break;
case 'doc':
return StreamSvgIcon.filetype_doc();
break;
case 'docx':
return StreamSvgIcon.filetype_docx();
break;
case 'html':
return StreamSvgIcon.filetype_html();
break;
case 'md':
return StreamSvgIcon.filetype_md();
break;
case 'odt':
return StreamSvgIcon.filetype_odt();
break;
case 'pdf':
return StreamSvgIcon.filetype_pdf();
break;
case 'ppt':
return StreamSvgIcon.filetype_ppt();
break;
case 'pptx':
return StreamSvgIcon.filetype_pptx();
break;
case 'rar':
return StreamSvgIcon.filetype_rar();
break;
case 'rtf':
return StreamSvgIcon.filetype_rtf();
break;
case 'tar':
return StreamSvgIcon.filetype_tar();
break;
case 'txt':
return StreamSvgIcon.filetype_txt();
break;
case 'xls':
return StreamSvgIcon.filetype_xls();
break;
case 'xlsx':
return StreamSvgIcon.filetype_xlsx();
break;
case 'zip':
return StreamSvgIcon.filetype_zip();
break;
default:
return StreamSvgIcon.filetype_Generic();
break;
}
}