Merge pull request #169 from GetStream/feat/messg-reply

Add Message Reply feature
This commit is contained in:
Salvatore Giordano
2021-01-05 10:29:23 +01:00
committed by GitHub
20 changed files with 1200 additions and 483 deletions
+1 -1
View File
@@ -1 +1 @@
13a41b8138c4868054e44b5158f3bdd6
c770358a10272b4ee1cdc2eb432445f6
+17 -5
View File
@@ -5,27 +5,39 @@
<testcase classname="fastlane.lanes" name="0: Verifying fastlane version" time="0.00094">
<testcase classname="fastlane.lanes" name="0: Verifying fastlane version" time="0.00047">
</testcase>
<testcase classname="fastlane.lanes" name="1: default_platform" time="0.000521">
<testcase classname="fastlane.lanes" name="1: default_platform" time="0.000176">
</testcase>
<testcase classname="fastlane.lanes" name="2: is_ci" time="0.000605">
<testcase classname="fastlane.lanes" name="2: is_ci" time="0.000168">
</testcase>
<testcase classname="fastlane.lanes" name="3: is_ci" time="0.000675">
<testcase classname="fastlane.lanes" name="3: Switch to ios match_me lane" time="0.000157">
</testcase>
<testcase classname="fastlane.lanes" name="4: match" time="48.727951">
<testcase classname="fastlane.lanes" name="4: is_ci" time="0.000161">
</testcase>
<testcase classname="fastlane.lanes" name="5: match" time="32.299713">
</testcase>
<testcase classname="fastlane.lanes" name="6: gym" time="9.737819">
<failure message="/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `chdir'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing'&#10;Fastfile:28:in `block (2 levels) in parsing_binding'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane.rb:33:in `call'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `chdir'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `execute'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:108:in `block (2 levels) in run'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:178:in `call'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:153:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/runner.rb:476:in `run_active_command'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:76:in `run!'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/delegates.rb:15:in `run!'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:352:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:41:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/cli_tools_distributor.rb:119:in `take_off'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/bin/fastlane:23:in `&lt;top (required)&gt;'&#10;/usr/local/bin/fastlane:23:in `load'&#10;/usr/local/bin/fastlane:23:in `&lt;top (required)&gt;'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `load'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `kernel_load'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:28:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:465:in `exec'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/command.rb:27:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/invocation.rb:126:in `invoke_command'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor.rb:387:in `dispatch'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:27:in `dispatch'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/base.rb:466:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:18:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:30:in `block in &lt;top (required)&gt;'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/friendly_errors.rb:124:in `with_friendly_errors'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:22:in `&lt;top (required)&gt;'&#10;/usr/local/bin/bundle:23:in `load'&#10;/usr/local/bin/bundle:23:in `&lt;main&gt;'&#10;&#10;Error building the application - see the log above" />
</testcase>
+38 -5
View File
@@ -444,7 +444,7 @@ class ChannelPageArgs {
});
}
class ChannelPage extends StatelessWidget {
class ChannelPage extends StatefulWidget {
final int initialScrollIndex;
final double initialAlignment;
final bool highlightInitialMessage;
@@ -456,6 +456,31 @@ class ChannelPage extends StatelessWidget {
this.highlightInitialMessage = false,
}) : super(key: key);
@override
_ChannelPageState createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
Message _quotedMessage;
FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _reply(Message message) {
setState(() => _quotedMessage = message);
_focusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -507,9 +532,11 @@ class ChannelPage extends StatelessWidget {
child: Stack(
children: <Widget>[
MessageListView(
initialScrollIndex: initialScrollIndex,
initialAlignment: initialAlignment,
highlightInitialMessage: highlightInitialMessage,
initialScrollIndex: widget.initialScrollIndex,
initialAlignment: widget.initialAlignment,
highlightInitialMessage: widget.highlightInitialMessage,
onMessageSwiped: _reply,
onReplyTap: _reply,
threadBuilder: (_, parentMessage) {
return ThreadPage(
parent: parentMessage,
@@ -557,7 +584,13 @@ class ChannelPage extends StatelessWidget {
],
),
),
MessageInput(),
MessageInput(
focusNode: _focusNode,
quotedMessage: _quotedMessage,
onQuotedMessageCleared: () {
setState(() => _quotedMessage = null);
},
),
],
),
);
+1 -1
View File
@@ -1,6 +1,6 @@
name: example
description: A new Flutter project.
version: 1.0.108+111
version: 1.0.110+113
environment:
sdk: ">=2.2.2 <3.0.0"
+14
View File
@@ -0,0 +1,14 @@
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${this.substring(1)}";
}
}
/// List extension
extension ListX<T> on List<T> {
/// Insert any item<T> inBetween the list items
List<T> insertBetween(T item) => expand((e) sync* {
yield item;
yield e;
}).skip(1).toList(growable: false);
}
+2 -103
View File
@@ -97,7 +97,7 @@ class _FileAttachmentState extends State<FileAttachment> {
height: 3.0,
),
Text(
'${_getSizeText(widget.attachment.extraData['file_size'])}',
'${getSizeText(widget.attachment.extraData['file_size'])}',
style: StreamChatTheme.of(context).textTheme.body.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
@@ -122,37 +122,6 @@ class _FileAttachmentState extends State<FileAttachment> {
),
],
),
// ListTile(
// dense: true,
// leading: Container(
// child: _getFileTypeImage(attachment.extraData['mime_type']),
// height: 40.0,
// width: 33.33,
// ),
// title: Text(
// attachment?.title ?? 'File',
// style: TextStyle(
// fontWeight: FontWeight.bold,
// ),
// maxLines: 3,
// ),
// subtitle: Text(
// '${attachment.extraData['file_size'] ?? 'N/A'} bytes',
// style: TextStyle(
// color: StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5),
// ),
// ),
// trailing: trailing ??
// IconButton(
// icon: StreamSvgIcon.cloud_download(
// color: StreamChatTheme.of(context).colorTheme.black,
// ),
// onPressed: () {
// launchURL(context, attachment.assetUrl);
// },
// ),
// ),
),
);
}
@@ -226,76 +195,6 @@ class _FileAttachmentState extends State<FileAttachment> {
break;
}
}
switch (widget.attachment.extraData['mime_type']) {
case '7z':
return StreamSvgIcon.filetype_7z();
break;
case 'csv':
return StreamSvgIcon.filetype_csv();
break;
case 'doc':
return StreamSvgIcon.filetype_doc();
break;
case 'docx':
return StreamSvgIcon.filetype_docx();
break;
case 'html':
return StreamSvgIcon.filetype_html();
break;
case 'md':
return StreamSvgIcon.filetype_md();
break;
case 'odt':
return StreamSvgIcon.filetype_odt();
break;
case 'pdf':
return StreamSvgIcon.filetype_pdf();
break;
case 'ppt':
return StreamSvgIcon.filetype_ppt();
break;
case 'pptx':
return StreamSvgIcon.filetype_pptx();
break;
case 'rar':
return StreamSvgIcon.filetype_rar();
break;
case 'rtf':
return StreamSvgIcon.filetype_rtf();
break;
case 'tar':
return StreamSvgIcon.filetype_tar();
break;
case 'txt':
return StreamSvgIcon.filetype_txt();
break;
case 'xls':
return StreamSvgIcon.filetype_xls();
break;
case 'xlsx':
return StreamSvgIcon.filetype_xlsx();
break;
case 'zip':
return StreamSvgIcon.filetype_zip();
break;
default:
return StreamSvgIcon.filetype_Generic();
break;
}
}
String _getSizeText(int bytes) {
if (bytes == null) {
return 'Size N/A';
}
if (bytes <= 1000) {
return '${bytes} bytes';
} else if (bytes <= 100000) {
return '${(bytes / 1000).toStringAsFixed(2)} KB';
} else {
return '${(bytes / 1000000).toStringAsFixed(2)} MB';
}
return getFileTypeImage(widget.attachment.extraData['mime_type']);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ class LazyLoadScrollView extends StatefulWidget {
final bool isLoading;
/// Initiates a LazyLoadScrollView widget
LazyLoadScrollView({
const LazyLoadScrollView({
Key key,
@required this.child,
this.onStartOfPage,
+2 -3
View File
@@ -29,6 +29,7 @@ class MediaListView extends StatefulWidget {
this.selectedIds = const [],
this.onSelect,
}) : super(key: key);
@override
_MediaListViewState createState() => _MediaListViewState();
}
@@ -41,9 +42,7 @@ class _MediaListViewState extends State<MediaListView> {
@override
Widget build(BuildContext context) {
return LazyLoadScrollView(
onEndOfPage: () async {
return _getMedia();
},
onEndOfPage: () async => _getMedia(),
child: GridView.builder(
itemCount: _media.length,
controller: _scrollController,
+60 -36
View File
@@ -15,7 +15,8 @@ import 'stream_chat_theme.dart';
class MessageActionsModal extends StatelessWidget {
final Widget Function(BuildContext, Message) editMessageInputBuilder;
final void Function(Message) onThreadTap;
final void Function(Message) onThreadReplyTap;
final void Function(Message) onReplyTap;
final Message message;
final MessageTheme messageTheme;
final bool showReactions;
@@ -24,6 +25,7 @@ class MessageActionsModal extends StatelessWidget {
final bool showEditMessage;
final bool showResendMessage;
final bool showReply;
final bool showThreadReply;
final bool reverse;
final ShapeBorder messageShape;
final DisplayWidget showUserAvatar;
@@ -35,10 +37,12 @@ class MessageActionsModal extends StatelessWidget {
this.showReactions = true,
this.showDeleteMessage = true,
this.showEditMessage = true,
this.onThreadTap,
this.onReplyTap,
this.onThreadReplyTap,
this.showCopyMessage = true,
this.showReply = true,
this.showResendMessage = true,
this.showThreadReply = true,
this.showUserAvatar = DisplayWidget.show,
this.editMessageInputBuilder,
this.messageShape,
@@ -51,17 +55,25 @@ class MessageActionsModal extends StatelessWidget {
final user = StreamChat.of(context).user;
final roughMaxSize = 2 * size.width / 3;
var messageTextLength = message.text.length;
if (message.quotedMessage != null) {
var quotedMessageLength = message.quotedMessage.text.length + 40;
if (message.quotedMessage.attachments?.isNotEmpty == true) {
quotedMessageLength += 40;
}
if (quotedMessageLength > messageTextLength) {
messageTextLength = quotedMessageLength;
}
}
final roughSentenceSize =
message.text.length * messageTheme.messageText.fontSize * 1.2;
messageTextLength * messageTheme.messageText.fontSize * 1.2;
final divFactor = message.attachments?.isNotEmpty == true
? 1
: (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize));
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
Navigator.pop(context);
},
onTap: () => Navigator.pop(context),
child: Stack(
children: [
Positioned.fill(
@@ -116,6 +128,7 @@ class MessageActionsModal extends StatelessWidget {
showReactions: false,
showUsername: false,
showThreadReplyIndicator: false,
showReplyIndicator: false,
showUserAvatar: showUserAvatar,
showTimestamp: false,
translateUserAvatar: false,
@@ -165,6 +178,12 @@ class MessageActionsModal extends StatelessWidget {
message.status == null) &&
message.parentId == null)
_buildReplyButton(context),
if (showThreadReply &&
(message.status ==
MessageSendingStatus.SENT ||
message.status == null) &&
message.parentId == null)
_buildThreadReplyButton(context),
if (showResendMessage)
_buildResendMessage(context),
if (showEditMessage)
@@ -190,6 +209,24 @@ class MessageActionsModal extends StatelessWidget {
);
}
Widget _buildReplyButton(BuildContext context) {
return ListTile(
title: Text(
'Reply',
style: Theme.of(context).textTheme.headline6,
),
leading: StreamSvgIcon.reply(
color: StreamChatTheme.of(context).primaryIconTheme.color,
),
onTap: () {
Navigator.pop(context);
if (onReplyTap != null) {
onReplyTap(message);
}
},
);
}
Widget _buildDeleteButton(BuildContext context) {
final isDeleteFailed = message.status == MessageSendingStatus.FAILED_DELETE;
return ListTile(
@@ -276,8 +313,8 @@ class MessageActionsModal extends StatelessWidget {
backgroundColor: StreamChatTheme.of(context).colorTheme.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (context) {
@@ -289,39 +326,26 @@ class MessageActionsModal extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
top: 16.0,
left: 16.0,
right: 16.0,
),
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: StreamSvgIcon.edit(
size: 22,
color:
StreamChatTheme.of(context).primaryIconTheme.color,
Padding(
padding: const EdgeInsets.all(8.0),
child: StreamSvgIcon.edit(
color: StreamChatTheme.of(context)
.colorTheme
.greyGainsboro,
),
onPressed: () {},
),
Text(
'Edit message',
style: Theme.of(context)
.textTheme
.headline6
.copyWith(fontWeight: FontWeight.bold),
'Edit Message',
style: TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
icon: Icon(
Icons.cancel_outlined,
size: 22,
color:
StreamChatTheme.of(context).primaryIconTheme.color,
),
onPressed: () {
Navigator.of(context).pop();
},
visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.close_small(),
onPressed: Navigator.of(context).pop,
),
],
),
@@ -348,7 +372,7 @@ class MessageActionsModal extends StatelessWidget {
);
}
Widget _buildReplyButton(BuildContext context) {
Widget _buildThreadReplyButton(BuildContext context) {
return ListTile(
title: Text(
'Thread reply',
@@ -359,8 +383,8 @@ class MessageActionsModal extends StatelessWidget {
),
onTap: () {
Navigator.pop(context);
if (onThreadTap != null) {
onThreadTap(message);
if (onThreadReplyTap != null) {
onThreadReplyTap(message);
}
},
);
+166 -99
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart';
@@ -21,8 +22,10 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart';
import 'extension.dart';
import '../stream_chat_flutter.dart';
import 'quoted_message_widget.dart';
import 'stream_channel.dart';
typedef FileUploader = Future<String> Function(PlatformFile, Channel);
@@ -107,6 +110,8 @@ class MessageInput extends StatefulWidget {
this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders,
this.focusNode,
this.quotedMessage,
this.onQuotedMessageCleared,
}) : super(key: key);
/// Message to edit
@@ -155,6 +160,12 @@ class MessageInput extends StatefulWidget {
/// The focus node associated to the TextField
final FocusNode focusNode;
///
final Message quotedMessage;
///
final VoidCallback onQuotedMessageCleared;
@override
MessageInputState createState() => MessageInputState();
@@ -166,7 +177,7 @@ class MessageInput extends StatefulWidget {
if (messageInputState == null) {
throw Exception(
'You must have a MessageInput widget as anchestor of your widget tree');
'You must have a MessageInput widget as ancestor of your widget tree');
}
return messageInputState;
@@ -196,9 +207,11 @@ class MessageInputState extends State<MessageInput> {
/// The editing controller passed to the input TextField
TextEditingController textEditingController;
bool get _hasQuotedMessage => widget.quotedMessage != null;
@override
Widget build(BuildContext context) {
return SafeArea(
Widget child = SafeArea(
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy > 0) {
@@ -213,6 +226,32 @@ class MessageInputState extends State<MessageInput> {
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),
@@ -227,6 +266,14 @@ class MessageInputState extends State<MessageInput> {
),
),
);
if (widget.editMessage == null) {
child = Material(
color: StreamChatTheme.of(context).colorTheme.white,
elevation: 8,
child: child,
);
}
return child;
}
Flex _buildTextField(BuildContext context) {
@@ -328,17 +375,23 @@ class MessageInputState extends State<MessageInput> {
return AnimatedCrossFade(
crossFadeState:
_actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond,
firstChild: InkWell(
onTap: () {
setState(() {
_actionsShrunk = false;
});
},
child: Padding(
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: StreamSvgIcon.emptyCircleLeft(
firstChild: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: 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(
@@ -370,7 +423,9 @@ class MessageInputState extends State<MessageInput> {
padding: _attachments.isEmpty ? null : EdgeInsets.all(6.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildReplyToMessage(),
_buildAttachments(),
LimitedBox(
maxHeight: widget.maxHeight,
@@ -416,7 +471,7 @@ class MessageInputState extends State<MessageInput> {
.accentBlue,
padding: EdgeInsets.zero,
labelPadding:
EdgeInsets.symmetric(horizontal: 9.0),
EdgeInsets.symmetric(horizontal: 8.0),
label: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -428,7 +483,7 @@ class MessageInputState extends State<MessageInput> {
size: 16.0,
),
Text(
_chosenCommand?.name?.toUpperCase() ?? "",
_chosenCommand?.name?.toUpperCase() ?? '',
style: StreamChatTheme.of(context)
.textTheme
.footnote
@@ -443,23 +498,14 @@ class MessageInputState extends State<MessageInput> {
)
: null,
suffixIcon: _commandEnabled
? InkWell(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: StreamSvgIcon.close_small(),
),
onTap: () {
setState(() {
_commandEnabled = false;
});
? IconButton(
icon: StreamSvgIcon.close_small(),
splashRadius: 24,
onPressed: () {
setState(() => _commandEnabled = false);
},
)
: null,
suffixIconConstraints: BoxConstraints(
maxHeight: 24.0,
maxWidth: 40.0,
),
),
textCapitalization: TextCapitalization.sentences,
),
@@ -1307,6 +1353,25 @@ class MessageInputState extends State<MessageInput> {
_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,
),
);
}
Widget _buildAttachments() {
return _attachments.isEmpty
? Container()
@@ -1520,55 +1585,62 @@ class MessageInputState extends State<MessageInput> {
}
Widget _buildCommandButton() {
return InkWell(
child: Padding(
padding:
const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0),
child: StreamSvgIcon.lightning(
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: IconButton(
icon: StreamSvgIcon.lightning(
color: _commandsOverlay != null
? StreamChatTheme.of(context).colorTheme.accentBlue
: StreamChatTheme.of(context).colorTheme.grey,
),
),
onTap: () async {
if (_openFilePickerSection) {
setState(() {
_animateContainer = false;
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
await Future.delayed(Duration(milliseconds: 300));
}
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;
});
}
},
if (_commandsOverlay == null) {
setState(() {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
});
} else {
setState(() {
_commandsOverlay?.remove();
_commandsOverlay = null;
});
}
},
),
);
}
Widget _buildAttachmentButton() {
var padding = widget.editMessage == null ? 4.0 : 8.0;
return Center(
child: InkWell(
child: Padding(
padding:
EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0),
child: StreamSvgIcon.attach(
color: _openFilePickerSection
? StreamChatTheme.of(context).colorTheme.accentBlue
: StreamChatTheme.of(context).colorTheme.grey,
),
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: IconButton(
icon: StreamSvgIcon.attach(
color: _openFilePickerSection
? StreamChatTheme.of(context).colorTheme.accentBlue
: StreamChatTheme.of(context).colorTheme.grey,
),
onTap: () async {
padding: const EdgeInsets.all(0),
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () async {
_emojiOverlay?.remove();
_emojiOverlay = null;
_commandsOverlay?.remove();
@@ -1886,36 +1958,29 @@ class MessageInputState extends State<MessageInput> {
Widget _buildIdleSendButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: Center(
child: InkWell(
onTap: () {
sendMessage();
},
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color: StreamChatTheme.of(context).colorTheme.greyGainsboro,
height: 24.0,
width: 24.0,
),
)),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color: StreamChatTheme.of(context).colorTheme.greyGainsboro,
),
);
}
Widget _buildSendButton(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: InkWell(
onTap: () {
sendMessage();
},
child: StreamSvgIcon(
assetName: _getSendIcon(),
color: StreamChatTheme.of(context).colorTheme.accentBlue,
height: 24.0,
width: 24.0,
),
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: IconButton(
onPressed: sendMessage,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(0),
splashRadius: 24,
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
icon: StreamSvgIcon(
assetName: _getSendIcon(),
color: StreamChatTheme.of(context).colorTheme.accentBlue,
),
),
);
@@ -1954,6 +2019,9 @@ class MessageInputState extends State<MessageInput> {
textEditingController.clear();
_attachments.clear();
if (widget.onQuotedMessageCleared != null) {
widget.onQuotedMessageCleared();
}
setState(() {
_messageIsPresent = false;
@@ -1985,6 +2053,12 @@ class MessageInputState extends State<MessageInput> {
);
}
if (widget.quotedMessage != null) {
message = message.copyWith(
quotedMessageId: widget.quotedMessage.id,
);
}
if (widget.preMessageSending != null) {
message = await widget.preMessageSending(message);
}
@@ -2023,7 +2097,6 @@ class MessageInputState extends State<MessageInput> {
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames = Emoji.all().map((e) => e.name);
@@ -2101,12 +2174,6 @@ class _SendingAttachment {
});
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${this.substring(1)}";
}
}
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Returns the first item of the tuple
+49 -2
View File
@@ -16,6 +16,7 @@ import 'package:visibility_detector/visibility_detector.dart';
import '../stream_chat_flutter.dart';
import 'date_divider.dart';
import 'stream_channel.dart';
import 'swipeable.dart';
typedef MessageBuilder = Widget Function(
BuildContext,
@@ -29,6 +30,9 @@ typedef ParentMessageBuilder = Widget Function(
typedef ThreadBuilder = Widget Function(BuildContext context, Message parent);
typedef ThreadTapCallback = void Function(Message, Widget);
typedef OnMessageSwiped = void Function(Message);
typedef ReplyTapCallback = void Function(Message);
class MessageDetails {
/// True if the message belongs to the current user
bool isMyMessage;
@@ -109,12 +113,14 @@ class MessageListView extends StatefulWidget {
this.parentMessage,
this.threadBuilder,
this.onThreadTap,
this.onReplyTap,
this.dateDividerBuilder,
this.scrollPhysics = const AlwaysScrollableScrollPhysics(),
this.scrollPhysics = const ClampingScrollPhysics(),
this.initialScrollIndex,
this.initialAlignment,
this.scrollController,
this.itemPositionListener,
this.onMessageSwiped,
this.highlightInitialMessage = false,
this.onShowMessage,
}) : super(key: key);
@@ -158,6 +164,12 @@ class MessageListView extends StatefulWidget {
/// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics;
/// Called when message item gets swiped
final OnMessageSwiped onMessageSwiped;
///
final ReplyTapCallback onReplyTap;
/// If true the list will highlight the initialMessage if there is any.
///
/// Also See [StreamChannel]
@@ -398,7 +410,7 @@ class _MessageListViewState extends State<MessageListView> {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
builder: (context) => widget.messageBuilder(
context,
MessageDetails(
context,
@@ -690,6 +702,7 @@ class _MessageListViewState extends State<MessageListView> {
return MessageWidget(
showThreadReplyIndicator: false,
showInChannelIndicator: false,
showReplyIndicator: false,
message: message,
reverse: isMyMessage,
showUsername: !isMyMessage,
@@ -748,6 +761,9 @@ class _MessageListViewState extends State<MessageListView> {
final allRead = readList.length >= (channel.memberCount ?? 0) - 1;
final isThreadMessage =
widget.parentMessage != null || message?.showInChannel == true;
Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
@@ -759,6 +775,26 @@ class _MessageListViewState extends State<MessageListView> {
bottom: index == 0 ? 30 : (isNextUser ? 2 : 7),
top: 3,
),
onQuotedMessageTap: (quotedMessageId) async {
final scrollToIndex = () {
final index = messages.indexWhere((m) => m.id == quotedMessageId);
_scrollController?.scrollTo(
index: index,
duration: const Duration(milliseconds: 350),
);
};
if (messages.map((e) => e.id).contains(quotedMessageId)) {
scrollToIndex();
} else {
streamChannel.loadChannelAtMessage(quotedMessageId).then((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (messages.map((e) => e.id).contains(quotedMessageId)) {
scrollToIndex();
}
});
});
}
},
showInChannelIndicator: widget.parentMessage == null,
showThreadReplyIndicator: widget.parentMessage == null,
showUsername: !isMyMessage && !isNextUser,
@@ -771,6 +807,7 @@ class _MessageListViewState extends State<MessageListView> {
showDeleteMessage: isMyMessage,
borderSide: isMyMessage ? BorderSide.none : null,
onThreadTap: _onThreadTap,
onReplyTap: widget.onReplyTap,
attachmentBorderRadiusGeometry: BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(!isNextUser ? 0 : 16),
@@ -795,6 +832,16 @@ class _MessageListViewState extends State<MessageListView> {
onShowMessage: widget.onShowMessage,
);
if (!isThreadMessage) {
child = Swipeable(
onSwipeEnd: () => widget.onMessageSwiped(message),
backgroundIcon: StreamSvgIcon.reply(
color: StreamChatTheme.of(context).colorTheme.accentBlue,
),
child: child,
);
}
if (!initialMessageHighlightComplete &&
widget.highlightInitialMessage &&
_isInitialMessage(message.id)) {
+227 -213
View File
@@ -11,13 +11,16 @@ import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/message_actions_modal.dart';
import 'package:stream_chat_flutter/src/message_reactions_modal.dart';
import 'package:stream_chat_flutter/src/reaction_bubble.dart';
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
import 'package:stream_chat_flutter/src/url_attachment.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'image_group.dart';
import 'message_text.dart';
import 'extension.dart';
typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment);
typedef OnQuotedMessageTap = void Function(String);
/// The display behaviour of a widget
enum DisplayWidget {
@@ -46,6 +49,7 @@ class MessageWidget extends StatefulWidget {
/// The function called when tapping on replies
final void Function(Message) onThreadTap;
final void Function(Message) onReplyTap;
final Widget Function(BuildContext, Message) editMessageInputBuilder;
final Widget Function(BuildContext, Message) textBuilder;
@@ -102,6 +106,9 @@ class MessageWidget extends StatefulWidget {
/// If true the widget will show the thread reply indicator
final bool showThreadReplyIndicator;
/// If true the widget will show the reply indicator
final bool showReplyIndicator;
/// If true the widget will show the show in channel indicator
final bool showInChannelIndicator;
@@ -131,6 +138,9 @@ class MessageWidget extends StatefulWidget {
/// Center user avatar with bottom of the message
final bool translateUserAvatar;
/// Function called when quotedMessage is tapped
final OnQuotedMessageTap onQuotedMessageTap;
///
MessageWidget({
Key key,
@@ -150,6 +160,8 @@ class MessageWidget extends StatefulWidget {
this.showSendingIndicator = DisplayWidget.show,
this.showThreadReplyIndicator = true,
this.showInChannelIndicator = true,
this.showReplyIndicator = true,
this.onReplyTap,
this.onThreadTap,
this.showUsername = true,
this.showTimestamp = true,
@@ -172,6 +184,7 @@ class MessageWidget extends StatefulWidget {
),
this.attachmentPadding = EdgeInsets.zero,
this.allRead = false,
this.onQuotedMessageTap,
}) : attachmentBuilders = {
'image': (context, message, attachment) {
return ImageAttachment(
@@ -239,6 +252,8 @@ class _MessageWidgetState extends State<MessageWidget> {
bool get showInChannel =>
widget.showInChannelIndicator && widget.message?.showInChannel == true;
bool get _hasQuotedMessage => widget.message?.quotedMessage != null;
bool get isSendFailed => widget.message.status == MessageSendingStatus.FAILED;
bool get isUpdateFailed =>
@@ -261,185 +276,201 @@ class _MessageWidgetState extends State<MessageWidget> {
? widget.messageTheme.avatarTheme.constraints.maxWidth + 14.5
: 6.0;
final isGiphy =
widget.message.attachments?.any((element) => element.type == 'giphy') ==
true;
final isOnlyEmoji =
widget.message.text.characters.every((c) => Emoji.byChar(c) != null);
final hasFiles =
widget.message.attachments?.any((element) => element.type == 'file') ==
true;
return Portal(
child: GestureDetector(
onLongPress: () => onLongPress(context),
child: Padding(
padding: widget.padding ?? EdgeInsets.all(8),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Stack(
clipBehavior: Clip.none,
alignment: AlignmentDirectional.bottomStart,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show)
_buildUserAvatar(),
SizedBox(width: 6),
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(
width: widget.messageTheme.avatarTheme
.constraints.maxWidth +
8,
),
Flexible(
child: PortalEntry(
portal: Container(
transform:
Matrix4.translationValues(-16, 2, 0),
child: _buildReactionIndicator(context),
constraints:
BoxConstraints(maxWidth: 22 * 6.0),
return Material(
type: MaterialType.transparency,
child: Portal(
child: InkWell(
onLongPress: widget.message.isDeleted && !isFailedState
? null
: () => onLongPress(context),
child: Padding(
padding: widget.padding ?? EdgeInsets.all(8),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Stack(
clipBehavior: Clip.none,
alignment: AlignmentDirectional.bottomStart,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show)
_buildUserAvatar(),
SizedBox(width: 6),
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(
width: widget.messageTheme.avatarTheme
.constraints.maxWidth +
8,
),
portalAnchor: Alignment(-1.0, -1.0),
childAnchor: Alignment(1, -1.0),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: DeletedMessage(
reverse: widget.reverse,
borderRadiusGeometry: widget
.borderRadiusGeometry,
borderSide: widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Material(
clipBehavior: Clip.antiAlias,
shape: widget.shape ??
RoundedRectangleBorder(
side: isOnlyEmoji
? BorderSide.none
: widget.borderSide ??
BorderSide(
color: Theme.of(context)
.brightness ==
Brightness
.dark
? StreamChatTheme.of(
context)
.colorTheme
.white
.withAlpha(
24)
: StreamChatTheme.of(
context)
.colorTheme
.black
.withAlpha(
24),
),
borderRadius: widget
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Padding(
padding: EdgeInsets.all(
hasFiles ? 2.0 : 0.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
..._parseAttachments(
context),
if (widget.message.text
.trim()
.isNotEmpty &&
!isGiphy)
_buildTextBubble(
Flexible(
child: PortalEntry(
portal: Container(
transform:
Matrix4.translationValues(-16, 2, 0),
child: _buildReactionIndicator(context),
constraints:
BoxConstraints(maxWidth: 22 * 6.0),
),
portalAnchor: Alignment(-1.0, -1.0),
childAnchor: Alignment(1, -1.0),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: DeletedMessage(
reverse: widget.reverse,
borderRadiusGeometry: widget
.borderRadiusGeometry,
borderSide:
widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Card(
clipBehavior: Clip.antiAlias,
elevation: 0.0,
shape: widget.shape ??
RoundedRectangleBorder(
side: isOnlyEmoji
? BorderSide.none
: widget.borderSide ??
BorderSide(
color: Theme.of(context)
.brightness ==
Brightness
.dark
? StreamChatTheme.of(
context)
.colorTheme
.white
.withAlpha(
24)
: StreamChatTheme.of(
context)
.colorTheme
.black
.withAlpha(
24),
),
borderRadius: widget
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Padding(
padding: EdgeInsets.all(
hasFiles ? 2.0 : 0.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
if (_hasQuotedMessage)
_buildQuotedMessage(),
..._parseAttachments(
context),
],
if (widget.message.text
.trim()
.isNotEmpty &&
!isGiphy)
_buildTextBubble(
context),
],
),
),
),
),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: 0,
top: -6,
child: Transform(
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: CustomPaint(
painter: ReactionBubblePainter(
widget.messageTheme
.reactionsBackgroundColor,
widget.messageTheme
.reactionsBorderColor,
),
if (widget.showReactionPickerIndicator)
Positioned(
right: 0,
top: -6,
child: Transform(
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: CustomPaint(
painter: ReactionBubblePainter(
widget.messageTheme
.reactionsBackgroundColor,
widget.messageTheme
.reactionsBorderColor,
),
),
),
),
),
],
],
),
),
),
),
],
),
if (showBottomRow) SizedBox(height: 20.0),
],
),
if (showBottomRow) _buildBottomRow(leftPadding),
if (isFailedState)
Positioned(
left: widget.reverse ? -3 : null,
right: widget.reverse ? null : -9,
bottom: showBottomRow ? 20 : 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
],
),
child: StreamSvgIcon.error(size: 20),
),
if (showBottomRow) SizedBox(height: 20.0),
],
),
],
),
],
if (showBottomRow) _buildBottomRow(leftPadding),
if (isFailedState)
Positioned(
left: widget.reverse ? -3 : null,
right: widget.reverse ? null : -9,
bottom: showBottomRow ? 20 : 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: StreamSvgIcon.error(size: 20),
),
),
],
),
],
),
),
),
),
@@ -448,6 +479,23 @@ class _MessageWidgetState extends State<MessageWidget> {
);
}
Widget _buildQuotedMessage() {
final isMyMessage =
widget.message.user.id == StreamChat.of(context).user.id;
return QuotedMessageWidget(
onTap: () {
if (widget.onQuotedMessageTap != null) {
widget.onQuotedMessageTap(widget.message.quotedMessageId);
}
},
message: widget.message.quotedMessage,
messageTheme: isMyMessage
? StreamChatTheme.of(context).otherMessageTheme
: StreamChatTheme.of(context).ownMessageTheme,
reverse: widget.reverse,
);
}
Widget _buildBottomRow(double leftPadding) {
final deleted = widget.message.isDeleted;
var children = <Widget>[];
@@ -572,7 +620,7 @@ class _MessageWidgetState extends State<MessageWidget> {
var splitList = host.split('.');
var hostName = splitList.length == 3 ? splitList[1] : splitList[0];
var hostDisplayName = urlAttachment.authorName?.capitalize() ??
_getWebsiteName(hostName.toLowerCase()) ??
getWebsiteName(hostName.toLowerCase()) ??
hostName.capitalize();
return UrlAttachment(
@@ -637,7 +685,8 @@ class _MessageWidgetState extends State<MessageWidget> {
showDeleteMessage: widget.showDeleteMessage,
message: widget.message,
editMessageInputBuilder: widget.editMessageInputBuilder,
onThreadTap: widget.onThreadTap,
onReplyTap: widget.onReplyTap,
onThreadReplyTap: widget.onThreadTap,
showResendMessage:
widget.showResendMessage && (isSendFailed || isUpdateFailed),
showCopyMessage: !isFailedState &&
@@ -648,9 +697,11 @@ class _MessageWidgetState extends State<MessageWidget> {
?.any((element) => element.type == 'giphy') !=
true,
showReactions: widget.showReactions,
showReply: widget.showThreadReplyIndicator &&
showReply: widget.showReplyIndicator &&
!isFailedState &&
widget.onThreadTap != null,
widget.onReplyTap != null,
showThreadReply:
widget.showThreadReplyIndicator && widget.onThreadTap != null,
),
);
});
@@ -835,7 +886,7 @@ class _MessageWidgetState extends State<MessageWidget> {
);
Widget _buildTextBubble(BuildContext context) {
Widget child = Transform(
return Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: Column(
@@ -859,13 +910,13 @@ class _MessageWidgetState extends State<MessageWidget> {
),
),
if (widget.message.attachments
?.any((element) => element.ogScrapeUrl != null) ==
true)
?.any((element) => element.ogScrapeUrl != null) ==
true &&
!_hasQuotedMessage)
_buildUrlAttachment(),
],
),
);
return child;
}
bool get isOnlyEmoji =>
@@ -873,6 +924,10 @@ class _MessageWidgetState extends State<MessageWidget> {
widget.message.text.characters.every((c) => Emoji.byChar(c) != null);
Color _getBackgroundColor() {
if (_hasQuotedMessage) {
return widget.messageTheme.messageBackgroundColor;
}
if (widget.message.attachments
?.any((element) => element.ogScrapeUrl != null) ==
true) {
@@ -912,47 +967,6 @@ class _MessageWidgetState extends State<MessageWidget> {
return;
}
}
String _getWebsiteName(String hostName) {
switch (hostName) {
case 'reddit':
return 'Reddit';
case 'youtube':
return 'Youtube';
case 'wikipedia':
return 'Wikipedia';
case 'twitter':
return 'Twitter';
case 'facebook':
return 'Facebook';
case 'amazon':
return 'Amazon';
case 'yelp':
return 'Yelp';
case 'imdb':
return 'IMDB';
case 'pinterest':
return 'Pinterest';
case 'tripadvisor':
return 'TripAdvisor';
case 'instagram':
return 'Instagram';
case 'walmart':
return 'Walmart';
case 'craigslist':
return 'Craigslist';
case 'ebay':
return 'eBay';
case 'linkedin':
return 'LinkedIn';
case 'google':
return 'Google';
case 'apple':
return 'Apple';
default:
return null;
}
}
}
class _ThreadReplyPainter extends CustomPainter {
+321
View File
@@ -0,0 +1,321 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:emojis/emoji.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:video_player/video_player.dart';
import 'attachment_error.dart';
import 'image_attachment.dart';
import 'message_text.dart';
import 'stream_chat_theme.dart';
import 'user_avatar.dart';
import 'utils.dart';
import 'extension.dart';
typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function(
BuildContext,
Attachment,
);
class _VideoAttachmentThumbnail extends StatefulWidget {
final Size size;
final Attachment attachment;
const _VideoAttachmentThumbnail({
Key key,
@required this.attachment,
this.size = const Size(32, 32),
}) : super(key: key);
@override
_VideoAttachmentThumbnailState createState() =>
_VideoAttachmentThumbnailState();
}
class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> {
VideoPlayerController _controller;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.network(widget.attachment.assetUrl)
..initialize().then((_) {
setState(() {}); //when your thumbnail will show.
});
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.size.height,
width: widget.size.width,
child: _controller.value.initialized
? VideoPlayer(_controller)
: CircularProgressIndicator());
}
}
///
class QuotedMessageWidget extends StatelessWidget {
/// The message
final Message message;
/// The message theme
final MessageTheme messageTheme;
/// If true the widget will be mirrored
final bool reverse;
/// If true the message will show a grey border
final bool showBorder;
/// limit of the text message shown
final int textLimit;
/// Map that defines a thumbnail builder for an attachment type
final Map<String, QuotedMessageAttachmentThumbnailBuilder>
attachmentThumbnailBuilders;
final GestureTapCallback onTap;
///
QuotedMessageWidget({
Key key,
@required this.message,
@required this.messageTheme,
this.reverse = false,
this.showBorder = false,
this.textLimit = 170,
this.attachmentThumbnailBuilders,
this.onTap,
}) : super(key: key);
bool get _hasAttachments => message.attachments?.isNotEmpty == true;
bool get _containsScrapeUrl =>
message.attachments?.any((element) => element.ogScrapeUrl != null) ==
true;
bool get _containsText => message?.text?.isNotEmpty == true;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 6, right: 4, left: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: _buildMessage(context)),
SizedBox(width: 4),
_buildUserAvatar(),
],
),
),
);
}
Widget _buildMessage(BuildContext context) {
final isOnlyEmoji =
message.text.characters.every((c) => Emoji.byChar(c) != null);
var msg = _hasAttachments && !_containsText
? message.copyWith(text: message.attachments.last?.title ?? '')
: message;
if (msg.text.length > textLimit) {
msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...');
}
final children = [
if (_hasAttachments) _parseAttachments(context),
if (msg.text.isNotEmpty)
Flexible(
child: Transform(
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: MessageText(
message: msg,
messageTheme: isOnlyEmoji && _containsText
? messageTheme.copyWith(
messageText: messageTheme.messageText.copyWith(
fontSize: 24,
))
: messageTheme,
),
),
),
].insertBetween(const SizedBox(width: 8));
return Container(
decoration: BoxDecoration(
color: _getBackgroundColor(context),
border: showBorder
? Border.all(
color: StreamChatTheme.of(context).colorTheme.greyGainsboro,
)
: null,
borderRadius: BorderRadius.only(
topRight: Radius.circular(12),
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
reverse ? MainAxisAlignment.end : MainAxisAlignment.start,
children: reverse ? children.reversed.toList() : children,
),
);
}
Widget _buildUrlAttachment(Attachment attachment) {
final size = Size(32, 32);
if (attachment.thumbUrl != null) {
return Container(
height: size.height,
width: size.width,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: CachedNetworkImageProvider(
attachment.imageUrl,
),
),
),
);
}
return AttachmentError(
attachment: attachment,
size: size,
);
}
Widget _parseAttachments(BuildContext context) {
Widget child;
Attachment attachment;
if (_containsScrapeUrl) {
attachment = message.attachments.firstWhere(
(element) => element.ogScrapeUrl != null,
);
child = _buildUrlAttachment(attachment);
} else {
QuotedMessageAttachmentThumbnailBuilder attachmentBuilder;
attachment = message.attachments.last;
if (attachmentThumbnailBuilders?.containsKey(attachment?.type) == true) {
attachmentBuilder = attachmentThumbnailBuilders[attachment?.type];
}
attachmentBuilder = _defaultAttachmentBuilder[attachment?.type];
if (attachmentBuilder == null) {
child = Offstage();
}
child = attachmentBuilder(context, attachment);
}
child = AbsorbPointer(child: child);
return Transform(
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: Material(
clipBehavior: Clip.hardEdge,
color: Colors.transparent,
shape: attachment.type == 'file' ? null : _getDefaultShape(context),
child: child,
),
);
}
ShapeBorder _getDefaultShape(BuildContext context) {
return RoundedRectangleBorder(
side: BorderSide(
color: StreamChatTheme.of(context).colorTheme.greyWhisper,
),
borderRadius: BorderRadius.circular(8),
);
}
Widget _buildUserAvatar() {
return Transform(
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: UserAvatar(
user: message.user,
constraints: BoxConstraints.tightFor(
height: 24,
width: 24,
),
showOnlineStatus: false,
),
),
);
}
Map<String, QuotedMessageAttachmentThumbnailBuilder>
get _defaultAttachmentBuilder {
return {
'image': (_, attachment) {
return ImageAttachment(
attachment: attachment,
message: message,
messageTheme: messageTheme,
size: Size(32, 32),
);
},
'video': (_, attachment) {
return _VideoAttachmentThumbnail(
key: ValueKey(attachment.assetUrl),
attachment: attachment,
);
},
'giphy': (_, attachment) {
final size = Size(32, 32);
return CachedNetworkImage(
height: size?.height,
width: size?.width,
placeholder: (_, __) {
return Container(
width: size?.width,
height: size?.height,
child: Center(
child: CircularProgressIndicator(),
),
);
},
imageUrl:
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl,
errorWidget: (context, url, error) => AttachmentError(
attachment: attachment,
size: size,
),
fit: BoxFit.cover,
);
},
'file': (_, attachment) {
return Container(
height: 32,
width: 32,
child: getFileTypeImage(attachment.extraData['mime_type']),
);
},
};
}
Color _getBackgroundColor(BuildContext context) {
if (_containsScrapeUrl) {
return StreamChatTheme.of(context).colorTheme.blueAlice;
}
return messageTheme.messageBackgroundColor;
}
}
+14 -2
View File
@@ -10,9 +10,9 @@ class StreamSvgIcon extends StatelessWidget {
const StreamSvgIcon({
this.assetName,
this.width,
this.height,
this.color,
this.width = 24,
this.height = 24,
});
@override
@@ -326,6 +326,18 @@ class StreamSvgIcon extends StatelessWidget {
);
}
factory StreamSvgIcon.reply({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'Icon_curve_line_left_up_big.svg',
color: color,
width: size,
height: size,
);
}
factory StreamSvgIcon.edit({
double size,
Color color,
+163
View File
@@ -0,0 +1,163 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'stream_chat_theme.dart';
///
class Swipeable extends StatefulWidget {
final Widget child;
final Widget backgroundIcon;
final VoidCallback onSwipeStart;
final VoidCallback onSwipeCancel;
final VoidCallback onSwipeEnd;
final double threshold;
///
const Swipeable({
@required this.child,
@required this.backgroundIcon,
this.onSwipeStart,
this.onSwipeCancel,
this.onSwipeEnd,
this.threshold = 82.0,
});
@override
State<StatefulWidget> createState() => _SwipeableState();
}
class _SwipeableState extends State<Swipeable> with TickerProviderStateMixin {
double _dragExtent = 0.0;
AnimationController _moveController;
AnimationController _iconMoveController;
Animation<Offset> _moveAnimation;
Animation<Offset> _iconTransitionAnimation;
Animation<double> _iconFadeAnimation;
bool _pastThreshold = false;
final _animationDuration = const Duration(milliseconds: 200);
@override
void initState() {
super.initState();
_moveController =
AnimationController(duration: _animationDuration, vsync: this);
_iconMoveController =
AnimationController(duration: _animationDuration, vsync: this);
_moveAnimation = Tween<Offset>(begin: Offset.zero, end: Offset(1.0, 0.0))
.animate(_moveController);
_iconTransitionAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset(0.4, 0.0))
.animate(_moveController);
_iconFadeAnimation =
Tween<double>(begin: 0.7, end: 1.0).animate(_iconMoveController);
final controllerValue = 0.0;
_moveController.animateTo(controllerValue);
_iconMoveController.animateTo(controllerValue);
}
@override
void dispose() {
_moveController.dispose();
_iconMoveController.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
if (widget.onSwipeStart != null) {
widget.onSwipeStart();
}
}
void _handleDragUpdate(DragUpdateDetails details) {
final delta = details.primaryDelta;
_dragExtent += delta;
if (_dragExtent.isNegative) return;
var movePastThresholdPixels = widget.threshold;
var newPos = _dragExtent.abs() / context.size.width;
if (_dragExtent.abs() > movePastThresholdPixels) {
// how many "thresholds" past the threshold we are. 1 = the threshold 2
// = two thresholds.
var n = _dragExtent.abs() / movePastThresholdPixels;
// Take the number of thresholds past the threshold, and reduce this
// number
var reducedThreshold = math.pow(n, 0.3);
var adjustedPixelPos = movePastThresholdPixels * reducedThreshold;
newPos = adjustedPixelPos / context.size.width;
if (_dragExtent > 0 && !_pastThreshold) {
_iconMoveController.value = 1;
_pastThreshold = true;
}
} else {
// Send a cancel event if the user has swiped back underneath the
// threshold
if (_pastThreshold && widget.onSwipeCancel != null) {
widget.onSwipeCancel();
}
_pastThreshold = false;
}
if (!_pastThreshold || newPos < _moveController.value) {
_iconMoveController.value = newPos;
}
_moveController.value = newPos;
}
void _handleDragEnd(DragEndDetails details) {
_moveController.animateTo(0.0, duration: _animationDuration);
_iconMoveController.animateTo(0.0, duration: _animationDuration);
_dragExtent = 0.0;
if (_pastThreshold && widget.onSwipeEnd != null) {
widget.onSwipeEnd();
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
behavior: HitTestBehavior.opaque,
child: Stack(
alignment: Alignment.center,
fit: StackFit.passthrough,
children: [
SlideTransition(
position: _iconTransitionAnimation,
child: Row(
children: [
FadeTransition(
opacity: _iconFadeAnimation,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: StreamChatTheme.of(context)
.colorTheme
.greyGainsboro,
),
),
child: widget.backgroundIcon,
),
),
],
),
),
SlideTransition(
position: _moveAnimation,
child: widget.child,
),
],
),
);
}
}
+117 -7
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:url_launcher/url_launcher.dart';
import 'stream_svg_icon.dart';
import '../stream_chat_flutter.dart';
Future<void> launchURL(BuildContext context, String url) async {
@@ -97,11 +99,119 @@ Future<bool> showConfirmationDialog(
String getRandomPicUrl(User user) =>
'https://getstream.io/random_png/?id=${user.id}&name=${user.name}';
/// List extension
extension ListX<T> on List<T> {
/// Insert any item<T> inBetween the list items
List<T> insertBetween(T item) => expand((e) sync* {
yield item;
yield e;
}).skip(1).toList(growable: false);
/// Get websiteName from [hostName]
String getWebsiteName(String hostName) {
switch (hostName) {
case 'reddit':
return 'Reddit';
case 'youtube':
return 'Youtube';
case 'wikipedia':
return 'Wikipedia';
case 'twitter':
return 'Twitter';
case 'facebook':
return 'Facebook';
case 'amazon':
return 'Amazon';
case 'yelp':
return 'Yelp';
case 'imdb':
return 'IMDB';
case 'pinterest':
return 'Pinterest';
case 'tripadvisor':
return 'TripAdvisor';
case 'instagram':
return 'Instagram';
case 'walmart':
return 'Walmart';
case 'craigslist':
return 'Craigslist';
case 'ebay':
return 'eBay';
case 'linkedin':
return 'LinkedIn';
case 'google':
return 'Google';
case 'apple':
return 'Apple';
default:
return null;
}
}
///
String getSizeText(int bytes) {
if (bytes == null) {
return 'Size N/A';
}
if (bytes <= 1000) {
return '${bytes} bytes';
} else if (bytes <= 100000) {
return '${(bytes / 1000).toStringAsFixed(2)} KB';
} else {
return '${(bytes / 1000000).toStringAsFixed(2)} MB';
}
}
///
StreamSvgIcon getFileTypeImage(String type) {
switch (type) {
case '7z':
return StreamSvgIcon.filetype_7z();
break;
case 'csv':
return StreamSvgIcon.filetype_csv();
break;
case 'doc':
return StreamSvgIcon.filetype_doc();
break;
case 'docx':
return StreamSvgIcon.filetype_docx();
break;
case 'html':
return StreamSvgIcon.filetype_html();
break;
case 'md':
return StreamSvgIcon.filetype_md();
break;
case 'odt':
return StreamSvgIcon.filetype_odt();
break;
case 'pdf':
return StreamSvgIcon.filetype_pdf();
break;
case 'ppt':
return StreamSvgIcon.filetype_ppt();
break;
case 'pptx':
return StreamSvgIcon.filetype_pptx();
break;
case 'rar':
return StreamSvgIcon.filetype_rar();
break;
case 'rtf':
return StreamSvgIcon.filetype_rtf();
break;
case 'tar':
return StreamSvgIcon.filetype_tar();
break;
case 'txt':
return StreamSvgIcon.filetype_txt();
break;
case 'xls':
return StreamSvgIcon.filetype_xls();
break;
case 'xlsx':
return StreamSvgIcon.filetype_xlsx();
break;
case 'zip':
return StreamSvgIcon.filetype_zip();
break;
default:
return StreamSvgIcon.filetype_Generic();
break;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies:
file_picker: ^2.0.12
image_picker: ^0.6.7+2
flutter_keyboard_visibility: ^3.3.0
stream_chat: ^0.2.21+2
stream_chat: ^0.2.22
mime: ^0.9.6+3
video_compress: ^2.1.1
visibility_detector: ^0.1.5
+2
View File
@@ -71,6 +71,7 @@ void main() {
showCopyMessage: false,
showDeleteMessage: false,
showReply: false,
showThreadReply: false,
message: Message(
text: 'test',
user: User(
@@ -87,6 +88,7 @@ void main() {
await tester.pump(Duration(milliseconds: 1000));
expect(find.byKey(Key('MessageWidget')), findsOneWidget);
expect(find.text('Reply'), findsNothing);
expect(find.text('Thread reply'), findsNothing);
expect(find.text('Edit message'), findsNothing);
expect(find.text('Delete message'), findsNothing);
+1 -1
View File
@@ -74,7 +74,7 @@ void main() {
),
latestReactions: [
Reaction(
type: 'thumbs_up',
type: 'like',
user: User(id: testUserId),
),
Reaction(
+3 -3
View File
@@ -31,7 +31,7 @@ void main() {
);
testWidgets(
'it should show a thumb up',
'it should show a like',
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
@@ -50,7 +50,7 @@ void main() {
child: ReactionBubble(
reactions: [
Reaction(
type: 'thumbs_up',
type: 'like',
user: User(id: 'test'),
),
],
@@ -86,7 +86,7 @@ void main() {
child: ReactionBubble(
reactions: [
Reaction(
type: 'thumbs_up',
type: 'like',
user: User(id: 'test'),
),
Reaction(