feat: added customization options in main widgets (#312)

* polish channelpreview customization options

* polish channelheader customization options

* polish messageinput customization options

* polish threadheader customization options

* polish channellistheader customization options

* show the parent message if no messages in thread

* fix edit message

* fix channelinfo textstyle

* fix review

* fix useravatar

* add ontitle tap to thread header

* add custom message actions

* add borderradius and button builder

* add ontap to messageinput custom send button

* fix bottom sheet

* add doc

* use placeholder image

* add doc

* fix listtile density

* remove subtitle from ChannelListHeaderTheme.copyWith

* add InputDecoration.merge extension
This commit is contained in:
Salvatore Giordano
2021-03-08 15:27:52 +01:00
committed by GitHub
parent db04ba021a
commit 5080203c1b
23 changed files with 815 additions and 411 deletions
+8 -7
View File
@@ -84,16 +84,17 @@ class StreamChatClient {
Duration connectTimeout = const Duration(seconds: 6), Duration connectTimeout = const Duration(seconds: 6),
Duration receiveTimeout = const Duration(seconds: 6), Duration receiveTimeout = const Duration(seconds: 6),
Dio httpClient, Dio httpClient,
// ignore: avoid_unused_constructor_parameters
RetryPolicy retryPolicy, RetryPolicy retryPolicy,
this.attachmentFileUploader, this.attachmentFileUploader,
}) { }) {
_retryPolicy ??= RetryPolicy( _retryPolicy = retryPolicy ??
retryTimeout: (StreamChatClient client, int attempt, ApiError error) => RetryPolicy(
Duration(seconds: 1 * attempt), retryTimeout:
shouldRetry: (StreamChatClient client, int attempt, ApiError error) => (StreamChatClient client, int attempt, ApiError error) =>
attempt < 5, Duration(seconds: 1 * attempt),
); shouldRetry: (StreamChatClient client, int attempt, ApiError error) =>
attempt < 5,
);
attachmentFileUploader ??= StreamAttachmentFileUploader(this); attachmentFileUploader ??= StreamAttachmentFileUploader(this);
@@ -104,10 +104,10 @@ class FileAttachment extends AttachmentWidget {
return getFileTypeImage(attachment.extraData['other']); return getFileTypeImage(attachment.extraData['other']);
}, },
placeholder: (_, __) { placeholder: (_, __) {
return Container( return Image.asset(
width: size?.width, 'images/placeholder.png',
height: size?.height, fit: BoxFit.cover,
child: Image.memory(kTransparentImage), package: 'stream_chat_flutter',
); );
}, },
), ),
@@ -348,10 +348,10 @@ class GiphyAttachment extends AttachmentWidget {
height: size?.height, height: size?.height,
width: size?.width, width: size?.width,
placeholder: (_, __) { placeholder: (_, __) {
return Container( return Image.asset(
width: size?.width, 'images/placeholder.png',
height: size?.height, fit: BoxFit.cover,
child: Image.memory(kTransparentImage), package: 'stream_chat_flutter',
); );
}, },
imageUrl: imageUrl, imageUrl: imageUrl,
@@ -84,10 +84,10 @@ class ImageAttachment extends AttachmentWidget {
height: size?.height, height: size?.height,
width: size?.width, width: size?.width,
placeholder: (_, __) { placeholder: (_, __) {
return Container( return Image.asset(
width: size?.width, 'images/placeholder.png',
height: size?.height, fit: BoxFit.cover,
child: Image.memory(kTransparentImage), package: 'stream_chat_flutter',
); );
}, },
imageUrl: imageUrl, imageUrl: imageUrl,
@@ -114,9 +114,9 @@ class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
children: [ children: [
UserAvatar( UserAvatar(
user: members[index].user, user: members[index].user,
constraints: BoxConstraints( constraints: BoxConstraints.tightFor(
maxHeight: 64.0, height: 64.0,
maxWidth: 64.0, width: 64.0,
), ),
borderRadius: BorderRadius.circular(32.0), borderRadius: BorderRadius.circular(32.0),
onlineIndicatorConstraints: onlineIndicatorConstraints:
@@ -238,8 +238,8 @@ class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
color: StreamChatTheme.of(context).colorTheme.accentRed, color: StreamChatTheme.of(context).colorTheme.accentRed,
), ),
); );
var channel = StreamChannel.of(context).channel;
if (res == true) { if (res == true) {
final channel = StreamChannel.of(context).channel;
await channel.removeMembers([StreamChat.of(context).user.id]); await channel.removeMembers([StreamChat.of(context).user.id]);
Navigator.pop(context); Navigator.pop(context);
} }
@@ -71,6 +71,19 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
final bool showConnectionStateTile; 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<Widget> actions;
/// Creates a channel header /// Creates a channel header
ChannelHeader({ ChannelHeader({
Key key, Key key,
@@ -80,6 +93,10 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
this.showTypingIndicator = true, this.showTypingIndicator = true,
this.onImageTap, this.onImageTap,
this.showConnectionStateTile = false, this.showConnectionStateTile = false,
this.title,
this.subtitle,
this.leading,
this.actions,
}) : preferredSize = Size.fromHeight(kToolbarHeight), }) : preferredSize = Size.fromHeight(kToolbarHeight),
super(key: key); super(key: key);
@@ -87,6 +104,14 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
final leadingWidget = leading ??
(showBackButton
? StreamBackButton(
onPressed: onBackPressed,
showUnreads: true,
)
: SizedBox());
return ConnectionStatusBuilder( return ConnectionStatusBuilder(
statusBuilder: (context, status) { statusBuilder: (context, status) {
var statusString = ''; var statusString = '';
@@ -111,26 +136,32 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
child: AppBar( child: AppBar(
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
leading: showBackButton leading: leadingWidget,
? StreamBackButton(
onPressed: onBackPressed,
showUnreads: true,
)
: SizedBox(),
backgroundColor: StreamChatTheme.of(context) backgroundColor: StreamChatTheme.of(context)
.channelTheme .channelTheme
.channelHeaderTheme .channelHeaderTheme
.color, .color,
actions: <Widget>[ actions: actions ??
Padding( <Widget>[
padding: const EdgeInsets.only(right: 10.0), Padding(
child: Center( padding: const EdgeInsets.only(right: 10.0),
child: ChannelImage( child: Center(
onTap: onImageTap, child: ChannelImage(
borderRadius: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.avatarTheme
.borderRadius,
constraints: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.avatarTheme
.constraints,
onTap: onImageTap,
),
),
), ),
), ],
),
],
centerTitle: true, centerTitle: true,
title: InkWell( title: InkWell(
onTap: onTitleTap, onTap: onTitleTap,
@@ -141,20 +172,23 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
ChannelName( title ??
textStyle: StreamChatTheme.of(context) ChannelName(
.channelTheme textStyle: StreamChatTheme.of(context)
.channelHeaderTheme .channelTheme
.title, .channelHeaderTheme
), .title,
),
SizedBox(height: 2), SizedBox(height: 2),
ChannelInfo( subtitle ??
showTypingIndicator: showTypingIndicator, ChannelInfo(
channel: channel, showTypingIndicator: showTypingIndicator,
textStyle: StreamChatTheme.of(context) channel: channel,
.channelPreviewTheme textStyle: StreamChatTheme.of(context)
.subtitle, .channelTheme
), .channelHeaderTheme
.subtitle,
),
], ],
), ),
), ),
@@ -57,7 +57,7 @@ class ChannelInfo extends StatelessWidget {
style: StreamChatTheme.of(context) style: StreamChatTheme.of(context)
.channelTheme .channelTheme
.channelHeaderTheme .channelHeaderTheme
.lastMessageAt, .subtitle,
); );
} else { } else {
final otherMember = members.firstWhere( final otherMember = members.firstWhere(
@@ -69,10 +69,7 @@ class ChannelInfo extends StatelessWidget {
if (otherMember.user.online) { if (otherMember.user.online) {
alternativeWidget = Text( alternativeWidget = Text(
'Online', 'Online',
style: StreamChatTheme.of(context) style: textStyle,
.channelTheme
.channelHeaderTheme
.lastMessageAt,
); );
} else { } else {
alternativeWidget = Text( alternativeWidget = Text(
@@ -45,7 +45,7 @@ typedef _TitleBuilder = Widget Function(
/// The widget by default uses the inherited [StreamChatClient] to fetch information about the status. /// 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. /// 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. /// Modify it to change the widget appearance.
class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
/// Instantiates a ChannelListHeader /// Instantiates a ChannelListHeader
@@ -57,6 +57,9 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
this.onNewChatButtonTap, this.onNewChatButtonTap,
this.showConnectionStateTile = false, this.showConnectionStateTile = false,
this.preNavigationCallback, this.preNavigationCallback,
this.subtitle,
this.leading,
this.actions,
}) : super(key: key); }) : super(key: key);
/// Pass this if you don't have a [StreamChatClient] in your widget tree. /// 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; 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<Widget> actions;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final _client = client ?? StreamChat.of(context).client; final _client = client ?? StreamChat.of(context).client;
@@ -104,76 +118,85 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
child: AppBar( child: AppBar(
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
backgroundColor: StreamChatTheme.of(context) backgroundColor:
.channelTheme StreamChatTheme.of(context).channelListHeaderTheme.color,
.channelHeaderTheme
.color,
centerTitle: true, centerTitle: true,
leading: Center( leading: leading ??
child: UserAvatar( Center(
user: user, child: UserAvatar(
showOnlineStatus: false, user: user,
onTap: onUserAvatarTap ?? showOnlineStatus: false,
(_) { onTap: onUserAvatarTap ??
if (preNavigationCallback != null) { (_) {
preNavigationCallback(); if (preNavigationCallback != null) {
} preNavigationCallback();
Scaffold.of(context).openDrawer(); }
}, Scaffold.of(context).openDrawer();
borderRadius: BorderRadius.circular(20), },
constraints: BoxConstraints.tightFor( borderRadius: StreamChatTheme.of(context)
height: 40, .channelListHeaderTheme
width: 40, .avatarTheme
), .borderRadius,
), constraints: StreamChatTheme.of(context)
), .channelListHeaderTheme
actions: [ .avatarTheme
StreamNeumorphicButton( .constraints,
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,
), ),
) actions: actions ??
], [
title: Builder( StreamNeumorphicButton(
builder: (context) { child: IconButton(
if (titleBuilder != null) { icon: ConnectionStatusBuilder(
return titleBuilder(context, status, _client); statusBuilder: (context, status) {
} var color;
switch (status) { switch (status) {
case ConnectionStatus.connected: case ConnectionStatus.connected:
return _buildConnectedTitleState(context); color = StreamChatTheme.of(context)
case ConnectionStatus.connecting: .colorTheme
return _buildConnectingTitleState(context); .accentBlue;
case ConnectionStatus.disconnected: break;
return _buildDisconnectedTitleState(context, _client); case ConnectionStatus.connecting:
default: color = Colors.grey;
return Offstage(); 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), SizedBox(width: 10),
Text( Text(
'Searching for Network', 'Searching for Network',
style: StreamChatTheme.of(context) style:
.channelTheme StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith(
.channelHeaderTheme fontSize: 16,
.title fontWeight: FontWeight.bold,
.copyWith( ),
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
], ],
); );
@@ -222,14 +242,11 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
children: [ children: [
Text( Text(
'Offline...', 'Offline...',
style: StreamChatTheme.of(context) style:
.channelTheme StreamChatTheme.of(context).channelListHeaderTheme.title.copyWith(
.channelHeaderTheme fontSize: 16,
.title fontWeight: FontWeight.bold,
.copyWith( ),
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -239,8 +256,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
child: Text( child: Text(
'Try Again', 'Try Again',
style: StreamChatTheme.of(context) style: StreamChatTheme.of(context)
.channelTheme .channelListHeaderTheme
.channelHeaderTheme
.title .title
.copyWith( .copyWith(
fontSize: 16, fontSize: 16,
@@ -32,16 +32,37 @@ class ChannelPreview extends StatelessWidget {
/// The function called when the image is tapped /// The function called when the image is tapped
final VoidCallback onImageTap; 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({ ChannelPreview({
@required this.channel, @required this.channel,
Key key, Key key,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
this.onImageTap, this.onImageTap,
this.title,
this.subtitle,
this.leading,
this.sendingIndicator,
this.trailing,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme;
return StreamBuilder<bool>( return StreamBuilder<bool>(
stream: channel.isMutedStream, stream: channel.isMutedStream,
initialData: channel.isMuted, initialData: channel.isMuted,
@@ -49,6 +70,7 @@ class ChannelPreview extends StatelessWidget {
return Opacity( return Opacity(
opacity: snapshot.data ? 0.5 : 1, opacity: snapshot.data ? 0.5 : 1,
child: ListTile( child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
), ),
@@ -62,17 +84,18 @@ class ChannelPreview extends StatelessWidget {
onLongPress(channel); onLongPress(channel);
} }
}, },
leading: ChannelImage( leading: leading ??
onTap: onImageTap, ChannelImage(
), onTap: onImageTap,
),
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Flexible( Flexible(
child: ChannelName( child: title ??
textStyle: ChannelName(
StreamChatTheme.of(context).channelPreviewTheme.title, textStyle: channelPreviewTheme.title,
), ),
), ),
StreamBuilder<List<Member>>( StreamBuilder<List<Member>>(
stream: channel.state.membersStream, stream: channel.state.membersStream,
@@ -93,37 +116,36 @@ class ChannelPreview extends StatelessWidget {
subtitle: Row( subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Flexible(child: _buildSubtitle(context)), Flexible(child: subtitle ?? _buildSubtitle(context)),
Builder( sendingIndicator ??
builder: (context) { Builder(
final lastMessage = channel.state.messages.lastWhere( builder: (context) {
(m) => !m.isDeleted && m.shadowed != true, final lastMessage = channel.state.messages.lastWhere(
orElse: () => null, (m) => !m.isDeleted && m.shadowed != true,
); orElse: () => null,
if (lastMessage?.user?.id == );
StreamChat.of(context).user.id) { if (lastMessage?.user?.id ==
return Padding( StreamChat.of(context).user.id) {
padding: const EdgeInsets.only(right: 4.0), return Padding(
child: SendingIndicator( padding: const EdgeInsets.only(right: 4.0),
message: lastMessage, child: SendingIndicator(
size: StreamChatTheme.of(context) message: lastMessage,
.channelPreviewTheme size: channelPreviewTheme.indicatorIconSize,
.indicatorIconSize, isMessageRead: channel.state.read
isMessageRead: channel.state.read ?.where((element) =>
?.where((element) => element.user.id !=
element.user.id != channel.client.state.user.id)
channel.client.state.user.id) ?.where((element) => element.lastRead
?.where((element) => element.lastRead .isAfter(lastMessage.createdAt))
.isAfter(lastMessage.createdAt)) ?.isNotEmpty ==
?.isNotEmpty == true,
true, ),
), );
); }
} return SizedBox();
return SizedBox(); },
}, ),
), trailing ?? _buildDate(context),
_buildDate(context),
], ],
), ),
), ),
@@ -176,15 +198,7 @@ class ChannelPreview extends StatelessWidget {
), ),
Text( Text(
' Channel is muted', ' Channel is muted',
style: StreamChatTheme.of(context) style: StreamChatTheme.of(context).channelPreviewTheme.subtitle,
.channelPreviewTheme
.subtitle
.copyWith(
color: StreamChatTheme.of(context)
.channelPreviewTheme
.subtitle
.color,
),
), ),
], ],
); );
@@ -192,10 +206,7 @@ class ChannelPreview extends StatelessWidget {
return TypingIndicator( return TypingIndicator(
channel: channel, channel: channel,
alternativeWidget: _buildLastMessage(context), alternativeWidget: _buildLastMessage(context),
style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( style: StreamChatTheme.of(context).channelPreviewTheme.subtitle,
color:
StreamChatTheme.of(context).channelPreviewTheme.subtitle.color,
),
); );
} }
@@ -1,6 +1,7 @@
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:emojis/emoji.dart'; import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
final _emojis = Emoji.all(); final _emojis = Emoji.all();
@@ -44,3 +45,55 @@ extension PlatformFileX on PlatformFile {
size: size, 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,
);
}
}
@@ -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,
});
}
@@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
@@ -34,6 +35,9 @@ class MessageActionsModal extends StatefulWidget {
final ShapeBorder attachmentShape; final ShapeBorder attachmentShape;
final DisplayWidget showUserAvatar; final DisplayWidget showUserAvatar;
/// List of custom actions
final List<MessageAction> customActions;
const MessageActionsModal({ const MessageActionsModal({
Key key, Key key,
@required this.message, @required this.message,
@@ -53,6 +57,7 @@ class MessageActionsModal extends StatefulWidget {
this.messageShape, this.messageShape,
this.attachmentShape, this.attachmentShape,
this.reverse = false, this.reverse = false,
this.customActions = const [],
}) : super(key: key); }) : super(key: key);
@override @override
@@ -223,6 +228,12 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
_buildFlagButton(context), _buildFlagButton(context),
if (widget.showDeleteMessage) if (widget.showDeleteMessage)
_buildDeleteButton(context), _buildDeleteButton(context),
...widget.customActions.map((action) {
return _buildCustomAction(
context,
action,
);
})
].insertBetween( ].insertBetween(
Container( Container(
height: 1, height: 1,
@@ -248,6 +259,27 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
); );
} }
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 { void _showFlagDialog() async {
final client = StreamChat.of(context).client; final client = StreamChat.of(context).client;
@@ -541,21 +573,16 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
], ],
), ),
), ),
Padding( widget.editMessageInputBuilder != null
padding: EdgeInsets.only( ? widget.editMessageInputBuilder(context, widget.message)
bottom: MediaQuery.of(context).viewInsets.bottom, : MessageInput(
), editMessage: widget.message,
child: widget.editMessageInputBuilder != null preMessageSending: (m) {
? widget.editMessageInputBuilder(context, widget.message) FocusScope.of(context).unfocus();
: MessageInput( Navigator.pop(context);
editMessage: widget.message, return m;
preMessageSending: (m) { },
FocusScope.of(context).unfocus(); ),
Navigator.pop(context);
return m;
},
),
),
], ],
), ),
); );
@@ -42,6 +42,15 @@ enum DefaultAttachmentTypes {
file, 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 _kMinMediaPickerSize = 360.0;
const _kMaxAttachmentSize = 20971520; // 20MB in Bytes const _kMaxAttachmentSize = 20971520; // 20MB in Bytes
@@ -107,6 +116,11 @@ class MessageInput extends StatefulWidget {
this.focusNode, this.focusNode,
this.quotedMessage, this.quotedMessage,
this.onQuotedMessageCleared, this.onQuotedMessageCleared,
this.sendButtonLocation = SendButtonLocation.outside,
this.autofocus = false,
this.hideSendAsDm = false,
this.idleSendButton,
this.activeSendButton,
}) : super(key: key); }) : super(key: key);
/// Message to edit /// Message to edit
@@ -134,6 +148,9 @@ class MessageInput extends StatefulWidget {
/// If true the attachments button will not be displayed /// If true the attachments button will not be displayed
final bool disableAttachments; final bool disableAttachments;
/// Hide send as dm checkbox
final bool hideSendAsDm;
/// The text controller of the TextField /// The text controller of the TextField
final TextEditingController textEditingController; final TextEditingController textEditingController;
@@ -155,6 +172,18 @@ class MessageInput extends StatefulWidget {
/// ///
final VoidCallback onQuotedMessageCleared; 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 @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
@@ -281,7 +310,7 @@ class MessageInputState extends State<MessageInput> {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: _buildTextField(context), child: _buildTextField(context),
), ),
if (widget.parentMessage != null) if (widget.parentMessage != null && !widget.hideSendAsDm)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _buildDmCheckbox(), child: _buildDmCheckbox(),
@@ -310,9 +339,10 @@ class MessageInputState extends State<MessageInput> {
if (widget.actionsLocation == ActionsLocation.left) if (widget.actionsLocation == ActionsLocation.left)
...widget.actions ?? [], ...widget.actions ?? [],
_buildTextInput(context), _buildTextInput(context),
_animateSendButton(context),
if (widget.actionsLocation == ActionsLocation.right) if (widget.actionsLocation == ActionsLocation.right)
...widget.actions ?? [], ...widget.actions ?? [],
if (widget.sendButtonLocation == SendButtonLocation.outside)
_animateSendButton(context),
], ],
); );
} }
@@ -384,14 +414,20 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _animateSendButton(BuildContext context) { Widget _animateSendButton(BuildContext context) {
final sendButton = widget.activeSendButton != null
? InkWell(
child: widget.activeSendButton,
onTap: sendMessage,
)
: _buildSendButton(context);
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: AnimatedCrossFade( child: AnimatedCrossFade(
crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) crossFadeState: (_messageIsPresent || _attachments.isNotEmpty)
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
firstChild: _buildSendButton(context), firstChild: sendButton,
secondChild: _buildIdleSendButton(context), secondChild: widget.idleSendButton ?? _buildIdleSendButton(context),
duration: duration:
StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration, StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration,
alignment: Alignment.center, alignment: Alignment.center,
@@ -447,101 +483,139 @@ class MessageInputState extends State<MessageInput> {
child: Container( child: Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.0), borderRadius: theme.messageInputTheme.borderRadius,
border: Border.all(color: theme.colorTheme.greyGainsboro), gradient: _focusNode.hasFocus
? theme.messageInputTheme.activeBorderGradient
: theme.messageInputTheme.idleBorderGradient,
), ),
child: Column( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(1.5),
crossAxisAlignment: CrossAxisAlignment.start, child: Container(
children: [ clipBehavior: Clip.antiAlias,
_buildReplyToMessage(), decoration: BoxDecoration(
_buildAttachments(), borderRadius: theme.messageInputTheme.borderRadius,
LimitedBox( color: theme.messageInputTheme.inputBackground,
maxHeight: widget.maxHeight, ),
child: TextField( child: Column(
key: Key('messageInputText'), mainAxisSize: MainAxisSize.min,
enabled: _inputEnabled, crossAxisAlignment: CrossAxisAlignment.start,
minLines: null, children: [
maxLines: null, _buildReplyToMessage(),
onSubmitted: (_) => sendMessage(), _buildAttachments(),
keyboardType: widget.keyboardType, LimitedBox(
controller: textEditingController, maxHeight: widget.maxHeight,
focusNode: _focusNode, child: TextField(
style: theme.textTheme.body, key: Key('messageInputText'),
autofocus: false, enabled: _inputEnabled,
textAlignVertical: TextAlignVertical.center, minLines: null,
decoration: InputDecoration( maxLines: null,
isDense: true, onSubmitted: (_) => sendMessage(),
hintText: _getHint(), keyboardType: widget.keyboardType,
hintStyle: theme.textTheme.body.copyWith( controller: textEditingController,
color: theme.colorTheme.grey, 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; Timer _debounce;
void _onChanged(BuildContext context, String s) { void _onChanged(BuildContext context, String s) {
@@ -621,7 +695,9 @@ class MessageInputState extends State<MessageInput> {
.last .last
.contains('@')) { .contains('@')) {
_mentionsOverlay = _buildMentionsOverlayEntry(); _mentionsOverlay = _buildMentionsOverlayEntry();
Overlay.of(context).insert(_mentionsOverlay); if (_mentionsOverlay != null) {
Overlay.of(context).insert(_mentionsOverlay);
}
} }
} }
@@ -646,7 +722,9 @@ class MessageInputState extends State<MessageInput> {
_commandsOverlay = null; _commandsOverlay = null;
} else { } else {
_commandsOverlay = _buildCommandsOverlayEntry(); _commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay); if (_commandsOverlay != null) {
Overlay.of(context).insert(_commandsOverlay);
}
} }
} }
} }
@@ -661,6 +739,10 @@ class MessageInputState extends State<MessageInput> {
?.toList() ?? ?.toList() ??
[]; [];
if (commands.isEmpty) {
return null;
}
RenderBox renderBox = context.findRenderObject(); RenderBox renderBox = context.findRenderObject();
final size = renderBox.size; final size = renderBox.size;
@@ -833,6 +915,7 @@ class MessageInputState extends State<MessageInput> {
child: Material( child: Material(
color: StreamChatTheme.of(context).colorTheme.whiteSmoke, color: StreamChatTheme.of(context).colorTheme.whiteSmoke,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -1100,6 +1183,10 @@ class MessageInputState extends State<MessageInput> {
})?.toList() ?? })?.toList() ??
[]; [];
if (members.isEmpty) {
return null;
}
RenderBox renderBox = context.findRenderObject(); RenderBox renderBox = context.findRenderObject();
final size = renderBox.size; final size = renderBox.size;
@@ -36,6 +36,7 @@ typedef ThreadBuilder = Widget Function(BuildContext context, Message parent);
typedef ThreadTapCallback = void Function(Message, Widget); typedef ThreadTapCallback = void Function(Message, Widget);
typedef OnMessageSwiped = void Function(Message); typedef OnMessageSwiped = void Function(Message);
typedef OnMessageTap = void Function(Message);
typedef ReplyTapCallback = void Function(Message); typedef ReplyTapCallback = void Function(Message);
class MessageDetails { class MessageDetails {
@@ -224,10 +225,10 @@ class MessageListView extends StatefulWidget {
final Map<String, AttachmentBuilder> customAttachmentBuilders; final Map<String, AttachmentBuilder> customAttachmentBuilders;
/// Called when any message is tapped except a system message (use [onSystemMessageTap] instead) /// 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 /// Called when system message is tapped
final void Function(Message) onSystemMessageTap; final OnMessageTap onSystemMessageTap;
// Customize onTap on attachment // Customize onTap on attachment
final void Function(Message message, Attachment attachment) onAttachmentTap; final void Function(Message message, Attachment attachment) onAttachmentTap;
@@ -436,7 +437,7 @@ class _MessageListViewState extends State<MessageListView> {
style: StreamChatTheme.of(context) style: StreamChatTheme.of(context)
.channelTheme .channelTheme
.channelHeaderTheme .channelHeaderTheme
.lastMessageAt, .subtitle,
), ),
), ),
); );
@@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; 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/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart';
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_portal/flutter_portal.dart';
import 'package:jiffy/jiffy.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_actions_modal.dart';
import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; import 'package:stream_chat_flutter/src/message_reactions_modal.dart';
import 'package:stream_chat_flutter/src/quoted_message_widget.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 /// Function called when message is tapped
final void Function(Message) onMessageTap; final void Function(Message) onMessageTap;
/// List of custom actions shown on message long tap
final List<MessageAction> customActions;
// Customize onTap on attachment // Customize onTap on attachment
final void Function(Message message, Attachment attachment) onAttachmentTap; final void Function(Message message, Attachment attachment) onAttachmentTap;
@@ -198,6 +202,7 @@ class MessageWidget extends StatefulWidget {
this.attachmentPadding = EdgeInsets.zero, this.attachmentPadding = EdgeInsets.zero,
this.allRead = false, this.allRead = false,
this.onQuotedMessageTap, this.onQuotedMessageTap,
this.customActions = const [],
this.onAttachmentTap, this.onAttachmentTap,
}) : attachmentBuilders = { }) : attachmentBuilders = {
'image': (context, message, attachment) { 'image': (context, message, attachment) {
@@ -760,6 +765,7 @@ class _MessageWidgetState extends State<MessageWidget>
!isFailedState && !isFailedState &&
widget.onThreadTap != null, widget.onThreadTap != null,
showFlagButton: widget.showFlagButton, showFlagButton: widget.showFlagButton,
customActions: widget.customActions,
), ),
); );
}); });
@@ -966,6 +972,7 @@ class _MessageWidgetState extends State<MessageWidget>
user: widget.message.user, user: widget.message.user,
onTap: widget.onUserAvatarTap, onTap: widget.onUserAvatarTap,
constraints: widget.messageTheme.avatarTheme.constraints, constraints: widget.messageTheme.avatarTheme.constraints,
borderRadius: widget.messageTheme.avatarTheme.borderRadius,
showOnlineStatus: false, showOnlineStatus: false,
), ),
), ),
@@ -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/message_input.dart';
import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart';
import 'package:stream_chat_flutter/src/utils.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'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Inherited widget providing the [StreamChatThemeData] to the widget tree /// Inherited widget providing the [StreamChatThemeData] to the widget tree
@@ -45,12 +46,15 @@ class StreamChatThemeData {
/// The text themes used in the widgets /// The text themes used in the widgets
final TextTheme textTheme; final TextTheme textTheme;
/// The text themes used in the widgets /// The color themes used in the widgets
final ColorTheme colorTheme; final ColorTheme colorTheme;
/// Theme of the [ChannelPreview] /// Theme of the [ChannelPreview]
final ChannelPreviewTheme channelPreviewTheme; final ChannelPreviewTheme channelPreviewTheme;
/// Theme of the [ChannelListHeader]
final ChannelListHeaderTheme channelListHeaderTheme;
/// Theme of the chat widgets dedicated to a channel /// Theme of the chat widgets dedicated to a channel
final ChannelTheme channelTheme; final ChannelTheme channelTheme;
@@ -60,7 +64,7 @@ class StreamChatThemeData {
/// Theme of other users messages /// Theme of other users messages
final MessageTheme otherMessageTheme; final MessageTheme otherMessageTheme;
/// Theme of other users messages /// Theme dedicated to the [MessageInput] widget
final MessageInputTheme messageInputTheme; final MessageInputTheme messageInputTheme;
/// The widget that will be built when the channel image is unavailable /// The widget that will be built when the channel image is unavailable
@@ -79,6 +83,7 @@ class StreamChatThemeData {
const StreamChatThemeData({ const StreamChatThemeData({
this.textTheme, this.textTheme,
this.colorTheme, this.colorTheme,
this.channelListHeaderTheme,
this.channelPreviewTheme, this.channelPreviewTheme,
this.channelTheme, this.channelTheme,
this.otherMessageTheme, this.otherMessageTheme,
@@ -98,9 +103,7 @@ class StreamChatThemeData {
accentBlue: theme.accentColor, accentBlue: theme.accentColor,
), ),
defaultTheme.textTheme, defaultTheme.textTheme,
).copyWith( );
// primaryIconTheme: theme.primaryIconTheme,
);
return defaultTheme.merge(customizedTheme) ?? customizedTheme; return defaultTheme.merge(customizedTheme) ?? customizedTheme;
} }
@@ -116,9 +119,12 @@ class StreamChatThemeData {
Widget Function(BuildContext, Channel) defaultChannelImage, Widget Function(BuildContext, Channel) defaultChannelImage,
Widget Function(BuildContext, User) defaultUserImage, Widget Function(BuildContext, User) defaultUserImage,
IconThemeData primaryIconTheme, IconThemeData primaryIconTheme,
ChannelListHeaderTheme channelListHeaderTheme,
List<ReactionIcon> reactionIcons, List<ReactionIcon> reactionIcons,
}) => }) =>
StreamChatThemeData( StreamChatThemeData(
channelListHeaderTheme:
channelListHeaderTheme ?? this.channelListHeaderTheme,
textTheme: textTheme ?? this.textTheme, textTheme: textTheme ?? this.textTheme,
colorTheme: colorTheme ?? this.colorTheme, colorTheme: colorTheme ?? this.colorTheme,
primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme,
@@ -135,6 +141,9 @@ class StreamChatThemeData {
StreamChatThemeData merge(StreamChatThemeData other) { StreamChatThemeData merge(StreamChatThemeData other) {
if (other == null) return this; if (other == null) return this;
return copyWith( return copyWith(
channelListHeaderTheme:
channelListHeaderTheme?.merge(other.channelListHeaderTheme) ??
other.channelListHeaderTheme,
textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme, textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme,
colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme, colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme,
primaryIconTheme: other.primaryIconTheme, primaryIconTheme: other.primaryIconTheme,
@@ -189,6 +198,17 @@ class StreamChatThemeData {
color: colorTheme.black.withOpacity(.5), color: colorTheme.black.withOpacity(.5),
), ),
indicatorIconSize: 16.0), 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( channelTheme: ChannelTheme(
channelHeaderTheme: ChannelHeaderTheme( channelHeaderTheme: ChannelHeaderTheme(
avatarTheme: AvatarTheme( avatarTheme: AvatarTheme(
@@ -200,9 +220,8 @@ class StreamChatThemeData {
), ),
color: colorTheme.white, color: colorTheme.white,
title: textTheme.headlineBold, title: textTheme.headlineBold,
lastMessageAt: TextStyle( subtitle: textTheme.footnote.copyWith(
fontSize: 11, color: Color(0xff7A7A7A),
color: colorTheme.black.withOpacity(.5),
), ),
), ),
), ),
@@ -249,12 +268,26 @@ class StreamChatThemeData {
), ),
), ),
messageInputTheme: MessageInputTheme( messageInputTheme: MessageInputTheme(
borderRadius: BorderRadius.circular(20),
sendAnimationDuration: Duration(milliseconds: 300), sendAnimationDuration: Duration(milliseconds: 300),
actionButtonColor: colorTheme.accentBlue, actionButtonColor: colorTheme.accentBlue,
actionButtonIdleColor: colorTheme.grey, actionButtonIdleColor: colorTheme.grey,
sendButtonColor: colorTheme.accentBlue, sendButtonColor: colorTheme.accentBlue,
sendButtonIdleColor: colorTheme.greyGainsboro, sendButtonIdleColor: colorTheme.greyGainsboro,
inputBackground: colorTheme.white, inputBackground: colorTheme.white,
inputTextStyle: textTheme.body,
idleBorderGradient: LinearGradient(
colors: [
colorTheme.greyGainsboro,
colorTheme.greyGainsboro,
],
),
activeBorderGradient: LinearGradient(
colors: [
colorTheme.greyGainsboro,
colorTheme.greyGainsboro,
],
),
), ),
reactionIcons: [ reactionIcons: [
ReactionIcon( ReactionIcon(
@@ -809,26 +842,26 @@ class ChannelPreviewTheme {
class ChannelHeaderTheme { class ChannelHeaderTheme {
final TextStyle title; final TextStyle title;
final TextStyle lastMessageAt; final TextStyle subtitle;
final AvatarTheme avatarTheme; final AvatarTheme avatarTheme;
final Color color; final Color color;
const ChannelHeaderTheme({ const ChannelHeaderTheme({
this.title, this.title,
this.lastMessageAt, this.subtitle,
this.avatarTheme, this.avatarTheme,
this.color, this.color,
}); });
ChannelHeaderTheme copyWith({ ChannelHeaderTheme copyWith({
TextStyle title, TextStyle title,
TextStyle lastMessageAt, TextStyle subtitle,
AvatarTheme avatarTheme, AvatarTheme avatarTheme,
Color color, Color color,
}) => }) =>
ChannelHeaderTheme( ChannelHeaderTheme(
title: title ?? this.title, title: title ?? this.title,
lastMessageAt: lastMessageAt ?? this.lastMessageAt, subtitle: subtitle ?? this.subtitle,
avatarTheme: avatarTheme ?? this.avatarTheme, avatarTheme: avatarTheme ?? this.avatarTheme,
color: color ?? this.color, color: color ?? this.color,
); );
@@ -837,8 +870,48 @@ class ChannelHeaderTheme {
if (other == null) return this; if (other == null) return this;
return copyWith( return copyWith(
title: title?.merge(other.title) ?? other.title, title: title?.merge(other.title) ?? other.title,
lastMessageAt: subtitle: subtitle?.merge(other.subtitle) ?? other.subtitle,
lastMessageAt?.merge(other.lastMessageAt) ?? other.lastMessageAt, 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, avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme,
color: other.color, color: other.color,
); );
@@ -865,6 +938,21 @@ class MessageInputTheme {
/// Background color of [MessageInput] /// Background color of [MessageInput]
final Color inputBackground; 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] /// Returns a new [MessageInputTheme]
const MessageInputTheme({ const MessageInputTheme({
this.sendAnimationDuration, this.sendAnimationDuration,
@@ -873,6 +961,11 @@ class MessageInputTheme {
this.actionButtonIdleColor, this.actionButtonIdleColor,
this.sendButtonIdleColor, this.sendButtonIdleColor,
this.inputBackground, this.inputBackground,
this.inputTextStyle,
this.inputDecoration,
this.activeBorderGradient,
this.idleBorderGradient,
this.borderRadius,
}); });
/// Returns a new [MessageInputTheme] replacing some of its properties /// Returns a new [MessageInputTheme] replacing some of its properties
@@ -883,6 +976,11 @@ class MessageInputTheme {
Color sendButtonColor, Color sendButtonColor,
Color actionButtonIdleColor, Color actionButtonIdleColor,
Color sendButtonIdleColor, Color sendButtonIdleColor,
TextStyle inputTextStyle,
InputDecoration inputDecoration,
Gradient activeBorderGradient,
Gradient idleBorderGradient,
BorderRadius borderRadius,
}) => }) =>
MessageInputTheme( MessageInputTheme(
sendAnimationDuration: sendAnimationDuration:
@@ -892,7 +990,12 @@ class MessageInputTheme {
sendButtonColor: sendButtonColor ?? this.sendButtonColor, sendButtonColor: sendButtonColor ?? this.sendButtonColor,
actionButtonIdleColor: actionButtonIdleColor:
actionButtonIdleColor ?? this.actionButtonIdleColor, actionButtonIdleColor ?? this.actionButtonIdleColor,
inputTextStyle: inputTextStyle ?? this.inputTextStyle,
sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, 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] /// Merges [this] [MessageInputTheme] with the [other]
@@ -905,6 +1008,12 @@ class MessageInputTheme {
actionButtonIdleColor: other.actionButtonIdleColor, actionButtonIdleColor: other.actionButtonIdleColor,
sendButtonColor: other.sendButtonColor, sendButtonColor: other.sendButtonColor,
sendButtonIdleColor: other.sendButtonIdleColor, sendButtonIdleColor: other.sendButtonIdleColor,
inputTextStyle: other.inputTextStyle,
inputDecoration: inputDecoration?.merge(other.inputDecoration) ??
other.inputDecoration,
activeBorderGradient: other.activeBorderGradient,
idleBorderGradient: other.idleBorderGradient,
borderRadius: other.borderRadius,
); );
} }
} }
@@ -63,15 +63,35 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
/// By default it calls [Navigator.pop] /// By default it calls [Navigator.pop]
final VoidCallback onBackPressed; final VoidCallback onBackPressed;
/// Callback to call when the title is tapped.
final VoidCallback onTitleTap;
/// The message parent of this thread /// The message parent of this thread
final Message parent; final Message parent;
/// Title widget
final Widget title;
/// Subtitle widget
final Widget subtitle;
/// Leading widget
final Widget leading;
/// AppBar actions
final List<Widget> actions;
/// Instantiate a new ThreadHeader /// Instantiate a new ThreadHeader
ThreadHeader({ ThreadHeader({
Key key, Key key,
@required this.parent, @required this.parent,
this.showBackButton = true, this.showBackButton = true,
this.onBackPressed, this.onBackPressed,
this.title,
this.subtitle,
this.leading,
this.actions,
this.onTitleTap,
}) : preferredSize = Size.fromHeight(kToolbarHeight), }) : preferredSize = Size.fromHeight(kToolbarHeight),
super(key: key); super(key: key);
@@ -81,51 +101,61 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
leading: showBackButton leading: leading ??
? StreamBackButton( (showBackButton
cid: StreamChannel.of(context).channel.cid, ? StreamBackButton(
onPressed: onBackPressed, cid: StreamChannel.of(context).channel.cid,
showUnreads: true, onPressed: onBackPressed,
) showUnreads: true,
: SizedBox(), )
: SizedBox()),
backgroundColor: backgroundColor:
StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color,
centerTitle: true, centerTitle: true,
title: Column( actions: actions,
crossAxisAlignment: CrossAxisAlignment.center, title: InkWell(
mainAxisAlignment: MainAxisAlignment.center, onTap: onTitleTap,
children: [ child: Container(
Text( height: preferredSize.height,
'Thread Reply', child: Column(
style: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.title,
),
SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( title ??
'with ', Text(
style: StreamChatTheme.of(context) 'Thread Reply',
.channelTheme style: StreamChatTheme.of(context)
.channelHeaderTheme .channelTheme
.lastMessageAt, .channelHeaderTheme
), .title,
Flexible( ),
child: ChannelName( SizedBox(height: 2),
textStyle: StreamChatTheme.of(context) subtitle ??
.channelTheme Row(
.channelHeaderTheme mainAxisSize: MainAxisSize.min,
.lastMessageAt, 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,
),
),
],
),
], ],
), ),
], ),
), ),
); );
} }
@@ -14,41 +14,43 @@ class UnreadIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = StreamChat.of(context).client; final client = StreamChat.of(context).client;
return StreamBuilder<int>( return IgnorePointer(
stream: cid != null child: StreamBuilder<int>(
? client.state.channels[cid].state.unreadCountStream stream: cid != null
: client.state.totalUnreadCountStream, ? client.state.channels[cid].state.unreadCountStream
initialData: cid != null : client.state.totalUnreadCountStream,
? client.state.channels[cid].state.unreadCount initialData: cid != null
: client.state.totalUnreadCount, ? client.state.channels[cid].state.unreadCount
builder: (context, snapshot) { : client.state.totalUnreadCount,
if (!snapshot.hasData || snapshot.data == 0) { builder: (context, snapshot) {
return SizedBox(); if (!snapshot.hasData || snapshot.data == 0) {
} return SizedBox();
return Material( }
borderRadius: BorderRadius.circular(8), return Material(
color: StreamChatTheme.of(context) borderRadius: BorderRadius.circular(8),
.channelPreviewTheme color: StreamChatTheme.of(context)
.unreadCounterColor, .channelPreviewTheme
child: Padding( .unreadCounterColor,
padding: const EdgeInsets.only( child: Padding(
left: 5.0, padding: const EdgeInsets.only(
right: 5.0, left: 5.0,
top: 2, right: 5.0,
bottom: 1, top: 2,
), bottom: 1,
child: Center( ),
child: Text( child: Center(
'${snapshot.data}', child: Text(
style: TextStyle( '${snapshot.data}',
fontSize: 11, style: TextStyle(
color: Colors.white, fontSize: 11,
color: Colors.white,
),
), ),
), ),
), ),
), );
); },
}, ),
); );
} }
} }
@@ -39,26 +39,29 @@ class UserAvatar extends StatelessWidget {
user.extraData['image'] != ''; user.extraData['image'] != '';
final streamChatTheme = StreamChatTheme.of(context); final streamChatTheme = StreamChatTheme.of(context);
Widget avatar = ClipRRect( Widget avatar = FittedBox(
clipBehavior: Clip.antiAlias, fit: BoxFit.cover,
borderRadius: borderRadius ?? child: ClipRRect(
streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, clipBehavior: Clip.antiAlias,
child: Container( borderRadius: borderRadius ??
constraints: constraints ?? streamChatTheme.ownMessageTheme.avatarTheme.borderRadius,
streamChatTheme.ownMessageTheme.avatarTheme.constraints, child: Container(
decoration: BoxDecoration( constraints: constraints ??
color: streamChatTheme.colorTheme.accentBlue, 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, alignment: onlineIndicatorAlignment,
child: Material( child: Material(
type: MaterialType.circle, type: MaterialType.circle,
color: streamChatTheme.colorTheme.white,
child: Container( child: Container(
margin: const EdgeInsets.all(2.0), margin: const EdgeInsets.all(2.0),
constraints: onlineIndicatorConstraints ?? constraints: onlineIndicatorConstraints ??
@@ -103,7 +107,6 @@ class UserAvatar extends StatelessWidget {
color: streamChatTheme.colorTheme.accentGreen, color: streamChatTheme.colorTheme.accentGreen,
), ),
), ),
color: streamChatTheme.colorTheme.white,
), ),
), ),
), ),
@@ -7,6 +7,7 @@ export 'src/channel_name.dart';
export 'src/channel_preview.dart'; export 'src/channel_preview.dart';
export 'src/date_divider.dart'; export 'src/date_divider.dart';
export 'src/deleted_message.dart'; export 'src/deleted_message.dart';
export 'src/message_action.dart';
export 'src/attachment/attachment.dart'; export 'src/attachment/attachment.dart';
export 'src/full_screen_media.dart'; export 'src/full_screen_media.dart';
export 'src/image_header.dart'; export 'src/image_header.dart';
@@ -172,11 +172,14 @@ class ChannelsBlocState extends State<ChannelsBloc>
})); }));
_subscriptions.add(client _subscriptions.add(client
.on(EventType.channelDeleted, EventType.notificationRemovedFromChannel) .on(
EventType.channelDeleted,
EventType.notificationRemovedFromChannel,
)
.listen((e) { .listen((e) {
final channel = e.channel; final channel = e.channel;
_channelsController _channelsController.add(List.from(
.add(List.from(channels..removeWhere((c) => c.cid == channel.cid))); (channels ?? [])..removeWhere((c) => c.cid == channel.cid)));
})); }));
} }
@@ -144,7 +144,7 @@ class _MessageListCoreState extends State<MessageListCore> {
return widget.errorWidgetBuilder(context, snapshot.error); return widget.errorWidgetBuilder(context, snapshot.error);
} else { } else {
final messageList = snapshot.data?.reversed?.toList() ?? []; final messageList = snapshot.data?.reversed?.toList() ?? [];
if (messageList.isEmpty) { if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) { if (_upToDate) {
return widget.emptyBuilder(context); return widget.emptyBuilder(context);
} }