diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index cbb0901d..1f80e63b 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -84,16 +84,17 @@ class StreamChatClient { Duration connectTimeout = const Duration(seconds: 6), Duration receiveTimeout = const Duration(seconds: 6), Dio httpClient, - // ignore: avoid_unused_constructor_parameters RetryPolicy retryPolicy, this.attachmentFileUploader, }) { - _retryPolicy ??= RetryPolicy( - retryTimeout: (StreamChatClient client, int attempt, ApiError error) => - Duration(seconds: 1 * attempt), - shouldRetry: (StreamChatClient client, int attempt, ApiError error) => - attempt < 5, - ); + _retryPolicy = retryPolicy ?? + RetryPolicy( + retryTimeout: + (StreamChatClient client, int attempt, ApiError error) => + Duration(seconds: 1 * attempt), + shouldRetry: (StreamChatClient client, int attempt, ApiError error) => + attempt < 5, + ); attachmentFileUploader ??= StreamAttachmentFileUploader(this); diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 8ce9ecd4..9bf398bd 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -104,10 +104,10 @@ class FileAttachment extends AttachmentWidget { return getFileTypeImage(attachment.extraData['other']); }, placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Image.memory(kTransparentImage), + return Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', ); }, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 91f1aeeb..01a9ab0b 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -348,10 +348,10 @@ class GiphyAttachment extends AttachmentWidget { height: size?.height, width: size?.width, placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Image.memory(kTransparentImage), + return Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', ); }, imageUrl: imageUrl, diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 84c39213..d562b7fb 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -84,10 +84,10 @@ class ImageAttachment extends AttachmentWidget { height: size?.height, width: size?.width, placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Image.memory(kTransparentImage), + return Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', ); }, imageUrl: imageUrl, diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart index 57b27a2f..2f3a26b2 100644 --- a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -114,9 +114,9 @@ class _ChannelBottomSheetState extends State { children: [ UserAvatar( user: members[index].user, - constraints: BoxConstraints( - maxHeight: 64.0, - maxWidth: 64.0, + constraints: BoxConstraints.tightFor( + height: 64.0, + width: 64.0, ), borderRadius: BorderRadius.circular(32.0), onlineIndicatorConstraints: @@ -238,8 +238,8 @@ class _ChannelBottomSheetState extends State { color: StreamChatTheme.of(context).colorTheme.accentRed, ), ); - var channel = StreamChannel.of(context).channel; if (res == true) { + final channel = StreamChannel.of(context).channel; await channel.removeMembers([StreamChat.of(context).user.id]); Navigator.pop(context); } diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart index 6845a632..c2927a6e 100644 --- a/packages/stream_chat_flutter/lib/src/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -71,6 +71,19 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { final bool showConnectionStateTile; + /// Title widget + final Widget title; + + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + final Widget leading; + + /// AppBar actions + /// By default it shows the [ChannelImage] + final List actions; + /// Creates a channel header ChannelHeader({ Key key, @@ -80,6 +93,10 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { this.showTypingIndicator = true, this.onImageTap, this.showConnectionStateTile = false, + this.title, + this.subtitle, + this.leading, + this.actions, }) : preferredSize = Size.fromHeight(kToolbarHeight), super(key: key); @@ -87,6 +104,14 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { final channel = StreamChannel.of(context).channel; + final leadingWidget = leading ?? + (showBackButton + ? StreamBackButton( + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox()); + return ConnectionStatusBuilder( statusBuilder: (context, status) { var statusString = ''; @@ -111,26 +136,32 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { child: AppBar( brightness: Theme.of(context).brightness, elevation: 1, - leading: showBackButton - ? StreamBackButton( - onPressed: onBackPressed, - showUnreads: true, - ) - : SizedBox(), + leading: leadingWidget, backgroundColor: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme .color, - actions: [ - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Center( - child: ChannelImage( - onTap: onImageTap, + actions: actions ?? + [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Center( + child: ChannelImage( + borderRadius: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .avatarTheme + .borderRadius, + constraints: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .avatarTheme + .constraints, + onTap: onImageTap, + ), + ), ), - ), - ), - ], + ], centerTitle: true, title: InkWell( onTap: onTitleTap, @@ -141,20 +172,23 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), + title ?? + ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), SizedBox(height: 2), - ChannelInfo( - showTypingIndicator: showTypingIndicator, - channel: channel, - textStyle: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle, - ), + subtitle ?? + ChannelInfo( + showTypingIndicator: showTypingIndicator, + channel: channel, + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 53a621a0..1d6f6fe2 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -57,7 +57,7 @@ class ChannelInfo extends StatelessWidget { style: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme - .lastMessageAt, + .subtitle, ); } else { final otherMember = members.firstWhere( @@ -69,10 +69,7 @@ class ChannelInfo extends StatelessWidget { if (otherMember.user.online) { alternativeWidget = Text( 'Online', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, + style: textStyle, ); } else { alternativeWidget = Text( diff --git a/packages/stream_chat_flutter/lib/src/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel_list_header.dart index cca4170e..88b329e6 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_header.dart @@ -45,7 +45,7 @@ typedef _TitleBuilder = Widget Function( /// The widget by default uses the inherited [StreamChatClient] to fetch information about the status. /// However you can also pass your own [StreamChatClient] if you don't have it in the widget tree. /// -/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelListHeaderTheme] property. /// Modify it to change the widget appearance. class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { /// Instantiates a ChannelListHeader @@ -57,6 +57,9 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { this.onNewChatButtonTap, this.showConnectionStateTile = false, this.preNavigationCallback, + this.subtitle, + this.leading, + this.actions, }) : super(key: key); /// Pass this if you don't have a [StreamChatClient] in your widget tree. @@ -76,6 +79,17 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { final VoidCallback preNavigationCallback; + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + /// By default it shows the logged in user avatar + final Widget leading; + + /// AppBar actions + /// By default it shows the new chat button + final List actions; + @override Widget build(BuildContext context) { final _client = client ?? StreamChat.of(context).client; @@ -104,76 +118,85 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { child: AppBar( brightness: Theme.of(context).brightness, elevation: 1, - backgroundColor: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .color, + backgroundColor: + StreamChatTheme.of(context).channelListHeaderTheme.color, centerTitle: true, - leading: Center( - child: UserAvatar( - user: user, - showOnlineStatus: false, - onTap: onUserAvatarTap ?? - (_) { - if (preNavigationCallback != null) { - preNavigationCallback(); - } - Scaffold.of(context).openDrawer(); - }, - borderRadius: BorderRadius.circular(20), - constraints: BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - ), - actions: [ - StreamNeumorphicButton( - child: IconButton( - icon: ConnectionStatusBuilder( - statusBuilder: (context, status) { - var color; - switch (status) { - case ConnectionStatus.connected: - color = - StreamChatTheme.of(context).colorTheme.accentBlue; - break; - case ConnectionStatus.connecting: - color = Colors.grey; - break; - case ConnectionStatus.disconnected: - color = Colors.grey; - break; - } - return SvgPicture.asset( - 'svgs/icon_pen_write.svg', - package: 'stream_chat_flutter', - width: 24.0, - height: 24.0, - color: color, - ); - }, + leading: leading ?? + Center( + child: UserAvatar( + user: user, + showOnlineStatus: false, + onTap: onUserAvatarTap ?? + (_) { + if (preNavigationCallback != null) { + preNavigationCallback(); + } + Scaffold.of(context).openDrawer(); + }, + borderRadius: StreamChatTheme.of(context) + .channelListHeaderTheme + .avatarTheme + .borderRadius, + constraints: StreamChatTheme.of(context) + .channelListHeaderTheme + .avatarTheme + .constraints, ), - onPressed: onNewChatButtonTap, ), - ) - ], - title: Builder( - builder: (context) { - if (titleBuilder != null) { - return titleBuilder(context, status, _client); - } - switch (status) { - case ConnectionStatus.connected: - return _buildConnectedTitleState(context); - case ConnectionStatus.connecting: - return _buildConnectingTitleState(context); - case ConnectionStatus.disconnected: - return _buildDisconnectedTitleState(context, _client); - default: - return Offstage(); - } - }, + actions: actions ?? + [ + StreamNeumorphicButton( + child: IconButton( + icon: ConnectionStatusBuilder( + statusBuilder: (context, status) { + var color; + switch (status) { + case ConnectionStatus.connected: + color = StreamChatTheme.of(context) + .colorTheme + .accentBlue; + break; + case ConnectionStatus.connecting: + color = Colors.grey; + break; + case ConnectionStatus.disconnected: + color = Colors.grey; + break; + } + return SvgPicture.asset( + 'svgs/icon_pen_write.svg', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + color: color, + ); + }, + ), + onPressed: onNewChatButtonTap, + ), + ) + ], + title: Column( + children: [ + Builder( + builder: (context) { + if (titleBuilder != null) { + return titleBuilder(context, status, _client); + } + switch (status) { + case ConnectionStatus.connected: + return _buildConnectedTitleState(context); + case ConnectionStatus.connecting: + return _buildConnectingTitleState(context); + case ConnectionStatus.disconnected: + return _buildDisconnectedTitleState(context, _client); + default: + return Offstage(); + } + }, + ), + subtitle ?? Offstage(), + ], ), ), ); @@ -202,14 +225,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { SizedBox(width: 10), Text( 'Searching for Network', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title - .copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: + StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), ], ); @@ -222,14 +242,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { children: [ Text( 'Offline...', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title - .copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: + StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), TextButton( onPressed: () async { @@ -239,8 +256,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { child: Text( 'Try Again', style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme + .channelListHeaderTheme .title .copyWith( fontSize: 16, diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 2d29f12b..9ba34f3a 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -32,16 +32,37 @@ class ChannelPreview extends StatelessWidget { /// The function called when the image is tapped final VoidCallback onImageTap; + /// Widget rendering the title + final Widget title; + + /// Widget rendering the subtitle + final Widget subtitle; + + /// Widget rendering the leading element, by default it shows the [ChannelImage] + final Widget leading; + + /// Widget rendering the trailing element, by default it shows the last message date + final Widget trailing; + + /// Widget rendering the sending indicator, by default it uses the [SendingIndicator] widget + final Widget sendingIndicator; + ChannelPreview({ @required this.channel, Key key, this.onTap, this.onLongPress, this.onImageTap, + this.title, + this.subtitle, + this.leading, + this.sendingIndicator, + this.trailing, }) : super(key: key); @override Widget build(BuildContext context) { + final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme; return StreamBuilder( stream: channel.isMutedStream, initialData: channel.isMuted, @@ -49,6 +70,7 @@ class ChannelPreview extends StatelessWidget { return Opacity( opacity: snapshot.data ? 0.5 : 1, child: ListTile( + visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric( horizontal: 8, ), @@ -62,17 +84,18 @@ class ChannelPreview extends StatelessWidget { onLongPress(channel); } }, - leading: ChannelImage( - onTap: onImageTap, - ), + leading: leading ?? + ChannelImage( + onTap: onImageTap, + ), title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( - child: ChannelName( - textStyle: - StreamChatTheme.of(context).channelPreviewTheme.title, - ), + child: title ?? + ChannelName( + textStyle: channelPreviewTheme.title, + ), ), StreamBuilder>( stream: channel.state.membersStream, @@ -93,37 +116,36 @@ class ChannelPreview extends StatelessWidget { subtitle: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible(child: _buildSubtitle(context)), - Builder( - builder: (context) { - final lastMessage = channel.state.messages.lastWhere( - (m) => !m.isDeleted && m.shadowed != true, - orElse: () => null, - ); - if (lastMessage?.user?.id == - StreamChat.of(context).user.id) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: SendingIndicator( - message: lastMessage, - size: StreamChatTheme.of(context) - .channelPreviewTheme - .indicatorIconSize, - isMessageRead: channel.state.read - ?.where((element) => - element.user.id != - channel.client.state.user.id) - ?.where((element) => element.lastRead - .isAfter(lastMessage.createdAt)) - ?.isNotEmpty == - true, - ), - ); - } - return SizedBox(); - }, - ), - _buildDate(context), + Flexible(child: subtitle ?? _buildSubtitle(context)), + sendingIndicator ?? + Builder( + builder: (context) { + final lastMessage = channel.state.messages.lastWhere( + (m) => !m.isDeleted && m.shadowed != true, + orElse: () => null, + ); + if (lastMessage?.user?.id == + StreamChat.of(context).user.id) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: SendingIndicator( + message: lastMessage, + size: channelPreviewTheme.indicatorIconSize, + isMessageRead: channel.state.read + ?.where((element) => + element.user.id != + channel.client.state.user.id) + ?.where((element) => element.lastRead + .isAfter(lastMessage.createdAt)) + ?.isNotEmpty == + true, + ), + ); + } + return SizedBox(); + }, + ), + trailing ?? _buildDate(context), ], ), ), @@ -176,15 +198,7 @@ class ChannelPreview extends StatelessWidget { ), Text( ' Channel is muted', - style: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .copyWith( - color: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitle - .color, - ), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, ), ], ); @@ -192,10 +206,7 @@ class ChannelPreview extends StatelessWidget { return TypingIndicator( channel: channel, alternativeWidget: _buildLastMessage(context), - style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( - color: - StreamChatTheme.of(context).channelPreviewTheme.subtitle.color, - ), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, ); } diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index f19d851b..65b1b100 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,6 +1,7 @@ import 'package:characters/characters.dart'; import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; final _emojis = Emoji.all(); @@ -44,3 +45,55 @@ extension PlatformFileX on PlatformFile { size: size, ); } + +extension InputDecorationX on InputDecoration { + InputDecoration merge(InputDecoration other) { + if (other == null) return this; + return copyWith( + icon: other?.icon, + labelText: other?.labelText, + labelStyle: labelStyle?.merge(other.labelStyle) ?? other.labelStyle, + helperText: other?.helperText, + helperStyle: helperStyle?.merge(other.helperStyle) ?? other.helperStyle, + helperMaxLines: other?.helperMaxLines, + hintText: other?.hintText, + hintStyle: hintStyle?.merge(other.hintStyle) ?? other.hintStyle, + hintTextDirection: other?.hintTextDirection, + hintMaxLines: other?.hintMaxLines, + errorText: other?.errorText, + errorStyle: errorStyle?.merge(other.errorStyle) ?? other.errorStyle, + errorMaxLines: other?.errorMaxLines, + floatingLabelBehavior: other?.floatingLabelBehavior, + isCollapsed: other?.isCollapsed, + isDense: other?.isDense, + contentPadding: other?.contentPadding, + prefixIcon: other?.prefixIcon, + prefix: other?.prefix, + prefixText: other?.prefixText, + prefixIconConstraints: other?.prefixIconConstraints, + prefixStyle: prefixStyle?.merge(other.prefixStyle) ?? other.prefixStyle, + suffixIcon: other?.suffixIcon, + suffix: other?.suffix, + suffixText: other?.suffixText, + suffixStyle: suffixStyle?.merge(other.suffixStyle) ?? other.suffixStyle, + suffixIconConstraints: other?.suffixIconConstraints, + counter: other?.counter, + counterText: other?.counterText, + counterStyle: + counterStyle?.merge(other.counterStyle) ?? other.counterStyle, + filled: other?.filled, + fillColor: other?.fillColor, + focusColor: other?.focusColor, + hoverColor: other?.hoverColor, + errorBorder: other?.errorBorder, + focusedBorder: other?.focusedBorder, + focusedErrorBorder: other?.focusedErrorBorder, + disabledBorder: other?.disabledBorder, + enabledBorder: other?.enabledBorder, + border: other?.border, + enabled: other?.enabled, + semanticCounterText: other?.semanticCounterText, + alignLabelWithHint: other?.alignLabelWithHint, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_action.dart b/packages/stream_chat_flutter/lib/src/message_action.dart new file mode 100644 index 00000000..5cedc962 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Class describing a message action +class MessageAction { + /// leading widget + final Widget leading; + + /// title widget + final Widget title; + + /// callback called on tap + final OnMessageTap onTap; + + /// returns a new instance of a [MessageAction] + MessageAction({ + this.leading, + this.title, + this.onTap, + }); +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 12e465b3..883210ba 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; @@ -34,6 +35,9 @@ class MessageActionsModal extends StatefulWidget { final ShapeBorder attachmentShape; final DisplayWidget showUserAvatar; + /// List of custom actions + final List customActions; + const MessageActionsModal({ Key key, @required this.message, @@ -53,6 +57,7 @@ class MessageActionsModal extends StatefulWidget { this.messageShape, this.attachmentShape, this.reverse = false, + this.customActions = const [], }) : super(key: key); @override @@ -223,6 +228,12 @@ class _MessageActionsModalState extends State { _buildFlagButton(context), if (widget.showDeleteMessage) _buildDeleteButton(context), + ...widget.customActions.map((action) { + return _buildCustomAction( + context, + action, + ); + }) ].insertBetween( Container( height: 1, @@ -248,6 +259,27 @@ class _MessageActionsModalState extends State { ); } + InkWell _buildCustomAction( + BuildContext context, + MessageAction messageAction, + ) { + return InkWell( + onTap: () { + messageAction.onTap?.call(widget.message); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + messageAction.leading ?? Offstage(), + const SizedBox(width: 16), + messageAction.title ?? Offstage(), + ], + ), + ), + ); + } + void _showFlagDialog() async { final client = StreamChat.of(context).client; @@ -541,21 +573,16 @@ class _MessageActionsModalState extends State { ], ), ), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: widget.editMessageInputBuilder != null - ? widget.editMessageInputBuilder(context, widget.message) - : MessageInput( - editMessage: widget.message, - preMessageSending: (m) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - return m; - }, - ), - ), + widget.editMessageInputBuilder != null + ? widget.editMessageInputBuilder(context, widget.message) + : MessageInput( + editMessage: widget.message, + preMessageSending: (m) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + return m; + }, + ), ], ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 5eb238cc..8922a6c3 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -42,6 +42,15 @@ enum DefaultAttachmentTypes { file, } +/// Available locations for the sendMessage button relative to the textField +enum SendButtonLocation { + /// inside the textField + inside, + + /// outside the textField + outside, +} + const _kMinMediaPickerSize = 360.0; const _kMaxAttachmentSize = 20971520; // 20MB in Bytes @@ -107,6 +116,11 @@ class MessageInput extends StatefulWidget { this.focusNode, this.quotedMessage, this.onQuotedMessageCleared, + this.sendButtonLocation = SendButtonLocation.outside, + this.autofocus = false, + this.hideSendAsDm = false, + this.idleSendButton, + this.activeSendButton, }) : super(key: key); /// Message to edit @@ -134,6 +148,9 @@ class MessageInput extends StatefulWidget { /// If true the attachments button will not be displayed final bool disableAttachments; + /// Hide send as dm checkbox + final bool hideSendAsDm; + /// The text controller of the TextField final TextEditingController textEditingController; @@ -155,6 +172,18 @@ class MessageInput extends StatefulWidget { /// final VoidCallback onQuotedMessageCleared; + /// The location of the send button + final SendButtonLocation sendButtonLocation; + + /// Autofocus property passed to the TextField + final bool autofocus; + + /// Send button widget in an idle state + final Widget idleSendButton; + + /// Send button widget in an active state + final Widget activeSendButton; + @override MessageInputState createState() => MessageInputState(); @@ -281,7 +310,7 @@ class MessageInputState extends State { padding: const EdgeInsets.all(8.0), child: _buildTextField(context), ), - if (widget.parentMessage != null) + if (widget.parentMessage != null && !widget.hideSendAsDm) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildDmCheckbox(), @@ -310,9 +339,10 @@ class MessageInputState extends State { if (widget.actionsLocation == ActionsLocation.left) ...widget.actions ?? [], _buildTextInput(context), - _animateSendButton(context), if (widget.actionsLocation == ActionsLocation.right) ...widget.actions ?? [], + if (widget.sendButtonLocation == SendButtonLocation.outside) + _animateSendButton(context), ], ); } @@ -384,14 +414,20 @@ class MessageInputState extends State { } Widget _animateSendButton(BuildContext context) { + final sendButton = widget.activeSendButton != null + ? InkWell( + child: widget.activeSendButton, + onTap: sendMessage, + ) + : _buildSendButton(context); return Padding( padding: const EdgeInsets.all(8.0), child: AnimatedCrossFade( crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: _buildSendButton(context), - secondChild: _buildIdleSendButton(context), + firstChild: sendButton, + secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), duration: StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration, alignment: Alignment.center, @@ -447,101 +483,139 @@ class MessageInputState extends State { child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.0), - border: Border.all(color: theme.colorTheme.greyGainsboro), + borderRadius: theme.messageInputTheme.borderRadius, + gradient: _focusNode.hasFocus + ? theme.messageInputTheme.activeBorderGradient + : theme.messageInputTheme.idleBorderGradient, ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: TextField( - key: Key('messageInputText'), - enabled: _inputEnabled, - minLines: null, - maxLines: null, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: textEditingController, - focusNode: _focusNode, - style: theme.textTheme.body, - autofocus: false, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - isDense: true, - hintText: _getHint(), - hintStyle: theme.textTheme.body.copyWith( - color: theme.colorTheme.grey, + child: Padding( + padding: const EdgeInsets.all(1.5), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: theme.messageInputTheme.borderRadius, + color: theme.messageInputTheme.inputBackground, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: Key('messageInputText'), + enabled: _inputEnabled, + minLines: null, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: theme.messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(), + textCapitalization: TextCapitalization.sentences, ), - border: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - errorBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent)), - contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), - prefixIconConstraints: BoxConstraints.tight(Size(78, 24)), - suffixIconConstraints: BoxConstraints.tight(Size(40, 40)), - prefixIcon: _commandEnabled - ? Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.accentBlue, - ), - margin: const EdgeInsets.only(right: 4, left: 8), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - StreamSvgIcon.lightning( - color: Colors.white, - size: 16.0, - ), - Text( - _chosenCommand?.name?.toUpperCase() ?? '', - style: StreamChatTheme.of(context) - .textTheme - .footnoteBold - .copyWith( - color: Colors.white, - ), - ), - ], - ), - ) - : null, - suffixIcon: _commandEnabled - ? IconButton( - icon: StreamSvgIcon.closeSmall(), - splashRadius: 24, - padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - setState(() => _commandEnabled = false); - }, - ) - : null, - ), - textCapitalization: TextCapitalization.sentences, - ), - ) - ], + ) + ], + ), + ), ), ), ), ); } + InputDecoration _getInputDecoration() { + final theme = StreamChatTheme.of(context); + final passedDecoration = theme.messageInputTheme.inputDecoration; + return InputDecoration( + isDense: true, + hintText: _getHint(), + hintStyle: theme.messageInputTheme.inputTextStyle.copyWith( + color: theme.colorTheme.grey, + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), + prefixIconConstraints: BoxConstraints.tight(Size(78, 24)), + suffixIconConstraints: BoxConstraints.tight(Size(40, 40)), + prefixIcon: _commandEnabled + ? Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.accentBlue, + ), + margin: const EdgeInsets.only(right: 4, left: 8), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.lightning( + color: Colors.white, + size: 16.0, + ), + Text( + _chosenCommand?.name?.toUpperCase() ?? '', + style: StreamChatTheme.of(context) + .textTheme + .footnoteBold + .copyWith( + color: Colors.white, + ), + ), + ], + ), + ) + : null, + suffixIcon: Row( + children: [ + if (_commandEnabled) + IconButton( + icon: StreamSvgIcon.closeSmall(), + splashRadius: 24, + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + onPressed: () { + setState(() => _commandEnabled = false); + }, + ), + if (widget.sendButtonLocation == SendButtonLocation.inside) + _animateSendButton(context), + ], + ), + ).merge(passedDecoration); + } + Timer _debounce; void _onChanged(BuildContext context, String s) { @@ -621,7 +695,9 @@ class MessageInputState extends State { .last .contains('@')) { _mentionsOverlay = _buildMentionsOverlayEntry(); - Overlay.of(context).insert(_mentionsOverlay); + if (_mentionsOverlay != null) { + Overlay.of(context).insert(_mentionsOverlay); + } } } @@ -646,7 +722,9 @@ class MessageInputState extends State { _commandsOverlay = null; } else { _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); + if (_commandsOverlay != null) { + Overlay.of(context).insert(_commandsOverlay); + } } } } @@ -661,6 +739,10 @@ class MessageInputState extends State { ?.toList() ?? []; + if (commands.isEmpty) { + return null; + } + RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; @@ -833,6 +915,7 @@ class MessageInputState extends State { child: Material( color: StreamChatTheme.of(context).colorTheme.whiteSmoke, child: Column( + mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.start, @@ -1100,6 +1183,10 @@ class MessageInputState extends State { })?.toList() ?? []; + if (members.isEmpty) { + return null; + } + RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 81a38b9b..e277f3bb 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -36,6 +36,7 @@ typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); typedef ThreadTapCallback = void Function(Message, Widget); typedef OnMessageSwiped = void Function(Message); +typedef OnMessageTap = void Function(Message); typedef ReplyTapCallback = void Function(Message); class MessageDetails { @@ -224,10 +225,10 @@ class MessageListView extends StatefulWidget { final Map customAttachmentBuilders; /// Called when any message is tapped except a system message (use [onSystemMessageTap] instead) - final void Function(Message) onMessageTap; + final OnMessageTap onMessageTap; /// Called when system message is tapped - final void Function(Message) onSystemMessageTap; + final OnMessageTap onSystemMessageTap; // Customize onTap on attachment final void Function(Message message, Attachment attachment) onAttachmentTap; @@ -436,7 +437,7 @@ class _MessageListViewState extends State { style: StreamChatTheme.of(context) .channelTheme .channelHeaderTheme - .lastMessageAt, + .subtitle, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index cc587786..61da96b7 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 3b40643c..fba5b3bc 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/message_action.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/quoted_message_widget.dart'; @@ -146,6 +147,9 @@ class MessageWidget extends StatefulWidget { /// Function called when message is tapped final void Function(Message) onMessageTap; + /// List of custom actions shown on message long tap + final List customActions; + // Customize onTap on attachment final void Function(Message message, Attachment attachment) onAttachmentTap; @@ -198,6 +202,7 @@ class MessageWidget extends StatefulWidget { this.attachmentPadding = EdgeInsets.zero, this.allRead = false, this.onQuotedMessageTap, + this.customActions = const [], this.onAttachmentTap, }) : attachmentBuilders = { 'image': (context, message, attachment) { @@ -760,6 +765,7 @@ class _MessageWidgetState extends State !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, + customActions: widget.customActions, ), ); }); @@ -966,6 +972,7 @@ class _MessageWidgetState extends State user: widget.message.user, onTap: widget.onUserAvatarTap, constraints: widget.messageTheme.avatarTheme.constraints, + borderRadius: widget.messageTheme.avatarTheme.borderRadius, showOnlineStatus: false, ), ), diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index aa4f51f0..eafa12c5 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Inherited widget providing the [StreamChatThemeData] to the widget tree @@ -45,12 +46,15 @@ class StreamChatThemeData { /// The text themes used in the widgets final TextTheme textTheme; - /// The text themes used in the widgets + /// The color themes used in the widgets final ColorTheme colorTheme; /// Theme of the [ChannelPreview] final ChannelPreviewTheme channelPreviewTheme; + /// Theme of the [ChannelListHeader] + final ChannelListHeaderTheme channelListHeaderTheme; + /// Theme of the chat widgets dedicated to a channel final ChannelTheme channelTheme; @@ -60,7 +64,7 @@ class StreamChatThemeData { /// Theme of other users messages final MessageTheme otherMessageTheme; - /// Theme of other users messages + /// Theme dedicated to the [MessageInput] widget final MessageInputTheme messageInputTheme; /// The widget that will be built when the channel image is unavailable @@ -79,6 +83,7 @@ class StreamChatThemeData { const StreamChatThemeData({ this.textTheme, this.colorTheme, + this.channelListHeaderTheme, this.channelPreviewTheme, this.channelTheme, this.otherMessageTheme, @@ -98,9 +103,7 @@ class StreamChatThemeData { accentBlue: theme.accentColor, ), defaultTheme.textTheme, - ).copyWith( - // primaryIconTheme: theme.primaryIconTheme, - ); + ); return defaultTheme.merge(customizedTheme) ?? customizedTheme; } @@ -116,9 +119,12 @@ class StreamChatThemeData { Widget Function(BuildContext, Channel) defaultChannelImage, Widget Function(BuildContext, User) defaultUserImage, IconThemeData primaryIconTheme, + ChannelListHeaderTheme channelListHeaderTheme, List reactionIcons, }) => StreamChatThemeData( + channelListHeaderTheme: + channelListHeaderTheme ?? this.channelListHeaderTheme, textTheme: textTheme ?? this.textTheme, colorTheme: colorTheme ?? this.colorTheme, primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, @@ -135,6 +141,9 @@ class StreamChatThemeData { StreamChatThemeData merge(StreamChatThemeData other) { if (other == null) return this; return copyWith( + channelListHeaderTheme: + channelListHeaderTheme?.merge(other.channelListHeaderTheme) ?? + other.channelListHeaderTheme, textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme, colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme, primaryIconTheme: other.primaryIconTheme, @@ -189,6 +198,17 @@ class StreamChatThemeData { color: colorTheme.black.withOpacity(.5), ), indicatorIconSize: 16.0), + channelListHeaderTheme: ChannelListHeaderTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + color: colorTheme.white, + title: textTheme.headlineBold, + ), channelTheme: ChannelTheme( channelHeaderTheme: ChannelHeaderTheme( avatarTheme: AvatarTheme( @@ -200,9 +220,8 @@ class StreamChatThemeData { ), color: colorTheme.white, title: textTheme.headlineBold, - lastMessageAt: TextStyle( - fontSize: 11, - color: colorTheme.black.withOpacity(.5), + subtitle: textTheme.footnote.copyWith( + color: Color(0xff7A7A7A), ), ), ), @@ -249,12 +268,26 @@ class StreamChatThemeData { ), ), messageInputTheme: MessageInputTheme( + borderRadius: BorderRadius.circular(20), sendAnimationDuration: Duration(milliseconds: 300), actionButtonColor: colorTheme.accentBlue, actionButtonIdleColor: colorTheme.grey, sendButtonColor: colorTheme.accentBlue, sendButtonIdleColor: colorTheme.greyGainsboro, inputBackground: colorTheme.white, + inputTextStyle: textTheme.body, + idleBorderGradient: LinearGradient( + colors: [ + colorTheme.greyGainsboro, + colorTheme.greyGainsboro, + ], + ), + activeBorderGradient: LinearGradient( + colors: [ + colorTheme.greyGainsboro, + colorTheme.greyGainsboro, + ], + ), ), reactionIcons: [ ReactionIcon( @@ -809,26 +842,26 @@ class ChannelPreviewTheme { class ChannelHeaderTheme { final TextStyle title; - final TextStyle lastMessageAt; + final TextStyle subtitle; final AvatarTheme avatarTheme; final Color color; const ChannelHeaderTheme({ this.title, - this.lastMessageAt, + this.subtitle, this.avatarTheme, this.color, }); ChannelHeaderTheme copyWith({ TextStyle title, - TextStyle lastMessageAt, + TextStyle subtitle, AvatarTheme avatarTheme, Color color, }) => ChannelHeaderTheme( title: title ?? this.title, - lastMessageAt: lastMessageAt ?? this.lastMessageAt, + subtitle: subtitle ?? this.subtitle, avatarTheme: avatarTheme ?? this.avatarTheme, color: color ?? this.color, ); @@ -837,8 +870,48 @@ class ChannelHeaderTheme { if (other == null) return this; return copyWith( title: title?.merge(other.title) ?? other.title, - lastMessageAt: - lastMessageAt?.merge(other.lastMessageAt) ?? other.lastMessageAt, + subtitle: subtitle?.merge(other.subtitle) ?? other.subtitle, + avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + color: other.color, + ); + } +} + +/// Theme dedicated to the [ChannelListHeader] +class ChannelListHeaderTheme { + /// Style of the title text + final TextStyle title; + + /// Theme dedicated to the userAvatar + final AvatarTheme avatarTheme; + + /// Background color of the appbar + final Color color; + + /// Returns a new [ChannelListHeaderTheme] + const ChannelListHeaderTheme({ + this.title, + this.avatarTheme, + this.color, + }); + + /// Returns a new [ChannelListHeaderTheme] replacing some of its properties + ChannelListHeaderTheme copyWith({ + TextStyle title, + AvatarTheme avatarTheme, + Color color, + }) => + ChannelListHeaderTheme( + title: title ?? this.title, + avatarTheme: avatarTheme ?? this.avatarTheme, + color: color ?? this.color, + ); + + /// Merges [this] [ChannelListHeaderTheme] with the [other] + ChannelListHeaderTheme merge(ChannelListHeaderTheme other) { + if (other == null) return this; + return copyWith( + title: title?.merge(other.title) ?? other.title, avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, color: other.color, ); @@ -865,6 +938,21 @@ class MessageInputTheme { /// Background color of [MessageInput] final Color inputBackground; + /// TextStyle of [MessageInput] + final TextStyle inputTextStyle; + + /// InputDecoration of [MessageInput] + final InputDecoration inputDecoration; + + /// Border gradient when the [MessageInput] is not focused + final Gradient idleBorderGradient; + + /// Border gradient when the [MessageInput] is focused + final Gradient activeBorderGradient; + + /// Border radius of [MessageInput] + final BorderRadius borderRadius; + /// Returns a new [MessageInputTheme] const MessageInputTheme({ this.sendAnimationDuration, @@ -873,6 +961,11 @@ class MessageInputTheme { this.actionButtonIdleColor, this.sendButtonIdleColor, this.inputBackground, + this.inputTextStyle, + this.inputDecoration, + this.activeBorderGradient, + this.idleBorderGradient, + this.borderRadius, }); /// Returns a new [MessageInputTheme] replacing some of its properties @@ -883,6 +976,11 @@ class MessageInputTheme { Color sendButtonColor, Color actionButtonIdleColor, Color sendButtonIdleColor, + TextStyle inputTextStyle, + InputDecoration inputDecoration, + Gradient activeBorderGradient, + Gradient idleBorderGradient, + BorderRadius borderRadius, }) => MessageInputTheme( sendAnimationDuration: @@ -892,7 +990,12 @@ class MessageInputTheme { sendButtonColor: sendButtonColor ?? this.sendButtonColor, actionButtonIdleColor: actionButtonIdleColor ?? this.actionButtonIdleColor, + inputTextStyle: inputTextStyle ?? this.inputTextStyle, sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, + inputDecoration: inputDecoration ?? this.inputDecoration, + activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, + idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, + borderRadius: borderRadius ?? this.borderRadius, ); /// Merges [this] [MessageInputTheme] with the [other] @@ -905,6 +1008,12 @@ class MessageInputTheme { actionButtonIdleColor: other.actionButtonIdleColor, sendButtonColor: other.sendButtonColor, sendButtonIdleColor: other.sendButtonIdleColor, + inputTextStyle: other.inputTextStyle, + inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? + other.inputDecoration, + activeBorderGradient: other.activeBorderGradient, + idleBorderGradient: other.idleBorderGradient, + borderRadius: other.borderRadius, ); } } diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index b33b8bc1..449d4584 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -63,15 +63,35 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// By default it calls [Navigator.pop] final VoidCallback onBackPressed; + /// Callback to call when the title is tapped. + final VoidCallback onTitleTap; + /// The message parent of this thread final Message parent; + /// Title widget + final Widget title; + + /// Subtitle widget + final Widget subtitle; + + /// Leading widget + final Widget leading; + + /// AppBar actions + final List actions; + /// Instantiate a new ThreadHeader ThreadHeader({ Key key, @required this.parent, this.showBackButton = true, this.onBackPressed, + this.title, + this.subtitle, + this.leading, + this.actions, + this.onTitleTap, }) : preferredSize = Size.fromHeight(kToolbarHeight), super(key: key); @@ -81,51 +101,61 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { automaticallyImplyLeading: false, brightness: Theme.of(context).brightness, elevation: 1, - leading: showBackButton - ? StreamBackButton( - cid: StreamChannel.of(context).channel.cid, - onPressed: onBackPressed, - showUnreads: true, - ) - : SizedBox(), + leading: leading ?? + (showBackButton + ? StreamBackButton( + cid: StreamChannel.of(context).channel.cid, + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox()), backgroundColor: StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, centerTitle: true, - title: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Thread Reply', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), - SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, + actions: actions, + title: InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'with ', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ), - Flexible( - child: ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ), - ), + title ?? + Text( + 'Thread Reply', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), + SizedBox(height: 2), + subtitle ?? + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), + Flexible( + child: ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .subtitle, + ), + ), + ], + ), ], ), - ], + ), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index cf115f3b..61a92987 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -14,41 +14,43 @@ class UnreadIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; - return StreamBuilder( - stream: cid != null - ? client.state.channels[cid].state.unreadCountStream - : client.state.totalUnreadCountStream, - initialData: cid != null - ? client.state.channels[cid].state.unreadCount - : client.state.totalUnreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return SizedBox(); - } - return Material( - borderRadius: BorderRadius.circular(8), - color: StreamChatTheme.of(context) - .channelPreviewTheme - .unreadCounterColor, - child: Padding( - padding: const EdgeInsets.only( - left: 5.0, - right: 5.0, - top: 2, - bottom: 1, - ), - child: Center( - child: Text( - '${snapshot.data}', - style: TextStyle( - fontSize: 11, - color: Colors.white, + return IgnorePointer( + child: StreamBuilder( + stream: cid != null + ? client.state.channels[cid].state.unreadCountStream + : client.state.totalUnreadCountStream, + initialData: cid != null + ? client.state.channels[cid].state.unreadCount + : client.state.totalUnreadCount, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == 0) { + return SizedBox(); + } + return Material( + borderRadius: BorderRadius.circular(8), + color: StreamChatTheme.of(context) + .channelPreviewTheme + .unreadCounterColor, + child: Padding( + padding: const EdgeInsets.only( + left: 5.0, + right: 5.0, + top: 2, + bottom: 1, + ), + child: Center( + child: Text( + '${snapshot.data}', + style: TextStyle( + fontSize: 11, + color: Colors.white, + ), ), ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/user_avatar.dart b/packages/stream_chat_flutter/lib/src/user_avatar.dart index f38a9a68..41860a7f 100644 --- a/packages/stream_chat_flutter/lib/src/user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/user_avatar.dart @@ -39,26 +39,29 @@ class UserAvatar extends StatelessWidget { user.extraData['image'] != ''; final streamChatTheme = StreamChatTheme.of(context); - Widget avatar = ClipRRect( - clipBehavior: Clip.antiAlias, - borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, - child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, - decoration: BoxDecoration( - color: streamChatTheme.colorTheme.accentBlue, + Widget avatar = FittedBox( + fit: BoxFit.cover, + child: ClipRRect( + clipBehavior: Clip.antiAlias, + borderRadius: borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + decoration: BoxDecoration( + color: streamChatTheme.colorTheme.accentBlue, + ), + child: hasImage + ? CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: user.extraData['image'], + errorWidget: (_, __, ___) { + return streamChatTheme.defaultUserImage(context, user); + }, + fit: BoxFit.cover, + ) + : streamChatTheme.defaultUserImage(context, user), ), - child: hasImage - ? CachedNetworkImage( - filterQuality: FilterQuality.high, - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return streamChatTheme.defaultUserImage(context, user); - }, - fit: BoxFit.cover, - ) - : streamChatTheme.defaultUserImage(context, user), ), ); @@ -91,6 +94,7 @@ class UserAvatar extends StatelessWidget { alignment: onlineIndicatorAlignment, child: Material( type: MaterialType.circle, + color: streamChatTheme.colorTheme.white, child: Container( margin: const EdgeInsets.all(2.0), constraints: onlineIndicatorConstraints ?? @@ -103,7 +107,6 @@ class UserAvatar extends StatelessWidget { color: streamChatTheme.colorTheme.accentGreen, ), ), - color: streamChatTheme.colorTheme.white, ), ), ), diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 6fc905b5..74c0c33e 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -7,6 +7,7 @@ export 'src/channel_name.dart'; export 'src/channel_preview.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; +export 'src/message_action.dart'; export 'src/attachment/attachment.dart'; export 'src/full_screen_media.dart'; export 'src/image_header.dart'; diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 33922989..1ab84878 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -172,11 +172,14 @@ class ChannelsBlocState extends State })); _subscriptions.add(client - .on(EventType.channelDeleted, EventType.notificationRemovedFromChannel) + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + ) .listen((e) { final channel = e.channel; - _channelsController - .add(List.from(channels..removeWhere((c) => c.cid == channel.cid))); + _channelsController.add(List.from( + (channels ?? [])..removeWhere((c) => c.cid == channel.cid))); })); } diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 237754db..87f821c1 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -144,7 +144,7 @@ class _MessageListCoreState extends State { return widget.errorWidgetBuilder(context, snapshot.error); } else { final messageList = snapshot.data?.reversed?.toList() ?? []; - if (messageList.isEmpty) { + if (messageList.isEmpty && !_isThreadConversation) { if (_upToDate) { return widget.emptyBuilder(context); }