Merge pull request #43 from GetStream/feature/messageInputActions

Feature/message input actions
This commit is contained in:
Salvatore Giordano
2020-05-01 15:52:51 +02:00
committed by GitHub
5 changed files with 257 additions and 170 deletions
-1
View File
@@ -67,7 +67,6 @@ void main() async {
final client = Client(
's2dxdhpxd94g',
logLevel: Level.INFO,
persistenceEnabled: false,
showLocalNotification: Platform.isAndroid ? showLocalNotification : null,
);
+225 -149
View File
@@ -17,6 +17,21 @@ import '../stream_chat_flutter.dart';
import 'stream_channel.dart';
typedef FileUploader = Future<String> Function(File, Channel);
typedef AttachmentThumbnailBuilder = Widget Function(
BuildContext,
_SendingAttachment,
);
enum ActionsLocation {
left,
right,
}
enum DefaultAttachmentTypes {
image,
video,
file,
}
/// Inactive state
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png)
@@ -65,6 +80,7 @@ class MessageInput extends StatefulWidget {
MessageInput({
Key key,
this.onMessageSent,
this.preMessageSending,
this.parentMessage,
this.editMessage,
this.maxHeight = 150,
@@ -73,6 +89,10 @@ class MessageInput extends StatefulWidget {
this.doImageUploadRequest,
this.doFileUploadRequest,
this.initialMessage,
this.textEditingController,
this.actions,
this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders,
}) : super(key: key);
/// Message to edit
@@ -84,6 +104,10 @@ class MessageInput extends StatefulWidget {
/// Function called after sending the message
final void Function(Message) onMessageSent;
/// Function called right before sending the message
/// Use this to transform the message
final FutureOr<Message> Function(Message) preMessageSending;
/// Parent message in case of a thread
final Message parentMessage;
@@ -102,33 +126,48 @@ class MessageInput extends StatefulWidget {
/// Override file upload request
final FileUploader doFileUploadRequest;
/// The text controller of the TextField
final TextEditingController textEditingController;
/// List of action widgets
final List<Widget> actions;
/// The location of the custom actions
final ActionsLocation actionsLocation;
/// Map that defines a thumbnail builder for an attachment type
final Map<String, AttachmentThumbnailBuilder> attachmentThumbnailBuilders;
@override
_MessageInputState createState() => _MessageInputState(
doFileUploadRequest: doFileUploadRequest,
doImageUploadRequest: doImageUploadRequest,
);
MessageInputState createState() => MessageInputState();
/// Use this method to get the current [StreamChatState] instance
static MessageInputState of(BuildContext context) {
MessageInputState messageInputState;
messageInputState = context.findAncestorStateOfType<MessageInputState>();
if (messageInputState == null) {
throw Exception(
'You must have a MessageInput widget as anchestor of your widget tree');
}
return messageInputState;
}
}
class _MessageInputState extends State<MessageInput> {
class MessageInputState extends State<MessageInput> {
final List<_SendingAttachment> _attachments = [];
final _focusNode = FocusNode();
final List<User> _mentionedUsers = [];
FileUploader doImageUploadRequest;
FileUploader doFileUploadRequest;
TextEditingController _textController;
bool _inputEnabled = true;
bool _messageIsPresent = false;
bool _typingStarted = false;
OverlayEntry _commandsOverlay, _mentionsOverlay;
_MessageInputState({
this.doImageUploadRequest,
this.doFileUploadRequest,
}) {
doImageUploadRequest ??= _uploadImage;
doFileUploadRequest ??= _uploadFile;
}
/// The editing controller passed to the input TextField
TextEditingController textEditingController;
@override
Widget build(BuildContext context) {
@@ -165,8 +204,12 @@ class _MessageInputState extends State<MessageInput> {
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
if (!widget.disableAttachments) _buildAttachmentButton(),
if (widget.actionsLocation == ActionsLocation.left)
...widget.actions ?? [],
_buildTextInput(context),
_animateSendButton(context),
if (widget.actionsLocation == ActionsLocation.right)
...widget.actions ?? [],
],
);
}
@@ -194,10 +237,10 @@ class _MessageInputState extends State<MessageInput> {
minLines: null,
maxLines: null,
onSubmitted: (_) {
_sendMessage(context);
sendMessage();
},
keyboardType: widget.keyboardType,
controller: _textController,
controller: textEditingController,
focusNode: _focusNode,
onChanged: (s) {
StreamChannel.of(context).channel.keyStroke();
@@ -216,10 +259,10 @@ class _MessageInputState extends State<MessageInput> {
Overlay.of(context).insert(_commandsOverlay);
}
if (_textController.selection.isCollapsed &&
(s[_textController.selection.start - 1] == '@' ||
_textController.text
.substring(0, _textController.selection.start)
if (textEditingController.selection.isCollapsed &&
(s[textEditingController.selection.start - 1] == '@' ||
textEditingController.text
.substring(0, textEditingController.selection.start)
.split(' ')
.last
.contains('@'))) {
@@ -279,7 +322,7 @@ class _MessageInputState extends State<MessageInput> {
}
OverlayEntry _buildCommandsOverlayEntry() {
final text = _textController.text;
final text = textEditingController.text;
final commands = StreamChannel.of(context)
.channel
.config
@@ -344,8 +387,8 @@ class _MessageInputState extends State<MessageInput> {
}
OverlayEntry _buildMentionsOverlayEntry() {
final splits = _textController.text
.substring(0, _textController.value.selection.start)
final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start)
.split('@');
final query = splits.last.toLowerCase();
@@ -391,10 +434,10 @@ class _MessageInputState extends State<MessageInput> {
splits[splits.length - 1] = m.user.name;
final rejoin = splits.join('@');
_textController.value = TextEditingValue(
textEditingController.value = TextEditingValue(
text: rejoin +
_textController.text
.substring(_textController.selection.start),
textEditingController.text.substring(
textEditingController.selection.start),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
@@ -413,7 +456,7 @@ class _MessageInputState extends State<MessageInput> {
}
void _setCommand(Command c) {
_textController.value = TextEditingValue(
textEditingController.value = TextEditingValue(
text: '/${c.name} ',
selection: TextSelection.collapsed(
offset: c.name.length + 2,
@@ -452,34 +495,7 @@ class _MessageInputState extends State<MessageInput> {
width: 50,
child: _buildAttachment(attachment),
),
Positioned(
height: 16,
width: 16,
top: 4,
right: 4,
child: RawMaterialButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
disabledElevation: 0,
hoverElevation: 0,
onPressed: () {
setState(() {
_attachments.remove(attachment);
});
},
fillColor: Colors.white.withOpacity(.5),
child: Center(
child: Icon(
Icons.close,
size: 15,
),
),
),
),
_buildRemoveButton(attachment),
attachment.uploaded
? SizedBox()
: Positioned.fill(
@@ -499,20 +515,62 @@ class _MessageInputState extends State<MessageInput> {
);
}
Positioned _buildRemoveButton(_SendingAttachment attachment) {
return Positioned(
height: 16,
width: 16,
top: 4,
right: 4,
child: RawMaterialButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
disabledElevation: 0,
hoverElevation: 0,
onPressed: () {
setState(() {
_attachments.remove(attachment);
});
},
fillColor: Colors.white.withOpacity(.5),
child: Center(
child: Icon(
Icons.close,
size: 15,
),
),
),
);
}
Widget _buildAttachment(_SendingAttachment attachment) {
switch (attachment.type) {
case FileType.image:
if (widget.attachmentThumbnailBuilders
?.containsKey(attachment.attachment.type) ==
true) {
return widget.attachmentThumbnailBuilders[attachment.attachment.type](
context,
attachment,
);
}
switch (attachment.attachment.type) {
case 'image':
case 'giphy':
return attachment.file != null
? Image.file(
attachment.file,
fit: BoxFit.cover,
)
: Image.network(
attachment.url,
attachment.attachment.imageUrl ??
attachment.attachment.thumbUrl,
fit: BoxFit.cover,
);
break;
case FileType.video:
case 'video':
return Container(
child: Icon(Icons.videocam),
color: Colors.black26,
@@ -535,7 +593,7 @@ class _MessageInputState extends State<MessageInput> {
color: Colors.transparent,
child: IconButton(
onPressed: () {
_showAttachmentModal();
showAttachmentModal();
},
icon: Icon(
Icons.add_circle_outline,
@@ -544,7 +602,8 @@ class _MessageInputState extends State<MessageInput> {
);
}
void _showAttachmentModal() {
/// Show the attachment modal, making the user choose where to pick a media from
void showAttachmentModal() {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
@@ -575,7 +634,7 @@ class _MessageInputState extends State<MessageInput> {
leading: Icon(Icons.image),
title: Text('Upload a photo'),
onTap: () {
_pickFile(FileType.image, false);
pickFile(DefaultAttachmentTypes.image, false);
Navigator.pop(context);
},
),
@@ -583,7 +642,7 @@ class _MessageInputState extends State<MessageInput> {
leading: Icon(Icons.video_library),
title: Text('Upload a video'),
onTap: () {
_pickFile(FileType.video, false);
pickFile(DefaultAttachmentTypes.video, false);
Navigator.pop(context);
},
),
@@ -591,7 +650,7 @@ class _MessageInputState extends State<MessageInput> {
leading: Icon(Icons.camera_alt),
title: Text('Photo from camera'),
onTap: () {
_pickFile(FileType.image, true);
pickFile(DefaultAttachmentTypes.image, true);
Navigator.pop(context);
},
),
@@ -599,7 +658,7 @@ class _MessageInputState extends State<MessageInput> {
leading: Icon(Icons.videocam),
title: Text('Video from camera'),
onTap: () {
_pickFile(FileType.video, true);
pickFile(DefaultAttachmentTypes.video, true);
Navigator.pop(context);
},
),
@@ -607,7 +666,7 @@ class _MessageInputState extends State<MessageInput> {
leading: Icon(Icons.insert_drive_file),
title: Text('Upload a file'),
onTap: () {
_pickFile(FileType.any, false);
pickFile(DefaultAttachmentTypes.file, false);
Navigator.pop(context);
},
),
@@ -616,20 +675,50 @@ class _MessageInputState extends State<MessageInput> {
});
}
void _pickFile(FileType type, bool camera) async {
/// Add an attachment to the sending message
/// Use this to add custom type attachments
void addAttachment(Attachment attachment) {
setState(() {
_attachments.add(_SendingAttachment(
attachment: attachment,
uploaded: true,
));
});
}
/// Pick a file from the device
/// If [camera] is true then the camera will open
void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async {
setState(() {
_inputEnabled = false;
});
File file;
String attachmentType;
if (fileType == DefaultAttachmentTypes.image) {
attachmentType = 'image';
} else if (fileType == DefaultAttachmentTypes.video) {
attachmentType = 'video';
} else if (fileType == DefaultAttachmentTypes.file) {
attachmentType = 'file';
}
if (camera) {
if (type == FileType.image) {
if (fileType == DefaultAttachmentTypes.image) {
file = await ImagePicker.pickImage(source: ImageSource.camera);
} else if (type == FileType.video) {
} else if (fileType == DefaultAttachmentTypes.video) {
file = await ImagePicker.pickVideo(source: ImageSource.camera);
}
} else {
FileType type;
if (fileType == DefaultAttachmentTypes.image) {
type = FileType.image;
} else if (fileType == DefaultAttachmentTypes.video) {
type = FileType.video;
} else if (fileType == DefaultAttachmentTypes.file) {
type = FileType.any;
}
file = await FilePicker.getFile(type: type);
}
@@ -645,16 +734,27 @@ class _MessageInputState extends State<MessageInput> {
final attachment = _SendingAttachment(
file: file,
type: type,
attachment: Attachment(
localUri: file.uri,
type: attachmentType,
),
);
setState(() {
_attachments.add(attachment);
});
final url = await _uploadAttachment(file, type, channel);
final url = await _uploadAttachment(file, fileType, channel);
attachment.url = url;
if (fileType == DefaultAttachmentTypes.image) {
attachment.attachment = attachment.attachment.copyWith(
imageUrl: url,
);
} else {
attachment.attachment = attachment.attachment.copyWith(
assetUrl: url,
);
}
setState(() {
attachment.uploaded = true;
@@ -663,14 +763,22 @@ class _MessageInputState extends State<MessageInput> {
Future<String> _uploadAttachment(
File file,
FileType type,
DefaultAttachmentTypes type,
Channel channel,
) async {
String url;
if (type == FileType.image) {
url = await doImageUploadRequest(file, channel);
if (type == DefaultAttachmentTypes.image) {
if (widget.doImageUploadRequest != null) {
url = await widget.doImageUploadRequest(file, channel);
} else {
url = await _uploadImage(file, channel);
}
} else {
url = await doFileUploadRequest(file, channel);
if (widget.doFileUploadRequest != null) {
url = await widget.doFileUploadRequest(file, channel);
} else {
url = await _uploadFile(file, channel);
}
}
return url;
}
@@ -714,7 +822,7 @@ class _MessageInputState extends State<MessageInput> {
child: IconButton(
key: Key('sendButton'),
onPressed: () {
_sendMessage(context);
sendMessage();
},
icon: Icon(
Icons.send,
@@ -725,15 +833,16 @@ class _MessageInputState extends State<MessageInput> {
);
}
void _sendMessage(BuildContext context) {
final text = _textController.text.trim();
/// Sends the current message
void sendMessage() async {
final text = textEditingController.text.trim();
if (text.isEmpty && _attachments.isEmpty) {
return;
}
final attachments = List<_SendingAttachment>.from(_attachments);
_textController.clear();
textEditingController.clear();
_attachments.clear();
setState(() {
@@ -754,22 +863,10 @@ class _MessageInputState extends State<MessageInput> {
message = widget.editMessage.copyWith(
parentId: widget.parentMessage?.id,
text: text,
attachments: widget.editMessage.attachments
.where((attachment) => attachment.type == 'giphy')
.toList() +
_getAttachments(attachments).toList(),
attachments: _getAttachments(attachments).toList(),
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
);
if (widget.editMessage.status == MessageSendingStatus.FAILED) {
sendingFuture = channel.sendMessage(message);
}
sendingFuture = StreamChat.of(context).client.updateMessage(
message,
channel.cid,
);
} else {
message = (widget.initialMessage ?? Message()).copyWith(
parentId: widget.parentMessage?.id,
@@ -778,35 +875,36 @@ class _MessageInputState extends State<MessageInput> {
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
);
sendingFuture = channel.sendMessage(message);
}
sendingFuture.whenComplete(() {
if (widget.preMessageSending != null) {
message = await widget.preMessageSending(message);
}
if (widget.editMessage == null ||
widget.editMessage.status == MessageSendingStatus.FAILED) {
sendingFuture = channel.sendMessage(message);
} else {
sendingFuture = StreamChat.of(context).client.updateMessage(
message,
channel.cid,
);
}
return sendingFuture.whenComplete(() {
if (widget.onMessageSent != null) {
widget.onMessageSent(message);
} else {
if (widget.editMessage != null) {
Navigator.pop(context);
}
}
});
}
Iterable<Attachment> _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,
localUri: attachment.file.uri,
);
return attachment.attachment;
});
}
@@ -821,7 +919,7 @@ class _MessageInputState extends State<MessageInput> {
_keyboardListener = KeyboardVisibility.onChange.listen((visible) {
if (visible) {
if (_commandsOverlay != null) {
if (_textController.text.startsWith('/')) {
if (textEditingController.text.startsWith('/')) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
@@ -830,7 +928,7 @@ class _MessageInputState extends State<MessageInput> {
}
if (_mentionsOverlay != null) {
if (_textController.text.contains('@')) {
if (textEditingController.text.contains('@')) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_mentionsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_mentionsOverlay);
@@ -847,44 +945,24 @@ class _MessageInputState extends State<MessageInput> {
}
});
if (widget.editMessage != null) {
_parseExistingMessage(widget.editMessage);
} else if (widget.initialMessage != null) {
_parseExistingMessage(widget.initialMessage);
} else {
_textController = TextEditingController();
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
}
}
void _parseExistingMessage(Message message) {
_textController = TextEditingController(text: message.text);
textEditingController.text = message.text;
_typingStarted = true;
_messageIsPresent = true;
message.attachments?.forEach((attachment) {
if (attachment.type == 'image') {
_attachments.add(_SendingAttachment(
type: FileType.image,
url: attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl ??
attachment.ogScrapeUrl,
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,
));
}
_attachments.add(_SendingAttachment(
attachment: attachment,
uploaded: true,
));
});
}
@@ -908,15 +986,13 @@ class _MessageInputState extends State<MessageInput> {
}
class _SendingAttachment {
final File file;
final FileType type;
String url;
File file;
Attachment attachment;
bool uploaded;
_SendingAttachment({
this.url,
this.file,
this.type,
this.attachment,
this.uploaded = false,
});
}
+10
View File
@@ -73,6 +73,7 @@ class MessageListView extends StatefulWidget {
this.attachmentBuilders,
this.dateDividerBuilder,
this.showAvatar = true,
this.editMessageInputBuilder,
this.scrollPhysics = const AlwaysScrollableScrollPhysics(),
}) : super(key: key);
@@ -116,9 +117,14 @@ class MessageListView extends StatefulWidget {
/// if true shows the user avatar
final bool showAvatar;
/// Builder used to build the message input to edit a message
final Widget Function(BuildContext, Message) editMessageInputBuilder;
/// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -182,6 +188,7 @@ class _MessageListViewState extends State<MessageListView> {
onMessageActions: widget.onMessageActions,
attachmentBuilders: widget.attachmentBuilders,
showAvatar: widget.showAvatar,
editMessageInputBuilder: widget.editMessageInputBuilder,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -248,6 +255,7 @@ class _MessageListViewState extends State<MessageListView> {
onMessageActions: widget.onMessageActions,
attachmentBuilders: widget.attachmentBuilders,
showAvatar: widget.showAvatar,
editMessageInputBuilder: widget.editMessageInputBuilder,
);
}
}
@@ -342,6 +350,7 @@ class _MessageListViewState extends State<MessageListView> {
onMessageActions: widget.onMessageActions,
attachmentBuilders: widget.attachmentBuilders,
showAvatar: widget.showAvatar,
editMessageInputBuilder: widget.editMessageInputBuilder,
);
}
@@ -384,6 +393,7 @@ class _MessageListViewState extends State<MessageListView> {
onMessageActions: widget.onMessageActions,
attachmentBuilders: widget.attachmentBuilders,
showAvatar: widget.showAvatar,
editMessageInputBuilder: widget.editMessageInputBuilder,
);
}
+21 -19
View File
@@ -51,6 +51,7 @@ class MessageWidget extends StatefulWidget {
this.showVideoFullScreen = true,
this.attachmentBuilders,
this.showAvatar = true,
this.editMessageInputBuilder,
}) : super(key: key);
/// Function called on mention tap
@@ -89,6 +90,9 @@ class MessageWidget extends StatefulWidget {
/// if true shows the user avatar
final bool showAvatar;
/// Builder used to build the message input to edit a message
final Widget Function(BuildContext, Message) editMessageInputBuilder;
@override
_MessageWidgetState createState() => _MessageWidgetState();
}
@@ -712,10 +716,6 @@ class _MessageWidgetState extends State<MessageWidget>
child: Icon(
Icons.close,
size: 15,
color:
Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
),
),
),
@@ -727,21 +727,23 @@ class _MessageWidgetState extends State<MessageWidget>
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: (_) {
FocusScope.of(context).unfocus();
Navigator.pop(context);
},
),
child: widget.editMessageInputBuilder != null
? widget.editMessageInputBuilder(context, widget.message)
: MessageInput(
editMessage: widget.message,
parentMessage: widget.isParent
? StreamChannel.of(context)
.channel
.state
.messages
.firstWhere((message) =>
message.id == widget.message.parentId)
: null,
onMessageSent: (_) {
FocusScope.of(context).unfocus();
Navigator.pop(context);
},
),
),
],
),
+1 -1
View File
@@ -20,7 +20,7 @@ dependencies:
file_picker: ^1.6.3+2
image_picker: ^0.6.5
flutter_keyboard_visibility: ^2.0.0
stream_chat: ^0.2.0-alpha+7
stream_chat: ^0.2.0-alpha+8
mime: ^0.9.6+3
visibility_detector: ^0.1.4
http_parser: ^3.1.4