2367 lines
78 KiB
Dart
2367 lines
78 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
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:http_parser/http_parser.dart' as httpParser;
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:mime/mime.dart';
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
import 'package:stream_chat/stream_chat.dart';
|
|
import 'package:stream_chat_flutter/src/compress_video_service.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/user_avatar.dart';
|
|
import 'package:substring_highlight/substring_highlight.dart';
|
|
import 'package:video_compress/video_compress.dart';
|
|
|
|
import '../stream_chat_flutter.dart';
|
|
import 'extension.dart';
|
|
import 'quoted_message_widget.dart';
|
|
import 'stream_channel.dart';
|
|
|
|
typedef FileUploader = Future<String> Function(PlatformFile, Channel);
|
|
typedef AttachmentThumbnailBuilder = Widget Function(
|
|
BuildContext,
|
|
_SendingAttachment,
|
|
);
|
|
|
|
enum ActionsLocation {
|
|
left,
|
|
right,
|
|
}
|
|
|
|
enum DefaultAttachmentTypes {
|
|
image,
|
|
video,
|
|
file,
|
|
}
|
|
|
|
const _kMinMediaPickerSize = 360.0;
|
|
|
|
const _kMaxAttachmentSize = 20480; //20MB
|
|
|
|
/// Inactive state
|
|
/// 
|
|
/// 
|
|
/// Focused state
|
|
/// 
|
|
/// 
|
|
///
|
|
/// 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.doImageUploadRequest,
|
|
this.doFileUploadRequest,
|
|
this.initialMessage,
|
|
this.textEditingController,
|
|
this.actions,
|
|
this.actionsLocation = ActionsLocation.left,
|
|
this.attachmentThumbnailBuilders,
|
|
this.focusNode,
|
|
this.quotedMessage,
|
|
this.onQuotedMessageCleared,
|
|
}) : 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;
|
|
|
|
/// Override image upload request
|
|
final FileUploader doImageUploadRequest;
|
|
|
|
/// 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;
|
|
|
|
/// The focus node associated to the TextField
|
|
final FocusNode focusNode;
|
|
|
|
///
|
|
final Message quotedMessage;
|
|
|
|
///
|
|
final VoidCallback onQuotedMessageCleared;
|
|
|
|
@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 List<_SendingAttachment> _attachments = [];
|
|
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;
|
|
KeyboardVisibilityController _keyboardVisibilityController =
|
|
KeyboardVisibilityController();
|
|
|
|
/// The editing controller passed to the input TextField
|
|
TextEditingController textEditingController;
|
|
|
|
bool get _hasQuotedMessage => widget.quotedMessage != null;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget 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.close_small(),
|
|
onPressed: widget.onQuotedMessageCleared,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: _buildTextField(context),
|
|
),
|
|
if (widget.parentMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: _buildDmCheckbox(),
|
|
),
|
|
_buildFilePickerSection(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (widget.editMessage == null) {
|
|
child = Material(
|
|
color: StreamChatTheme.of(context).colorTheme.white,
|
|
elevation: 8,
|
|
child: child,
|
|
);
|
|
}
|
|
return child;
|
|
}
|
|
|
|
Flex _buildTextField(BuildContext context) {
|
|
return Flex(
|
|
direction: Axis.horizontal,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: <Widget>[
|
|
if (!_commandEnabled) _buildExpandActionsButton(),
|
|
if (widget.actionsLocation == ActionsLocation.left)
|
|
...widget.actions ?? [],
|
|
_buildTextInput(context),
|
|
_animateSendButton(context),
|
|
if (widget.actionsLocation == ActionsLocation.right)
|
|
...widget.actions ?? [],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDmCheckbox() {
|
|
return Container(
|
|
height: 36,
|
|
padding: const EdgeInsets.only(
|
|
left: 12,
|
|
bottom: 12,
|
|
top: 8,
|
|
),
|
|
child: 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: 16.0),
|
|
child: Text('Also send as direct message'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _animateSendButton(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: AnimatedCrossFade(
|
|
crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) &&
|
|
_attachments.every((a) => a.uploaded == true))
|
|
? CrossFadeState.showFirst
|
|
: CrossFadeState.showSecond,
|
|
firstChild: _buildSendButton(context),
|
|
secondChild: _buildIdleSendButton(context),
|
|
duration: Duration(milliseconds: 300),
|
|
alignment: Alignment.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildExpandActionsButton() {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: AnimatedCrossFade(
|
|
crossFadeState: _actionsShrunk
|
|
? CrossFadeState.showFirst
|
|
: CrossFadeState.showSecond,
|
|
firstChild: IconButton(
|
|
onPressed: () => setState(() => _actionsShrunk = false),
|
|
icon: StreamSvgIcon.emptyCircleLeft(
|
|
color: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
),
|
|
padding: const EdgeInsets.all(0),
|
|
constraints: BoxConstraints.tightFor(
|
|
height: 24,
|
|
width: 24,
|
|
),
|
|
splashRadius: 24,
|
|
),
|
|
secondChild: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
if (!widget.disableAttachments) _buildAttachmentButton(),
|
|
if (widget.editMessage == null &&
|
|
StreamChannel.of(context)
|
|
.channel
|
|
?.config
|
|
?.commands
|
|
?.isNotEmpty ==
|
|
true)
|
|
_buildCommandButton(),
|
|
].insertBetween(const SizedBox(width: 8)),
|
|
),
|
|
duration: Duration(milliseconds: 300),
|
|
alignment: Alignment.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
Expanded _buildTextInput(BuildContext context) {
|
|
final theme = StreamChatTheme.of(context);
|
|
return Expanded(
|
|
child: Center(
|
|
child: Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20.0),
|
|
border: Border.all(color: theme.colorTheme.greyGainsboro),
|
|
),
|
|
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.textTheme.body,
|
|
autofocus: false,
|
|
textAlignVertical: TextAlignVertical.center,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
hintText: _getHint(),
|
|
hintStyle: theme.textTheme.body.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
|
|
? Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: theme.colorTheme.accentBlue,
|
|
),
|
|
height: 24,
|
|
margin: const EdgeInsets.all(8.0),
|
|
padding: const EdgeInsets.only(right: 8, left: 4),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null,
|
|
suffixIcon: _commandEnabled
|
|
? IconButton(
|
|
icon: StreamSvgIcon.close_small(),
|
|
splashRadius: 24,
|
|
padding: const EdgeInsets.all(0),
|
|
constraints: BoxConstraints.tightFor(
|
|
height: 24,
|
|
width: 24,
|
|
),
|
|
onPressed: () {
|
|
setState(() => _commandEnabled = false);
|
|
},
|
|
)
|
|
: null,
|
|
),
|
|
textCapitalization: TextCapitalization.sentences,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Timer _debounce;
|
|
|
|
void _onChanged(BuildContext context, String 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;
|
|
});
|
|
|
|
_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();
|
|
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();
|
|
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() ??
|
|
[];
|
|
|
|
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() {
|
|
var _attachmentContainsFile =
|
|
_attachments.any((element) => element?.attachment?.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(
|
|
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: _buildPickerSection(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPickerSection() {
|
|
var _attachmentContainsFile =
|
|
_attachments.any((element) => element.attachment?.type == 'file');
|
|
|
|
switch (_filePickerIndex) {
|
|
case 0:
|
|
return FutureBuilder<bool>(
|
|
future: PhotoManager.requestPermission(),
|
|
builder: (context, snapshot) {
|
|
if (!snapshot.hasData) {
|
|
return Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
|
|
if (snapshot.data) {
|
|
if (_attachmentContainsFile) {
|
|
return GestureDetector(
|
|
onTap: () {
|
|
pickFile(DefaultAttachmentTypes.file);
|
|
},
|
|
child: Container(
|
|
constraints: BoxConstraints.expand(),
|
|
color: StreamChatTheme.of(context).colorTheme.whiteSmoke,
|
|
child: Text(
|
|
'Add more files',
|
|
style: TextStyle(
|
|
color:
|
|
StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
),
|
|
);
|
|
}
|
|
return MediaListView(
|
|
selectedIds: _attachments.map((e) => e.id).toList(),
|
|
onSelect: (media) async {
|
|
if (!_attachments
|
|
.any((element) => element.id == media.id)) {
|
|
_addAttachment(media);
|
|
} else {
|
|
setState(() {
|
|
_attachments
|
|
.removeWhere((element) => element.id == media.id);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
break;
|
|
default:
|
|
return SizedBox();
|
|
}
|
|
}
|
|
|
|
void _addAttachment(AssetEntity medium) async {
|
|
final attachment = _SendingAttachment(
|
|
id: medium.id,
|
|
);
|
|
try {
|
|
setState(() {
|
|
_attachments.add(attachment);
|
|
});
|
|
final mediaFile = await medium.originFile;
|
|
|
|
var file = PlatformFile(
|
|
path: mediaFile.path,
|
|
size: ((await mediaFile.length()) / 1024).ceil(),
|
|
bytes: mediaFile.readAsBytesSync(),
|
|
);
|
|
|
|
if (file.size > _kMaxAttachmentSize) {
|
|
if (medium?.type == AssetType.video) {
|
|
final mediaInfo = await CompressVideoService.compressVideo(file.path);
|
|
|
|
if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) {
|
|
// ignore: deprecated_member_use
|
|
Scaffold.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
|
|
),
|
|
),
|
|
);
|
|
|
|
setState(() {
|
|
_attachments.remove(attachment);
|
|
});
|
|
return;
|
|
}
|
|
file = PlatformFile(
|
|
name: file.name,
|
|
size: (mediaInfo.filesize / 1024).ceil(),
|
|
bytes: await mediaInfo.file.readAsBytes(),
|
|
path: mediaInfo.path,
|
|
);
|
|
} else {
|
|
// ignore: deprecated_member_use
|
|
Scaffold.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'The file is too large to upload. The file size limit is 20MB',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final channel = StreamChannel.of(context).channel;
|
|
setState(() {
|
|
attachment
|
|
..file = file
|
|
..attachment = Attachment(
|
|
localUri: file.path != null ? Uri.parse(file.path) : null,
|
|
type: medium?.type == AssetType.image ? 'image' : 'video',
|
|
);
|
|
});
|
|
|
|
final url = await _uploadAttachment(
|
|
file,
|
|
medium.type == AssetType.image
|
|
? DefaultAttachmentTypes.image
|
|
: DefaultAttachmentTypes.video,
|
|
channel);
|
|
|
|
final fileType = medium.type == AssetType.image
|
|
? DefaultAttachmentTypes.image
|
|
: DefaultAttachmentTypes.video;
|
|
|
|
if (fileType == DefaultAttachmentTypes.image) {
|
|
attachment.attachment = attachment.attachment.copyWith(
|
|
imageUrl: url,
|
|
);
|
|
} else {
|
|
attachment.attachment = attachment.attachment.copyWith(
|
|
assetUrl: url,
|
|
);
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
attachment.uploaded = true;
|
|
});
|
|
}
|
|
} catch (e, s) {
|
|
setState(() {
|
|
_attachments.remove(attachment);
|
|
});
|
|
print(e);
|
|
print(s);
|
|
// ignore: deprecated_member_use
|
|
Scaffold.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error adding the attachment: $e'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildCommandIcon(String iconType) {
|
|
switch (iconType) {
|
|
case 'giphy':
|
|
return CircleAvatar(
|
|
child: StreamSvgIcon.giphyIcon(
|
|
size: 24.0,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'ban':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.Icon_user_delete(
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'flag':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.flag(
|
|
size: 14.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'imgur':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: ClipOval(
|
|
child: StreamSvgIcon.imgur(
|
|
size: 24.0,
|
|
),
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'mute':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.mute(
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'unban':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.userAdd(
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
case 'unmute':
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.volumeUp(
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
break;
|
|
default:
|
|
return CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue,
|
|
child: StreamSvgIcon.lightning(
|
|
size: 16.0,
|
|
color: Colors.white,
|
|
),
|
|
radius: 12,
|
|
);
|
|
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() ??
|
|
[];
|
|
|
|
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: Container(
|
|
height: 56.0,
|
|
child: Row(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 16.0,
|
|
),
|
|
UserAvatar(
|
|
constraints: BoxConstraints.tight(
|
|
Size(
|
|
40,
|
|
40,
|
|
),
|
|
),
|
|
user: m.user,
|
|
),
|
|
SizedBox(
|
|
width: 8.0,
|
|
),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Column(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.center,
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${m.user.name}',
|
|
maxLines: 1,
|
|
overflow:
|
|
TextOverflow.ellipsis,
|
|
style: StreamChatTheme.of(
|
|
context)
|
|
.textTheme
|
|
.bodyBold,
|
|
),
|
|
SizedBox(
|
|
height: 2.0,
|
|
),
|
|
Text(
|
|
'@${m.userId}',
|
|
maxLines: 1,
|
|
overflow:
|
|
TextOverflow.ellipsis,
|
|
style: StreamChatTheme.of(
|
|
context)
|
|
.textTheme
|
|
.footnoteBold
|
|
.copyWith(
|
|
color: StreamChatTheme
|
|
.of(context)
|
|
.colorTheme
|
|
.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
right: 18.0, left: 8.0),
|
|
child: StreamSvgIcon.mentions(
|
|
color: StreamChatTheme.of(context)
|
|
.colorTheme
|
|
.accentBlue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
).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();
|
|
return Column(
|
|
children: [
|
|
if (_attachments.any((e) => e.attachment?.type == 'file'))
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
|
child: LimitedBox(
|
|
maxHeight: 136.0,
|
|
child: ListView(
|
|
reverse: true,
|
|
shrinkWrap: true,
|
|
children: _attachments.reversed
|
|
.where((e) => e.attachment?.type == 'file')
|
|
.map<Widget>(
|
|
(e) => ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: FileAttachment(
|
|
attachment: e.attachment,
|
|
attachmentType: FileAttachmentType.local,
|
|
file: e.file,
|
|
size: Size(
|
|
MediaQuery.of(context).size.width * 0.65,
|
|
56.0,
|
|
),
|
|
trailing: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: InkWell(
|
|
child: CircleAvatar(
|
|
backgroundColor: StreamChatTheme.of(context)
|
|
.colorTheme
|
|
.black
|
|
.withOpacity(0.6),
|
|
maxRadius: 12.0,
|
|
child: StreamSvgIcon.close(
|
|
color: StreamChatTheme.of(context)
|
|
.colorTheme
|
|
.white,
|
|
),
|
|
),
|
|
onTap: () =>
|
|
setState(() => _attachments.remove(e)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.insertBetween(const SizedBox(width: 8)),
|
|
),
|
|
),
|
|
),
|
|
if (_attachments.any((e) => e.attachment?.type != 'file'))
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
|
child: LimitedBox(
|
|
maxHeight: 104.0,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
children: _attachments
|
|
.where((e) => e.attachment?.type != 'file')
|
|
.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),
|
|
),
|
|
),
|
|
_buildRemoveButton(attachment),
|
|
attachment.uploaded
|
|
? SizedBox()
|
|
: Positioned.fill(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.insertBetween(const SizedBox(width: 8)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Positioned _buildRemoveButton(_SendingAttachment attachment) {
|
|
return Positioned(
|
|
height: 24,
|
|
width: 24,
|
|
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: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5),
|
|
child: Center(
|
|
child: StreamSvgIcon.close(
|
|
size: 24,
|
|
color: StreamChatTheme.of(context).colorTheme.white,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAttachment(_SendingAttachment attachment) {
|
|
if (widget.attachmentThumbnailBuilders
|
|
?.containsKey(attachment.attachment?.type) ==
|
|
true) {
|
|
return widget.attachmentThumbnailBuilders[attachment.attachment?.type](
|
|
context,
|
|
attachment,
|
|
);
|
|
}
|
|
|
|
if (attachment.attachment == null) {
|
|
return SizedBox();
|
|
}
|
|
|
|
switch (attachment.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',
|
|
);
|
|
},
|
|
)
|
|
: Image.network(
|
|
attachment.attachment.imageUrl,
|
|
fit: BoxFit.cover,
|
|
);
|
|
break;
|
|
case 'video':
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: Container(
|
|
child: FutureBuilder<File>(
|
|
future: VideoCompress.getFileThumbnail(attachment.file.path),
|
|
builder: (context, snapshot) {
|
|
if (!snapshot.hasData) {
|
|
return Image.asset(
|
|
'images/placeholder.png',
|
|
package: 'stream_chat_flutter',
|
|
);
|
|
}
|
|
|
|
return Image.file(
|
|
snapshot.data,
|
|
fit: BoxFit.cover,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 8,
|
|
bottom: 10,
|
|
child: SvgPicture.asset(
|
|
'svgs/video_call_icon.svg',
|
|
package: 'stream_chat_flutter',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
break;
|
|
default:
|
|
return Container(
|
|
child: Icon(Icons.insert_drive_file),
|
|
color: Colors.black26,
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildCommandButton() {
|
|
return IconButton(
|
|
icon: StreamSvgIcon.lightning(
|
|
color: _commandsOverlay != null
|
|
? StreamChatTheme.of(context).colorTheme.accentBlue
|
|
: StreamChatTheme.of(context).colorTheme.grey,
|
|
),
|
|
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();
|
|
Overlay.of(context).insert(_commandsOverlay);
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_commandsOverlay?.remove();
|
|
_commandsOverlay = null;
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAttachmentButton() {
|
|
return IconButton(
|
|
icon: StreamSvgIcon.attach(
|
|
color: _openFilePickerSection
|
|
? StreamChatTheme.of(context).colorTheme.accentBlue
|
|
: StreamChatTheme.of(context).colorTheme.grey,
|
|
),
|
|
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.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;
|
|
});
|
|
|
|
PlatformFile 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 = PlatformFile(
|
|
size: (bytes.length / 1024).ceil(),
|
|
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;
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_inputEnabled = true;
|
|
});
|
|
|
|
if (file == null) {
|
|
return;
|
|
}
|
|
|
|
final mimeType = _getMimeType(file.path.split('/').last);
|
|
|
|
Map<String, dynamic> extraDataMap = {};
|
|
|
|
if (camera) {
|
|
if (mimeType.type == 'video' || mimeType.type == 'image') {
|
|
attachmentType = mimeType.type;
|
|
}
|
|
} else {
|
|
attachmentType = 'file';
|
|
}
|
|
|
|
if (mimeType?.subtype != null) {
|
|
extraDataMap['mime_type'] = mimeType.subtype.toLowerCase();
|
|
}
|
|
|
|
if (file.size != null) {
|
|
extraDataMap['file_size'] = file.size;
|
|
}
|
|
|
|
final channel = StreamChannel.of(context).channel;
|
|
final attachment = _SendingAttachment(
|
|
file: file,
|
|
attachment: Attachment(
|
|
localUri: file.path != null ? Uri.parse(file.path) : null,
|
|
type: attachmentType,
|
|
extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
|
|
title: file.name,
|
|
),
|
|
);
|
|
|
|
setState(() {
|
|
_attachments.add(attachment);
|
|
});
|
|
|
|
if (file.size > _kMaxAttachmentSize) {
|
|
if (attachmentType == 'video') {
|
|
final mediaInfo = await CompressVideoService.compressVideo(file.path);
|
|
file = PlatformFile(
|
|
name: mediaInfo.title,
|
|
size: (mediaInfo.filesize / 1024).ceil(),
|
|
bytes: await mediaInfo.file.readAsBytes(),
|
|
path: mediaInfo.path,
|
|
);
|
|
setState(() {
|
|
attachment.file = file;
|
|
});
|
|
} else {
|
|
// ignore: deprecated_member_use
|
|
Scaffold.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'The file is too large to upload. The file size limit is 20MB',
|
|
),
|
|
),
|
|
);
|
|
setState(() {
|
|
_attachments.remove(attachment);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
final url = await _uploadAttachment(file, fileType, channel);
|
|
|
|
if (fileType == DefaultAttachmentTypes.image) {
|
|
attachment.attachment = attachment.attachment.copyWith(
|
|
imageUrl: url,
|
|
);
|
|
} else {
|
|
attachment.attachment = attachment.attachment.copyWith(
|
|
assetUrl: url,
|
|
);
|
|
}
|
|
|
|
setState(() {
|
|
attachment.uploaded = true;
|
|
});
|
|
}
|
|
|
|
Future<String> _uploadAttachment(
|
|
PlatformFile file,
|
|
DefaultAttachmentTypes type,
|
|
Channel channel,
|
|
) async {
|
|
String url;
|
|
if (type == DefaultAttachmentTypes.image) {
|
|
if (widget.doImageUploadRequest != null) {
|
|
url = await widget.doImageUploadRequest(file, channel);
|
|
} else {
|
|
url = await _uploadImage(file, channel);
|
|
}
|
|
} else {
|
|
if (widget.doFileUploadRequest != null) {
|
|
url = await widget.doFileUploadRequest(file, channel);
|
|
} else {
|
|
url = await _uploadFile(file, channel);
|
|
}
|
|
}
|
|
return url;
|
|
}
|
|
|
|
Future<String> _uploadImage(PlatformFile file, Channel channel) async {
|
|
final filename = file.path?.split('/')?.last;
|
|
final mimeType = _getMimeType(filename);
|
|
final bytes = file.bytes;
|
|
final res = await channel.sendImage(
|
|
MultipartFile.fromBytes(
|
|
bytes,
|
|
filename: filename,
|
|
contentType: mimeType,
|
|
),
|
|
);
|
|
return res.file;
|
|
}
|
|
|
|
httpParser.MediaType _getMimeType(String filename) {
|
|
httpParser.MediaType mimeType;
|
|
if (filename != null) {
|
|
if (filename.toLowerCase().endsWith('heic')) {
|
|
mimeType = httpParser.MediaType.parse('image/heic');
|
|
} else {
|
|
mimeType = httpParser.MediaType.parse(lookupMimeType(filename));
|
|
}
|
|
}
|
|
|
|
return mimeType;
|
|
}
|
|
|
|
Future<String> _uploadFile(PlatformFile file, Channel channel) async {
|
|
final filename = file.path?.split('/')?.last;
|
|
final mimeType = _getMimeType(filename);
|
|
final bytes = file.bytes;
|
|
final res = await channel.sendFile(
|
|
MultipartFile.fromBytes(
|
|
bytes,
|
|
filename: filename,
|
|
contentType: mimeType,
|
|
),
|
|
);
|
|
return res.file;
|
|
}
|
|
|
|
Widget _buildIdleSendButton(BuildContext context) {
|
|
return StreamSvgIcon(
|
|
assetName: _getIdleSendIcon(),
|
|
color: StreamChatTheme.of(context).colorTheme.greyGainsboro,
|
|
);
|
|
}
|
|
|
|
Widget _buildSendButton(BuildContext context) {
|
|
return 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).colorTheme.accentBlue,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (_commandEnabled) {
|
|
text = '/${_chosenCommand.name} ' + text;
|
|
}
|
|
|
|
final attachments = List<_SendingAttachment>.from(_attachments);
|
|
|
|
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: _getAttachments(attachments).toList(),
|
|
mentionedUsers:
|
|
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
|
|
);
|
|
} else {
|
|
message = (widget.initialMessage ?? Message()).copyWith(
|
|
parentId: widget.parentMessage?.id,
|
|
text: text,
|
|
attachments: _getAttachments(attachments).toList(),
|
|
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();
|
|
}
|
|
|
|
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.then((resp) {
|
|
if (widget.onMessageSent != null) {
|
|
widget.onMessageSent(resp.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
Iterable<Attachment> _getAttachments(List<_SendingAttachment> attachments) {
|
|
return attachments.map((attachment) {
|
|
return attachment.attachment;
|
|
});
|
|
}
|
|
|
|
StreamSubscription _keyboardListener;
|
|
|
|
@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;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _parseExistingMessage(Message message) {
|
|
textEditingController.text = message.text;
|
|
|
|
_messageIsPresent = true;
|
|
|
|
message.attachments?.forEach((attachment) {
|
|
_attachments.add(_SendingAttachment(
|
|
attachment: attachment,
|
|
uploaded: true,
|
|
));
|
|
});
|
|
}
|
|
|
|
@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();
|
|
}
|
|
}
|
|
|
|
class _SendingAttachment {
|
|
PlatformFile file;
|
|
Attachment attachment;
|
|
bool uploaded;
|
|
String id;
|
|
|
|
_SendingAttachment({
|
|
this.file,
|
|
this.attachment,
|
|
this.uploaded = false,
|
|
this.id,
|
|
});
|
|
}
|
|
|
|
/// 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) =>
|
|
other is Tuple2 && other.item1 == item1 && other.item2 == item2;
|
|
}
|