Files
stream-chat-flutter/packages/stream_chat_flutter/lib/src/message_input.dart
T

2372 lines
76 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/message_list_view.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:substring_highlight/substring_highlight.dart';
import '../stream_chat_flutter.dart';
import 'attachment/attachment.dart';
import 'extension.dart';
import 'quoted_message_widget.dart';
import 'video_thumbnail_image.dart';
typedef AttachmentThumbnailBuilder = Widget Function(
BuildContext,
Attachment,
);
/// Builder function for building a mention tile
/// Use [MentionTile] for the default implementation
typedef MentionTileBuilder = Widget Function(
BuildContext context,
Member member,
);
enum ActionsLocation {
left,
right,
leftInside,
rightInside,
}
enum DefaultAttachmentTypes {
image,
video,
file,
}
/// Available locations for the sendMessage button relative to the textField
enum SendButtonLocation {
/// inside the textField
inside,
/// outside the textField
outside,
}
const _kMinMediaPickerSize = 360.0;
const _kMaxAttachmentSize = 20971520; // 20MB in Bytes
/// 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)
///
/// Widget used to enter the message and add attachments
///
/// ```dart
/// class ChannelPage extends StatelessWidget {
/// const ChannelPage({
/// Key key,
/// }) : super(key: key);
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// appBar: ChannelHeader(),
/// body: Column(
/// children: <Widget>[
/// Expanded(
/// child: MessageListView(
/// threadBuilder: (_, parentMessage) {
/// return ThreadPage(
/// parent: parentMessage,
/// );
/// },
/// ),
/// ),
/// MessageInput(),
/// ],
/// ),
/// );
/// }
/// }
/// ```
///
/// You usually put this widget in the same page of a [MessageListView] as the bottom widget.
///
/// The widget renders the ui based on the first ancestor of type [StreamChatTheme].
/// Modify it to change the widget appearance.
class MessageInput extends StatefulWidget {
/// Instantiate a new MessageInput
MessageInput({
Key key,
this.onMessageSent,
this.preMessageSending,
this.parentMessage,
this.editMessage,
this.maxHeight = 150,
this.keyboardType = TextInputType.multiline,
this.disableAttachments = false,
this.initialMessage,
this.textEditingController,
this.actions,
this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders,
this.focusNode,
this.quotedMessage,
this.onQuotedMessageCleared,
this.sendButtonLocation = SendButtonLocation.outside,
this.autofocus = false,
this.hideSendAsDm = false,
this.idleSendButton,
this.activeSendButton,
this.showCommandsButton = true,
this.mentionsTileBuilder,
}) : super(key: key);
/// Message to edit
final Message editMessage;
/// Message to start with
final Message initialMessage;
/// 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;
/// Maximum Height for the TextField to grow before it starts scrolling
final double maxHeight;
/// The keyboard type assigned to the TextField
final TextInputType keyboardType;
/// If true the attachments button will not be displayed
final bool disableAttachments;
/// Use this property to hide/show the commands button
final bool showCommandsButton;
/// Hide send as dm checkbox
final bool hideSendAsDm;
/// 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;
/// The focus node associated to the TextField
final FocusNode focusNode;
///
final Message quotedMessage;
///
final VoidCallback onQuotedMessageCleared;
/// The location of the send button
final SendButtonLocation sendButtonLocation;
/// Autofocus property passed to the TextField
final bool autofocus;
/// Send button widget in an idle state
final Widget idleSendButton;
/// Send button widget in an active state
final Widget activeSendButton;
/// Customize the tile for the mentions overlay
final MentionTileBuilder mentionsTileBuilder;
@override
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 ancestor of your widget tree');
}
return messageInputState;
}
}
class MessageInputState extends State<MessageInput> {
final _attachments = <String, Attachment>{};
final List<User> _mentionedUsers = [];
final _imagePicker = ImagePicker();
FocusNode _focusNode;
bool _inputEnabled = true;
bool _messageIsPresent = false;
bool _animateContainer = true;
bool _commandEnabled = false;
OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay;
Iterable<String> _emojiNames;
Command _chosenCommand;
bool _actionsShrunk = false;
bool _sendAsDm = false;
bool _openFilePickerSection = false;
int _filePickerIndex = 0;
double _filePickerSize = _kMinMediaPickerSize;
final KeyboardVisibilityController _keyboardVisibilityController =
KeyboardVisibilityController();
/// The editing controller passed to the input TextField
TextEditingController textEditingController;
bool get _hasQuotedMessage => widget.quotedMessage != null;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames = Emoji.all().map((e) => e.name);
if (!kIsWeb) {
_keyboardListener =
_keyboardVisibilityController.onChange.listen((visible) {
if (_focusNode.hasFocus) {
_onChanged(context, textEditingController.text);
}
});
}
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
}
textEditingController.addListener(() {
_onChanged(context, textEditingController.text);
});
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_openFilePickerSection = false;
}
});
}
@override
Widget build(BuildContext context) {
Widget child = Container(
color: StreamChatTheme.of(context).messageInputTheme.inputBackground,
child: SafeArea(
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy > 0) {
_focusNode.unfocus();
if (_openFilePickerSection) {
setState(() {
_openFilePickerSection = false;
});
}
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_hasQuotedMessage)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: StreamSvgIcon.reply(
color: StreamChatTheme.of(context)
.colorTheme
.greyGainsboro,
),
),
Text(
'Reply to Message',
style: TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.closeSmall(),
onPressed: widget.onQuotedMessageCleared,
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: _buildTextField(context),
),
if (widget.parentMessage != null && !widget.hideSendAsDm)
Padding(
padding: const EdgeInsets.only(
right: 12.0,
left: 12.0,
bottom: 12.0,
),
child: _buildDmCheckbox(),
),
_buildFilePickerSection(),
],
),
),
),
);
if (widget.editMessage == null) {
child = Material(
elevation: 8,
child: child,
);
}
return child;
}
Flex _buildTextField(BuildContext context) {
return Flex(
direction: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left)
_buildExpandActionsButton(),
_buildTextInput(context),
if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right)
_buildExpandActionsButton(),
if (widget.sendButtonLocation == SendButtonLocation.outside)
_animateSendButton(context),
],
);
}
Widget _buildDmCheckbox() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 16,
width: 16,
foregroundDecoration: BoxDecoration(
border: _sendAsDm
? null
: Border.all(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(.5),
width: 2,
),
borderRadius: BorderRadius.circular(3),
),
child: Center(
child: Material(
borderRadius: BorderRadius.circular(3),
color: _sendAsDm
? StreamChatTheme.of(context).colorTheme.accentBlue
: StreamChatTheme.of(context).colorTheme.white,
child: InkWell(
onTap: () {
setState(() {
_sendAsDm = !_sendAsDm;
});
},
child: AnimatedCrossFade(
duration: Duration(milliseconds: 300),
reverseDuration: Duration(milliseconds: 300),
crossFadeState: _sendAsDm
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: StreamSvgIcon.check(
size: 16.0,
color: StreamChatTheme.of(context).colorTheme.white,
),
secondChild: SizedBox(
height: 16,
width: 16,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
'Also send as direct message',
style: StreamChatTheme.of(context).textTheme.footnote.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5),
),
),
),
],
);
}
Widget _animateSendButton(BuildContext context) {
final sendButton = widget.activeSendButton != null
? InkWell(
onTap: sendMessage,
child: widget.activeSendButton,
)
: _buildSendButton(context);
return AnimatedCrossFade(
crossFadeState: (_messageIsPresent || _attachments.isNotEmpty)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: sendButton,
secondChild: widget.idleSendButton ?? _buildIdleSendButton(context),
duration:
StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration,
alignment: Alignment.center,
);
}
Widget _buildExpandActionsButton() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: AnimatedCrossFade(
crossFadeState: _actionsShrunk
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: IconButton(
onPressed: () => setState(() => _actionsShrunk = false),
icon: Transform.rotate(
alignment: Alignment.center,
angle: (widget.actionsLocation == ActionsLocation.right ||
widget.actionsLocation == ActionsLocation.rightInside)
? pi
: 0,
child: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context)
.messageInputTheme
.expandButtonColor,
),
),
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
),
secondChild: FittedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
if (!widget.disableAttachments) _buildAttachmentButton(),
if (widget.showCommandsButton &&
widget.editMessage == null &&
StreamChannel.of(context)
.channel
?.config
?.commands
?.isNotEmpty ==
true)
_buildCommandButton(),
...widget.actions ?? [],
].insertBetween(const SizedBox(width: 8)),
),
),
duration: Duration(milliseconds: 300),
alignment: Alignment.center,
),
);
}
Expanded _buildTextInput(BuildContext context) {
final theme = StreamChatTheme.of(context);
final margin = (widget.sendButtonLocation == SendButtonLocation.inside
? const EdgeInsets.only(right: 8.0)
: EdgeInsets.zero) +
(widget.actionsLocation != ActionsLocation.left
? const EdgeInsets.only(left: 8.0)
: EdgeInsets.zero);
return Expanded(
child: Center(
child: Container(
clipBehavior: Clip.antiAlias,
margin: margin,
decoration: BoxDecoration(
borderRadius: theme.messageInputTheme.borderRadius,
gradient: _focusNode.hasFocus
? theme.messageInputTheme.activeBorderGradient
: theme.messageInputTheme.idleBorderGradient,
),
child: Padding(
padding: const EdgeInsets.all(1.5),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: theme.messageInputTheme.borderRadius,
color: theme.messageInputTheme.inputBackground,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildReplyToMessage(),
_buildAttachments(),
LimitedBox(
maxHeight: widget.maxHeight,
child: TextField(
key: Key('messageInputText'),
enabled: _inputEnabled,
minLines: null,
maxLines: null,
onSubmitted: (_) => sendMessage(),
keyboardType: widget.keyboardType,
controller: textEditingController,
focusNode: _focusNode,
style: theme.messageInputTheme.inputTextStyle,
autofocus: widget.autofocus,
textAlignVertical: TextAlignVertical.center,
decoration: _getInputDecoration(),
textCapitalization: TextCapitalization.sentences,
),
)
],
),
),
),
),
),
);
}
InputDecoration _getInputDecoration() {
final theme = StreamChatTheme.of(context);
final passedDecoration = theme.messageInputTheme.inputDecoration;
return InputDecoration(
isDense: true,
hintText: _getHint(),
hintStyle: theme.messageInputTheme.inputTextStyle.copyWith(
color: theme.colorTheme.grey,
),
border: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11),
prefixIcon: _commandEnabled
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
constraints: BoxConstraints.tight(Size(64, 24)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorTheme.accentBlue,
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.lightning(
color: Colors.white,
size: 16.0,
),
Text(
_chosenCommand?.name?.toUpperCase() ?? '',
style: StreamChatTheme.of(context)
.textTheme
.footnoteBold
.copyWith(
color: Colors.white,
),
),
],
),
),
),
],
)
: (widget.actionsLocation == ActionsLocation.leftInside
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildExpandActionsButton(),
],
)
: null),
suffixIconConstraints: BoxConstraints.tightFor(height: 40),
prefixIconConstraints: BoxConstraints.tightFor(height: 40),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_commandEnabled)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: StreamSvgIcon.closeSmall(),
splashRadius: 24,
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
onPressed: () {
setState(() => _commandEnabled = false);
},
),
),
if (!_commandEnabled &&
widget.actionsLocation == ActionsLocation.rightInside)
_buildExpandActionsButton(),
if (widget.sendButtonLocation == SendButtonLocation.inside)
_animateSendButton(context),
],
),
).merge(passedDecoration);
}
Timer _debounce;
String _previousValue;
void _onChanged(BuildContext context, String s) {
if (s == _previousValue) {
return;
}
_previousValue = s;
if (_debounce?.isActive == true) _debounce.cancel();
_debounce = Timer(
const Duration(milliseconds: 350),
() {
if (!mounted) {
return;
}
StreamChannel.of(context).channel.keyStroke()?.catchError((e) {});
setState(() {
_messageIsPresent = s.trim().isNotEmpty;
_actionsShrunk = s.trim().isNotEmpty &&
((widget.actions?.length ?? 0) +
(widget.showCommandsButton ? 1 : 0) +
(widget.disableAttachments ? 0 : 1) >
1);
});
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
_emojiOverlay?.remove();
_emojiOverlay = null;
_checkCommands(s.trim(), context);
_checkMentions(s, context);
_checkEmoji(s, context);
},
);
}
String _getHint() {
if (_commandEnabled && _chosenCommand.name == 'giphy') {
return 'Search GIFs';
}
if (_attachments.isNotEmpty) {
return 'Add a comment or send';
}
return 'Write a message';
}
void _checkEmoji(String s, BuildContext context) {
if (s.isNotEmpty &&
textEditingController.selection.baseOffset > 0 &&
textEditingController.text
.substring(
0,
textEditingController.selection.baseOffset,
)
.contains(':')) {
final textToSelection = textEditingController.text
.substring(0, textEditingController.value.selection.start);
final splits = textToSelection.split(':');
final query = splits[splits.length - 2]?.toLowerCase();
final emoji = Emoji.byName(query);
if (textToSelection.endsWith(':') && emoji != null) {
_chooseEmoji(splits.sublist(0, splits.length - 1), emoji);
} else {
_emojiOverlay = _buildEmojiOverlay();
if (_emojiOverlay != null) {
Overlay.of(context).insert(_emojiOverlay);
}
}
}
}
void _checkMentions(String s, BuildContext context) {
if (s.isNotEmpty &&
textEditingController.selection.baseOffset > 0 &&
textEditingController.text
.substring(0, textEditingController.selection.baseOffset)
.split(' ')
.last
.contains('@')) {
_mentionsOverlay = _buildMentionsOverlayEntry();
if (_mentionsOverlay != null) {
Overlay.of(context).insert(_mentionsOverlay);
}
}
}
void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) {
var matchedCommandsList = StreamChannel.of(context)
.channel
.config
?.commands
?.where((element) => element.name == s.substring(1))
?.toList() ??
[];
if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0];
textEditingController.clear();
_messageIsPresent = false;
setState(() {
_commandEnabled = true;
});
_commandsOverlay.remove();
_commandsOverlay = null;
} else {
_commandsOverlay = _buildCommandsOverlayEntry();
if (_commandsOverlay != null) {
Overlay.of(context).insert(_commandsOverlay);
}
}
}
}
OverlayEntry _buildCommandsOverlayEntry() {
final text = textEditingController.text.trimLeft();
final commands = StreamChannel.of(context)
.channel
.config
?.commands
?.where((c) => c.name.contains(text.replaceFirst('/', '')))
?.toList() ??
[];
if (commands.isEmpty) {
return null;
}
RenderBox renderBox = context.findRenderObject();
final size = renderBox.size;
return OverlayEntry(builder: (context) {
return Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, wid) {
return Transform.scale(
alignment: Alignment.center,
scale: val,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 2.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
color: StreamChatTheme.of(context).colorTheme.white,
clipBehavior: Clip.antiAlias,
child: Container(
constraints: BoxConstraints.loose(Size.fromHeight(400)),
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
borderRadius: BorderRadius.circular(8.0)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(left: 0.0, top: 8.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
),
child: StreamSvgIcon.lightning(
color: StreamChatTheme.of(context)
.colorTheme
.accentBlue,
),
),
Text(
'Instant Commands',
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(.5),
),
)
],
),
),
SizedBox(
height: 10.0,
),
...commands
.map(
(c) => InkWell(
onTap: () {
_setCommand(c);
},
child: Container(
height: 40.0,
child: Row(
children: [
SizedBox(
width: 16.0,
),
_buildCommandIcon(c.name),
SizedBox(
width: 8.0,
),
Text.rich(
TextSpan(
text: '${c.name.capitalize()}',
style: TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style:
StreamChatTheme.of(context)
.textTheme
.body
.copyWith(
color: StreamChatTheme
.of(context)
.colorTheme
.grey,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
),
);
}),
);
});
}
Widget _buildFilePickerSection() {
final _attachmentContainsFile = _attachments.values.any((it) {
return it.type == 'file';
});
Color _getIconColor(int index) {
switch (index) {
case 0:
return _attachments.isEmpty
? StreamChatTheme.of(context).colorTheme.accentBlue
: (!_attachmentContainsFile
? StreamChatTheme.of(context).colorTheme.accentBlue
: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.2));
break;
case 1:
return _attachmentContainsFile
? StreamChatTheme.of(context).colorTheme.accentBlue
: (_attachments.isEmpty
? StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5)
: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.2));
break;
case 2:
return _attachmentContainsFile && _attachments.isNotEmpty
? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2)
: StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5);
break;
case 3:
return _attachmentContainsFile && _attachments.isNotEmpty
? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2)
: StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5);
break;
default:
return Colors.black;
}
}
return AnimatedContainer(
duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero,
height: _openFilePickerSection ? _filePickerSize : 0,
child: Material(
color: StreamChatTheme.of(context).colorTheme.whiteSmoke,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
iconSize: 24,
icon: StreamSvgIcon.pictures(
color: _getIconColor(0),
),
onPressed: _attachmentContainsFile && _attachments.isNotEmpty
? null
: () {
setState(() {
_filePickerIndex = 0;
});
},
),
IconButton(
iconSize: 32,
icon: StreamSvgIcon.files(
color: _getIconColor(1),
),
onPressed: !_attachmentContainsFile && _attachments.isNotEmpty
? null
: () {
pickFile(DefaultAttachmentTypes.file, false);
},
),
IconButton(
iconSize: 24,
icon: StreamSvgIcon.camera(
color: _getIconColor(2),
),
onPressed: _attachmentContainsFile && _attachments.isNotEmpty
? null
: () {
pickFile(DefaultAttachmentTypes.image, true);
},
),
IconButton(
padding: const EdgeInsets.all(0),
iconSize: 24,
icon: StreamSvgIcon.record(
color: _getIconColor(3),
),
onPressed: _attachmentContainsFile && _attachments.isNotEmpty
? null
: () {
pickFile(DefaultAttachmentTypes.video, true);
},
),
],
),
GestureDetector(
onVerticalDragUpdate: (update) {
setState(() {
_animateContainer = false;
_filePickerSize = (_filePickerSize - update.delta.dy).clamp(
_kMinMediaPickerSize,
MediaQuery.of(context).size.height / 1.7,
);
});
},
child: Container(
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
child: Container(
width: double.infinity,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
width: 40.0,
height: 4.0,
decoration: BoxDecoration(
color:
StreamChatTheme.of(context).colorTheme.whiteSmoke,
borderRadius: BorderRadius.circular(4.0),
),
),
),
),
),
),
),
if (_openFilePickerSection)
Expanded(
child: Container(
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
borderRadius: BorderRadius.circular(8.0),
),
child: _PickerWidget(
filePickerIndex: _filePickerIndex,
containsFile: _attachmentContainsFile,
selectedMedias: _attachments.keys.toList(),
onAddMoreFilesClick: pickFile,
onMediaSelected: (media) {
if (_attachments.containsKey(media.id)) {
setState(() => _attachments.remove(media.id));
} else {
_addAttachment(media);
}
},
),
),
),
],
),
),
);
}
void _addAttachment(AssetEntity medium) async {
final mediaFile = await medium.originFile.timeout(
Duration(seconds: 5),
onTimeout: () => medium.originFile,
);
var file = AttachmentFile(
path: mediaFile.path,
size: await mediaFile.length(),
bytes: mediaFile.readAsBytesSync(),
);
if (file.size > _kMaxAttachmentSize) {
if (medium?.type == AssetType.video) {
final mediaInfo = await VideoService.compressVideo(file.path);
if (mediaInfo.filesize > _kMaxAttachmentSize) {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
);
return;
}
file = AttachmentFile(
name: file.name,
size: mediaInfo.filesize,
bytes: await mediaInfo.file.readAsBytes(),
path: mediaInfo.path,
);
} else {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.',
);
return;
}
}
setState(() {
_attachments[medium.id] = Attachment(
id: medium.id,
file: file,
type: medium.type == AssetType.image ? 'image' : 'video',
);
});
}
Widget _buildCommandIcon(String iconType) {
switch (iconType) {
case 'giphy':
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24.0,
),
);
break;
case 'ban':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16.0,
color: Colors.white,
),
);
break;
case 'flag':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.flag(
size: 14.0,
color: Colors.white,
),
);
break;
case 'imgur':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24.0,
),
),
);
break;
case 'mute':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.mute(
size: 16.0,
color: Colors.white,
),
);
break;
case 'unban':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16.0,
color: Colors.white,
),
);
break;
case 'unmute':
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16.0,
color: Colors.white,
),
);
break;
default:
return CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16.0,
color: Colors.white,
),
);
break;
}
}
OverlayEntry _buildMentionsOverlayEntry() {
final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start)
.split('@');
final query = splits.last.toLowerCase();
Future<List<Member>> queryMembers;
if (query.isNotEmpty) {
queryMembers = StreamChannel.of(context).channel.queryMembers(filter: {
'name': {
'\$autocomplete': query,
},
}).then((res) => res.members);
}
final members = StreamChannel.of(context).channel.state.members?.where((m) {
return m.user.name.toLowerCase().contains(query);
})?.toList() ??
[];
if (members.isEmpty) {
return null;
}
RenderBox renderBox = context.findRenderObject();
final size = renderBox.size;
return OverlayEntry(
builder: (context) {
return Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, wid) {
return Transform.scale(
scale: val,
child: Card(
margin: EdgeInsets.all(8.0),
elevation: 2.0,
color: StreamChatTheme.of(context).colorTheme.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
clipBehavior: Clip.antiAlias,
child: Container(
constraints: BoxConstraints.loose(Size.fromHeight(240)),
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
),
child: FutureBuilder<List<Member>>(
future: queryMembers ?? Future.value(members),
initialData: members,
builder: (context, snapshot) {
return ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
SizedBox(
height: 8.0,
),
...snapshot.data.map(
(m) {
return Material(
color: StreamChatTheme.of(context)
.colorTheme
.white,
child: InkWell(
onTap: () {
_mentionedUsers.add(m.user);
splits[splits.length - 1] = m.user.name;
final rejoin = splits.join('@');
textEditingController.value =
TextEditingValue(
text: rejoin +
textEditingController.text
.substring(textEditingController
.selection.start),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
_debounce.cancel();
_mentionsOverlay?.remove();
_mentionsOverlay = null;
},
child: widget.mentionsTileBuilder != null
? widget.mentionsTileBuilder(context, m)
: MentionTile(m),
),
);
},
).toList(),
SizedBox(
height: 8.0,
),
],
);
},
),
),
),
);
},
),
);
},
);
}
OverlayEntry _buildEmojiOverlay() {
final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start)
.split(':');
final query = splits.last.toLowerCase();
if (query.isEmpty) {
return null;
}
final emojis = _emojiNames
.where((e) => e.contains(query))
.map((e) => Emoji.byName(e))
.where((e) => e != null);
if (emojis.isEmpty) {
return null;
}
RenderBox renderBox = context.findRenderObject();
final size = renderBox.size;
return OverlayEntry(builder: (context) {
return Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: Card(
margin: EdgeInsets.all(8.0),
elevation: 2.0,
color: StreamChatTheme.of(context).colorTheme.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
clipBehavior: Clip.antiAlias,
child: Container(
constraints: BoxConstraints.loose(Size.fromHeight(200)),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
spreadRadius: -8,
blurRadius: 5.0,
offset: Offset(0, -4),
),
],
color: StreamChatTheme.of(context).colorTheme.white,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8.0, top: 8.0),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: StreamSvgIcon.smile(
color: StreamChatTheme.of(context)
.colorTheme
.accentBlue,
),
),
Flexible(
child: Text(
'Emoji matching "$query"',
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1);
return ListTile(
title: SubstringHighlight(
text: "${emoji.char} ${emoji.name.replaceAll('_', ' ')}",
term: query,
textStyleHighlight:
Theme.of(context).textTheme.headline6.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: Theme.of(context).textTheme.headline6.copyWith(
fontSize: 14.5,
),
),
onTap: () {
_chooseEmoji(splits, emoji);
},
);
}),
),
),
);
});
}
void _chooseEmoji(List<String> splits, Emoji emoji) {
final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char;
textEditingController.value = TextEditingValue(
text: rejoin +
textEditingController.text
.substring(textEditingController.selection.start),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
_emojiOverlay?.remove();
_emojiOverlay = null;
}
void _setCommand(Command c) {
textEditingController.clear();
setState(() {
_chosenCommand = c;
_commandEnabled = true;
_messageIsPresent = false;
});
_commandsOverlay?.remove();
_commandsOverlay = null;
}
Widget _buildReplyToMessage() {
if (!_hasQuotedMessage) return Offstage();
final containsUrl = widget.quotedMessage.attachments
?.any((element) => element.ogScrapeUrl != null) ==
true;
return Transform(
transform: Matrix4.rotationY(pi),
alignment: Alignment.center,
child: QuotedMessageWidget(
reverse: true,
showBorder: !containsUrl,
message: widget.quotedMessage,
messageTheme: StreamChatTheme.of(context).otherMessageTheme,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
),
);
}
Widget _buildAttachments() {
if (_attachments.isEmpty) return Offstage();
final fileAttachments = _attachments.values
.where((it) => it.type == 'file')
.toList(growable: false);
final remainingAttachments = _attachments.values
.where((it) => it.type != 'file')
.toList(growable: false);
return Column(
children: [
if (fileAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
maxHeight: 136.0,
child: ListView(
reverse: true,
shrinkWrap: true,
children: fileAttachments.reversed
.map<Widget>(
(e) => ClipRRect(
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: FileAttachment(
message: null,
attachment: e,
size: Size(
MediaQuery.of(context).size.width * 0.65,
56.0,
),
trailing: Padding(
padding: const EdgeInsets.all(8.0),
child: _buildRemoveButton(e),
),
),
),
)
.insertBetween(const SizedBox(height: 8)),
),
),
),
if (remainingAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
maxHeight: 104.0,
child: ListView(
scrollDirection: Axis.horizontal,
children: remainingAttachments
.map<Widget>(
(attachment) => ClipRRect(
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: Stack(
children: <Widget>[
AspectRatio(
aspectRatio: 1.0,
child: Container(
height: 104,
width: 104,
child: _buildAttachment(attachment),
),
),
Positioned(
top: 8,
right: 8,
child: _buildRemoveButton(attachment),
),
],
),
),
)
.insertBetween(const SizedBox(width: 8)),
),
),
),
],
);
}
Widget _buildRemoveButton(Attachment attachment) {
return Container(
height: 24,
width: 24,
child: RawMaterialButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
disabledElevation: 0,
hoverElevation: 0,
onPressed: () {
setState(() => _attachments.remove(attachment.id));
},
fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5),
child: Center(
child: StreamSvgIcon.close(
size: 24,
color: StreamChatTheme.of(context).colorTheme.white,
),
),
),
);
}
Widget _buildAttachment(Attachment attachment) {
if (attachment == null) return Offstage();
if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) ==
true) {
return widget.attachmentThumbnailBuilders[attachment.type](
context,
attachment,
);
}
switch (attachment.type) {
case 'image':
case 'giphy':
return attachment.file != null
? Image.memory(
attachment.file.bytes,
fit: BoxFit.cover,
errorBuilder: (context, _, __) {
return Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
},
)
: CachedNetworkImage(
imageUrl: attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl,
fit: BoxFit.cover,
errorWidget: (_, obj, trace) {
return getFileTypeImage(attachment.extraData['other']);
},
progressIndicatorBuilder: (context, _, progress) {
return Shimmer.fromColors(
baseColor:
StreamChatTheme.of(context).colorTheme.greyGainsboro,
highlightColor:
StreamChatTheme.of(context).colorTheme.whiteSmoke,
child: Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
package: 'stream_chat_flutter',
),
);
},
);
case 'video':
return Stack(
children: [
VideoThumbnailImage(
height: 104,
width: 104,
video: attachment.file?.path ?? attachment.assetUrl,
fit: BoxFit.cover,
),
Positioned(
left: 8,
bottom: 10,
child: SvgPicture.asset(
'svgs/video_call_icon.svg',
package: 'stream_chat_flutter',
),
),
],
);
default:
return Container(
color: Colors.black26,
child: Icon(Icons.insert_drive_file),
);
}
}
Widget _buildCommandButton() {
final s = textEditingController.text.trim();
return IconButton(
icon: StreamSvgIcon.lightning(
color: s.isNotEmpty
? StreamChatTheme.of(context).colorTheme.greyGainsboro
: (_commandsOverlay != null
? StreamChatTheme.of(context)
.messageInputTheme
.actionButtonColor
: StreamChatTheme.of(context)
.messageInputTheme
.actionButtonIdleColor),
),
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () async {
if (_openFilePickerSection) {
setState(() {
_animateContainer = false;
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
await Future.delayed(Duration(milliseconds: 300));
}
if (_commandsOverlay == null) {
setState(() {
_commandsOverlay = _buildCommandsOverlayEntry();
if (_commandsOverlay != null) {
Overlay.of(context).insert(_commandsOverlay);
}
});
} else {
setState(() {
_commandsOverlay?.remove();
_commandsOverlay = null;
});
}
},
);
}
Widget _buildAttachmentButton() {
return IconButton(
icon: StreamSvgIcon.attach(
color: _openFilePickerSection
? StreamChatTheme.of(context).messageInputTheme.actionButtonColor
: StreamChatTheme.of(context)
.messageInputTheme
.actionButtonIdleColor,
),
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();
}
},
);
}
/// Show the attachment modal, making the user choose where to pick a media from
void showAttachmentModal() {
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
if (!kIsWeb) {
setState(() {
_openFilePickerSection = true;
});
} else {
showModalBottomSheet(
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
context: context,
isScrollControlled: true,
builder: (_) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
title: Text(
'Add a file',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: Icon(Icons.image),
title: Text('Upload a photo'),
onTap: () {
pickFile(DefaultAttachmentTypes.image, false);
Navigator.pop(context);
},
),
ListTile(
leading: Icon(Icons.video_library),
title: Text('Upload a video'),
onTap: () {
pickFile(DefaultAttachmentTypes.video, false);
Navigator.pop(context);
},
),
if (!kIsWeb)
ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Photo from camera'),
onTap: () {
pickFile(DefaultAttachmentTypes.image, true);
Navigator.pop(context);
},
),
if (!kIsWeb)
ListTile(
leading: Icon(Icons.videocam),
title: Text('Video from camera'),
onTap: () {
pickFile(DefaultAttachmentTypes.video, true);
Navigator.pop(context);
},
),
ListTile(
leading: Icon(Icons.insert_drive_file),
title: Text('Upload a file'),
onTap: () {
pickFile(DefaultAttachmentTypes.file, false);
Navigator.pop(context);
},
),
],
);
});
}
}
/// Add an attachment to the sending message
/// Use this to add custom type attachments
void addAttachment(Attachment attachment) {
setState(() {
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState ?? UploadState.success(),
);
});
}
/// 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);
AttachmentFile 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) {
PickedFile pickedFile;
if (fileType == DefaultAttachmentTypes.image) {
pickedFile = await _imagePicker.getImage(source: ImageSource.camera);
} else if (fileType == DefaultAttachmentTypes.video) {
pickedFile = await _imagePicker.getVideo(source: ImageSource.camera);
}
if (pickedFile == null) {
return;
}
final bytes = await pickedFile.readAsBytes();
file = AttachmentFile(
size: bytes.length,
path: pickedFile.path,
bytes: bytes,
);
} 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;
}
final res = await FilePicker.platform.pickFiles(
type: type,
withData: true,
);
if (res?.files?.isNotEmpty == true) {
file = res.files.single.toAttachmentFile;
}
}
setState(() => _inputEnabled = true);
if (file == null) return;
final mimeType = file.name?.mimeType ?? file.path.split('/').last.mimeType;
final extraDataMap = <String, dynamic>{};
if (mimeType?.subtype != null) {
extraDataMap['mime_type'] = mimeType.subtype.toLowerCase();
}
if (file.size != null) {
extraDataMap['file_size'] = file.size;
}
final attachment = Attachment(
file: file,
type: attachmentType,
extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
);
if (file.size > _kMaxAttachmentSize) {
if (attachmentType == 'Video') {
final mediaInfo = await VideoService.compressVideo(file.path);
if (mediaInfo.filesize > _kMaxAttachmentSize) {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
);
return;
}
file = AttachmentFile(
name: file.name,
size: mediaInfo.filesize,
bytes: await mediaInfo.file.readAsBytes(),
path: mediaInfo.path,
);
} else {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.',
);
return;
}
}
_attachments[attachment.id] = attachment;
setState(() {
_attachments.update(attachment.id, (it) {
return it.copyWith(
file: file,
extraData: {...it.extraData}..update('file_size', (_) => file.size),
);
});
});
}
Widget _buildIdleSendButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color:
StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor,
),
);
}
Widget _buildSendButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
onPressed: sendMessage,
padding: const EdgeInsets.all(0),
splashRadius: 24,
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
icon: StreamSvgIcon(
assetName: _getSendIcon(),
color: StreamChatTheme.of(context).messageInputTheme.sendButtonColor,
),
),
);
}
String _getIdleSendIcon() {
if (_commandEnabled) {
return 'Icon_search.svg';
} else {
return 'Icon_circle_right.svg';
}
}
String _getSendIcon() {
if (widget.editMessage != null) {
return 'Icon_circle_up.svg';
} else if (_commandEnabled) {
return 'Icon_search.svg';
} else {
return 'Icon_circle_up.svg';
}
}
/// Sends the current message
void sendMessage() async {
var text = textEditingController.text.trim();
if (text.isEmpty && _attachments.isEmpty) {
return;
}
final shouldUnfocus = _commandEnabled;
if (_commandEnabled) {
text = '/${_chosenCommand.name} ' + text;
}
final attachments = [..._attachments.values];
textEditingController.clear();
_attachments.clear();
if (widget.onQuotedMessageCleared != null) {
widget.onQuotedMessageCleared();
}
setState(() {
_messageIsPresent = false;
_commandEnabled = false;
});
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
Future sendingFuture;
Message message;
if (widget.editMessage != null) {
message = widget.editMessage.copyWith(
text: text,
attachments: attachments,
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
);
} else {
message = (widget.initialMessage ?? Message()).copyWith(
parentId: widget.parentMessage?.id,
text: text,
attachments: attachments,
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
showInChannel: widget.parentMessage != null ? _sendAsDm : null,
);
}
if (widget.quotedMessage != null) {
message = message.copyWith(
quotedMessageId: widget.quotedMessage.id,
);
}
if (widget.preMessageSending != null) {
message = await widget.preMessageSending(message);
}
final streamChannel = StreamChannel.of(context);
final channel = streamChannel.channel;
if (!channel.state.isUpToDate) {
await streamChannel.reloadChannel();
}
_mentionedUsers.clear();
if (widget.editMessage == null ||
widget.editMessage.status == MessageSendingStatus.failed ||
widget.editMessage.status == MessageSendingStatus.sending) {
sendingFuture = channel.sendMessage(message);
} else {
sendingFuture = channel.updateMessage(message);
}
if (!shouldUnfocus) {
FocusScope.of(context).requestFocus(_focusNode);
}
return sendingFuture.then((resp) {
if (resp.message?._type == 'error') {
_parseExistingMessage(message);
}
if (widget.onMessageSent != null) {
widget.onMessageSent(resp.message);
}
});
}
StreamSubscription _keyboardListener;
void _showErrorAlert(String description) {
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,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
description,
textAlign: TextAlign.center,
),
),
SizedBox(
height: 36.0,
),
Container(
color:
StreamChatTheme.of(context).colorTheme.black.withOpacity(.08),
height: 1.0,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'OK',
style: StreamChatTheme.of(context)
.textTheme
.bodyBold
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.accentBlue),
),
),
],
),
],
);
},
);
}
void _parseExistingMessage(Message message) {
textEditingController.text = message.text;
_messageIsPresent = true;
if (message.attachments != null) {
for (final attachment in message.attachments) {
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState ?? UploadState.success(),
);
}
}
}
@override
void dispose() {
_commandsOverlay?.remove();
_emojiOverlay?.remove();
_mentionsOverlay?.remove();
_keyboardListener?.cancel();
super.dispose();
}
bool _initialized = false;
@override
void didChangeDependencies() {
if (widget.editMessage != null && !_initialized) {
FocusScope.of(context).requestFocus(_focusNode);
_initialized = true;
}
super.didChangeDependencies();
}
}
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Returns the first item of the tuple
final T1 item1;
/// Returns the second item of the tuple
final T2 item2;
/// Creates a new tuple value with the specified items.
const Tuple2(this.item1, this.item2);
/// Create a new tuple value with the specified list [items].
factory Tuple2.fromList(List items) {
if (items.length != 2) {
throw ArgumentError('items must have length 2');
}
return Tuple2<T1, T2>(items[0] as T1, items[1] as T2);
}
/// Returns a tuple with the first item set to the specified value.
Tuple2<T1, T2> withItem1(T1 v) => Tuple2<T1, T2>(v, item2);
/// Returns a tuple with the second item set to the specified value.
Tuple2<T1, T2> withItem2(T2 v) => Tuple2<T1, T2>(item1, v);
/// Creates a [List] containing the items of this [Tuple2].
///
/// The elements are in item order. The list is variable-length
/// if [growable] is true.
List toList({bool growable = false}) =>
List.from([item1, item2], growable: growable);
@override
String toString() => '[$item1, $item2]';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Tuple2 &&
runtimeType == other.runtimeType &&
item1 == other.item1 &&
item2 == other.item2;
@override
int get hashCode => item1.hashCode ^ item2.hashCode;
}
class _PickerWidget extends StatefulWidget {
final int filePickerIndex;
final bool containsFile;
final List<String> selectedMedias;
final void Function(DefaultAttachmentTypes) onAddMoreFilesClick;
final void Function(AssetEntity) onMediaSelected;
const _PickerWidget({
Key key,
@required this.filePickerIndex,
@required this.containsFile,
@required this.selectedMedias,
@required this.onAddMoreFilesClick,
@required this.onMediaSelected,
}) : super(key: key);
@override
__PickerWidgetState createState() => __PickerWidgetState();
}
class __PickerWidgetState extends State<_PickerWidget> {
Future<bool> requestPermission;
@override
void initState() {
super.initState();
requestPermission = PhotoManager.requestPermission();
}
@override
Widget build(BuildContext context) {
if (widget.filePickerIndex != 0) {
return Offstage();
}
return FutureBuilder<bool>(
future: requestPermission,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.data) {
if (widget.containsFile) {
return GestureDetector(
onTap: () {
widget.onAddMoreFilesClick(DefaultAttachmentTypes.file);
},
child: Container(
constraints: BoxConstraints.expand(),
color: StreamChatTheme.of(context).colorTheme.whiteSmoke,
alignment: Alignment.center,
child: Text(
'Add more files',
style: TextStyle(
color: StreamChatTheme.of(context).colorTheme.accentBlue,
fontWeight: FontWeight.bold,
),
),
),
);
}
return MediaListView(
selectedIds: widget.selectedMedias,
onSelect: widget.onMediaSelected,
);
}
return InkWell(
onTap: () async {
PhotoManager.openSetting();
},
child: Container(
color: StreamChatTheme.of(context).colorTheme.whiteSmoke,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SvgPicture.asset(
'svgs/icon_picture_empty_state.svg',
package: 'stream_chat_flutter',
height: 140,
color: StreamChatTheme.of(context).colorTheme.greyGainsboro,
),
Text(
'Please enable access to your photos \nand videos so you can share them with friends.',
style: StreamChatTheme.of(context).textTheme.body.copyWith(
color: StreamChatTheme.of(context).colorTheme.grey),
textAlign: TextAlign.center,
),
SizedBox(height: 6.0),
Center(
child: Text(
'Allow access to your gallery',
style: StreamChatTheme.of(context)
.textTheme
.bodyBold
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.accentBlue,
),
),
),
],
),
),
);
});
}
}