Merge pull request #169 from GetStream/feat/messg-reply
Add Message Reply feature
This commit is contained in:
@@ -1 +1 @@
|
||||
13a41b8138c4868054e44b5158f3bdd6
|
||||
c770358a10272b4ee1cdc2eb432445f6
|
||||
@@ -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' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `chdir' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:28:in `block (2 levels) in parsing_binding' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane.rb:33:in `call' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `chdir' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `execute' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:108:in `block (2 levels) in run' /Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:178:in `call' /Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:153:in `run' /Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/runner.rb:476:in `run_active_command' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:76:in `run!' /Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/delegates.rb:15:in `run!' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:352:in `run' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:41:in `start' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/cli_tools_distributor.rb:119:in `take_off' /Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/bin/fastlane:23:in `<top (required)>' /usr/local/bin/fastlane:23:in `load' /usr/local/bin/fastlane:23:in `<top (required)>' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `load' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `kernel_load' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:28:in `run' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:465:in `exec' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/command.rb:27:in `run' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/invocation.rb:126:in `invoke_command' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor.rb:387:in `dispatch' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:27:in `dispatch' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/base.rb:466:in `start' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:18:in `start' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:30:in `block in <top (required)>' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/friendly_errors.rb:124:in `with_friendly_errors' /Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:22:in `<top (required)>' /usr/local/bin/bundle:23:in `load' /usr/local/bin/bundle:23:in `<main>' Error building the application - see the log above" />
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
+38
-5
@@ -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,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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -74,7 +74,7 @@ void main() {
|
||||
),
|
||||
latestReactions: [
|
||||
Reaction(
|
||||
type: 'thumbs_up',
|
||||
type: 'like',
|
||||
user: User(id: testUserId),
|
||||
),
|
||||
Reaction(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user