Merge pull request #449 from GetStream/perf

fix: perf fixes
This commit is contained in:
Salvatore Giordano
2021-06-11 11:33:30 +02:00
committed by GitHub
37 changed files with 1416 additions and 1211 deletions
+11 -6
View File
@@ -70,8 +70,11 @@ class Channel {
true; true;
/// Returns true if the channel is muted as a stream /// Returns true if the channel is muted as a stream
Stream<bool>? get isMutedStream => _client.state.userStream.map((event) => Stream<bool>? get isMutedStream => _client.state.userStream
event!.channelMutes.any((element) => element.channel.cid == cid) == true); .map((event) =>
event!.channelMutes.any((element) => element.channel.cid == cid) ==
true)
.distinct();
/// True if the channel is a group /// True if the channel is a group
bool get isGroup => memberCount != 2; bool get isGroup => memberCount != 2;
@@ -1645,7 +1648,7 @@ class ChannelClientState {
/// Channel message list as a stream /// Channel message list as a stream
Stream<List<Message>?> get messagesStream => channelStateStream Stream<List<Message>?> get messagesStream => channelStateStream
.map((cs) => cs.messages) .map((cs) => cs.messages)
.distinct((prev, next) => const ListEquality().equals(prev, next)); .distinct(const ListEquality().equals);
/// Channel pinned message list /// Channel pinned message list
List<Message>? get pinnedMessages => _channelState.pinnedMessages.toList(); List<Message>? get pinnedMessages => _channelState.pinnedMessages.toList();
@@ -1675,7 +1678,7 @@ class ChannelClientState {
_channel.client.state.usersStream, _channel.client.state.usersStream,
(members, users) => (members, users) =>
members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(), members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(),
); ).distinct(const ListEquality().equals);
/// Channel watcher count /// Channel watcher count
int? get watcherCount => _channelState.watcherCount; int? get watcherCount => _channelState.watcherCount;
@@ -1706,7 +1709,7 @@ class ChannelClientState {
final BehaviorSubject<int> _unreadCountController = BehaviorSubject.seeded(0); final BehaviorSubject<int> _unreadCountController = BehaviorSubject.seeded(0);
/// Unread count getter as a stream /// Unread count getter as a stream
Stream<int> get unreadCountStream => _unreadCountController.stream; Stream<int> get unreadCountStream => _unreadCountController.stream.distinct();
/// Unread count getter /// Unread count getter
int? get unreadCount => _unreadCountController.value; int? get unreadCount => _unreadCountController.value;
@@ -1845,7 +1848,9 @@ class ChannelClientState {
List<User> get typingEvents => _typingEventsController.value; List<User> get typingEvents => _typingEventsController.value;
/// Channel related typing users stream /// Channel related typing users stream
Stream<List<User>> get typingEventsStream => _typingEventsController.stream; Stream<List<User>> get typingEventsStream =>
_typingEventsController.stream.distinct(const ListEquality().equals);
final BehaviorSubject<List<User>> _typingEventsController = final BehaviorSubject<List<User>> _typingEventsController =
BehaviorSubject.seeded([]); BehaviorSubject.seeded([]);
+6 -4
View File
@@ -213,13 +213,13 @@ class StreamChatClient {
_wsConnectionStatusController.add(status); _wsConnectionStatusController.add(status);
/// The current status value of the websocket connection /// The current status value of the websocket connection
ConnectionStatus? get wsConnectionStatus => ConnectionStatus get wsConnectionStatus =>
_wsConnectionStatusController.value; _wsConnectionStatusController.value;
/// This notifies the connection status of the websocket connection. /// This notifies the connection status of the websocket connection.
/// Listen to this to get notified when the websocket tries to reconnect. /// Listen to this to get notified when the websocket tries to reconnect.
Stream<ConnectionStatus> get wsConnectionStatusStream => Stream<ConnectionStatus> get wsConnectionStatusStream =>
_wsConnectionStatusController.stream; _wsConnectionStatusController.stream.distinct();
/// The current user token /// The current user token
String? token; String? token;
@@ -1532,13 +1532,15 @@ class ClientState {
int? get unreadChannels => _unreadChannelsController.valueOrNull; int? get unreadChannels => _unreadChannelsController.valueOrNull;
/// The current unread channels count as a stream /// The current unread channels count as a stream
Stream<int?> get unreadChannelsStream => _unreadChannelsController.stream; Stream<int?> get unreadChannelsStream =>
_unreadChannelsController.stream.distinct();
/// The current total unread messages count /// The current total unread messages count
int? get totalUnreadCount => _totalUnreadCountController.valueOrNull; int? get totalUnreadCount => _totalUnreadCountController.valueOrNull;
/// The current total unread messages count as a stream /// The current total unread messages count as a stream
Stream<int?> get totalUnreadCountStream => _totalUnreadCountController.stream; Stream<int?> get totalUnreadCountStream =>
_totalUnreadCountController.stream.distinct();
/// The current list of channels in memory as a stream /// The current list of channels in memory as a stream
Stream<Map<String?, Channel>?> get channelsStream => Stream<Map<String?, Channel>?> get channelsStream =>
@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/src/models/user.dart';
@@ -6,7 +7,7 @@ part 'member.g.dart';
/// The class that contains the information about the user membership /// The class that contains the information about the user membership
/// in a channel /// in a channel
@JsonSerializable() @JsonSerializable()
class Member { class Member extends Equatable {
/// Constructor used for json serialization /// Constructor used for json serialization
Member({ Member({
this.user, this.user,
@@ -98,4 +99,19 @@ class Member {
/// Serialize to json /// Serialize to json
Map<String, dynamic> toJson() => _$MemberToJson(this); Map<String, dynamic> toJson() => _$MemberToJson(this);
@override
List<Object?> get props => [
user,
inviteAcceptedAt,
inviteRejectedAt,
invited,
role,
userId,
isModerator,
banned,
shadowBanned,
createdAt,
updatedAt,
];
} }
+15 -1
View File
@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/serialization.dart'; import 'package:stream_chat/src/models/serialization.dart';
@@ -5,7 +6,7 @@ part 'user.g.dart';
/// The class that defines the user model /// The class that defines the user model
@JsonSerializable() @JsonSerializable()
class User { class User extends Equatable {
/// Constructor used for json serialization /// Constructor used for json serialization
User({ User({
required this.id, required this.id,
@@ -125,4 +126,17 @@ class User {
banned: banned ?? this.banned, banned: banned ?? this.banned,
teams: teams ?? this.teams, teams: teams ?? this.teams,
); );
@override
List<Object?> get props => [
id,
role,
teams,
createdAt,
updatedAt,
lastActive,
online,
banned,
extraData,
];
} }
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/src/utils.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.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/upload_progress_indicator.dart';
// ignore: always_use_package_imports // ignore: always_use_package_imports
import 'attachment_widget.dart'; import 'attachment_widget.dart';
@@ -103,7 +103,7 @@ class FileAttachment extends AttachmentWidget {
Widget _getFileTypeImage(BuildContext context) { Widget _getFileTypeImage(BuildContext context) {
if (isImageAttachment) { if (isImageAttachment) {
return Material( return Material(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
type: MaterialType.transparency, type: MaterialType.transparency,
shape: _getDefaultShape(context), shape: _getDefaultShape(context),
child: source.when( child: source.when(
@@ -154,7 +154,7 @@ class FileAttachment extends AttachmentWidget {
if (isVideoAttachment) { if (isVideoAttachment) {
return Material( return Material(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
type: MaterialType.transparency, type: MaterialType.transparency,
shape: _getDefaultShape(context), shape: _getDefaultShape(context),
child: source.when( child: source.when(
@@ -53,7 +53,7 @@ class GiphyAttachment extends AttachmentWidget {
Card( Card(
color: StreamChatTheme.of(context).colorTheme.white, color: StreamChatTheme.of(context).colorTheme.white,
elevation: 2, elevation: 2,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topRight: Radius.circular(16), topRight: Radius.circular(16),
@@ -74,16 +74,16 @@ class ImageAttachment extends AttachmentWidget {
if (imageUri.host == 'stream-io-cdn.com') { if (imageUri.host == 'stream-io-cdn.com') {
imageUri = imageUri.replace(queryParameters: { imageUri = imageUri.replace(queryParameters: {
...imageUri.queryParameters, ...imageUri.queryParameters,
'h': '500', 'h': '400',
'w': '500', 'w': '400',
'crop': 'center', 'crop': 'center',
'resize': 'crop', 'resize': 'crop',
}); });
} else if (imageUri.host == 'stream-cloud-uploads.imgix.net') { } else if (imageUri.host == 'stream-cloud-uploads.imgix.net') {
imageUri = imageUri.replace(queryParameters: { imageUri = imageUri.replace(queryParameters: {
...imageUri.queryParameters, ...imageUri.queryParameters,
'height': '500', 'height': '400',
'width': '500', 'width': '400',
'fit': 'crop', 'fit': 'crop',
}); });
} }
@@ -92,10 +92,10 @@ class ImageAttachment extends AttachmentWidget {
return _buildImageAttachment( return _buildImageAttachment(
context, context,
CachedNetworkImage( CachedNetworkImage(
cacheKey: imageUri.path, cacheKey: imageUrl,
height: size?.height, height: size?.height,
width: size?.width, width: size?.width,
placeholder: (_, __) { placeholder: (context, __) {
final image = Image.asset( final image = Image.asset(
'images/placeholder.png', 'images/placeholder.png',
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -133,8 +133,7 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
} }
return InfoTile( return InfoTile(
// ignore: avoid_bool_literals_in_conditional_expressions showMessage: showConnectionStateTile && showStatus,
showMessage: showConnectionStateTile ? showStatus : false,
message: statusString, message: statusString,
child: AppBar( child: AppBar(
textTheme: Theme.of(context).textTheme, textTheme: Theme.of(context).textTheme,
@@ -83,26 +83,28 @@ class ChannelImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final streamChat = StreamChat.of(context); final streamChat = StreamChat.of(context);
final channel = this.channel ?? StreamChannel.of(context).channel; final channel = this.channel ?? StreamChannel.of(context).channel;
return StreamBuilder<Map<String, dynamic>>( return BetterStreamBuilder<Map<String, dynamic>>(
stream: channel.extraDataStream, stream: channel.extraDataStream,
initialData: channel.extraData, initialData: channel.extraData,
builder: (context, snapshot) { builder: (context, data) {
String? image; String? image;
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
if (snapshot.data!.containsKey('image') == true) { if (data.containsKey('image') == true) {
image = snapshot.data!['image']; image = data['image'];
} else if (channel.state?.members.length == 2) { } else if (channel.state?.members.length == 2) {
final otherMember = channel.state?.members final otherMember = channel.state?.members
.firstWhere((member) => member.user?.id != streamChat.user?.id); .firstWhere((member) => member.user?.id != streamChat.user?.id);
return StreamBuilder<User>( return BetterStreamBuilder<User?>(
stream: streamChat.client.state.usersStream.map( stream: streamChat.client.state.usersStream
(users) => users[otherMember?.userId] ?? otherMember!.user!), .map((users) =>
users[otherMember?.userId] ?? otherMember!.user!)
.distinct(),
initialData: otherMember!.user, initialData: otherMember!.user,
builder: (context, snapshot) => UserAvatar( builder: (context, user) => UserAvatar(
borderRadius: borderRadius ?? borderRadius: borderRadius ??
chatThemeData chatThemeData
.channelPreviewTheme.avatarTheme?.borderRadius, .channelPreviewTheme.avatarTheme?.borderRadius,
user: snapshot.data ?? otherMember.user!, user: user ?? otherMember.user!,
constraints: constraints ?? constraints: constraints ??
chatThemeData chatThemeData
.channelPreviewTheme.avatarTheme?.constraints, .channelPreviewTheme.avatarTheme?.constraints,
@@ -153,9 +155,7 @@ class ChannelImage extends StatelessWidget {
imageUrl: image, imageUrl: image,
errorWidget: (_, __, ___) => Center( errorWidget: (_, __, ___) => Center(
child: Text( child: Text(
snapshot.data?.containsKey('name') ?? false data.containsKey('name') ? data['name'][0] : '',
? snapshot.data!['name'][0]
: '',
style: TextStyle( style: TextStyle(
color: chatThemeData.colorTheme.white, color: chatThemeData.colorTheme.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -25,14 +25,14 @@ class ChannelInfo 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<List<Member>>( return BetterStreamBuilder<List<Member>>(
stream: channel.state?.membersStream, stream: channel.state!.membersStream,
initialData: channel.state?.members, initialData: channel.state!.members,
builder: (context, snapshot) => ConnectionStatusBuilder( builder: (context, data) => ConnectionStatusBuilder(
statusBuilder: (context, status) { statusBuilder: (context, status) {
switch (status) { switch (status) {
case ConnectionStatus.connected: case ConnectionStatus.connected:
return _buildConnectedTitleState(context, snapshot.data); return _buildConnectedTitleState(context, data);
case ConnectionStatus.connecting: case ConnectionStatus.connecting:
return _buildConnectingTitleState(context); return _buildConnectingTitleState(context);
case ConnectionStatus.disconnected: case ConnectionStatus.disconnected:
@@ -1,11 +1,10 @@
import 'package:collection/collection.dart' show IterableExtension; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/channel_bottom_sheet.dart'; import 'package:stream_chat_flutter/src/channel_bottom_sheet.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/utils.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';
@@ -245,10 +244,7 @@ class _ChannelListViewState extends State<ChannelListView> {
} }
} }
return AnimatedSwitcher( return child;
duration: const Duration(milliseconds: 500),
child: child,
);
} }
Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder( Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder(
@@ -465,17 +461,17 @@ class _ChannelListViewState extends State<ChannelListView> {
Widget _listItemBuilder(BuildContext context, int i, List<Channel> channels) { Widget _listItemBuilder(BuildContext context, int i, List<Channel> channels) {
final channelsBloc = ChannelsBloc.of(context); final channelsBloc = ChannelsBloc.of(context);
if (i < channels.length) {
final channel = channels[i];
final onTap = _getChannelTap(context); final onTap = _getChannelTap(context);
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
final backgroundColor = chatThemeData.colorTheme.whiteSmoke; final backgroundColor = chatThemeData.colorTheme.whiteSmoke;
if (i < channels.length) {
final channel = channels[i];
return StreamChannel( return StreamChannel(
key: ValueKey<String>('CHANNEL-${channel.id}'), key: ValueKey<String>('CHANNEL-${channel.cid}'),
channel: channel, channel: channel,
child: Builder( child: Slidable(
builder: (context) => Slidable(
controller: _slideController, controller: _slideController,
enabled: widget.swipeToAction, enabled: widget.swipeToAction,
actionPane: const SlidableBehindActionPane(), actionPane: const SlidableBehindActionPane(),
@@ -553,8 +549,10 @@ class _ChannelListViewState extends State<ChannelListView> {
}, },
), ),
], ],
child: Container( child: DecoratedBox(
decoration: BoxDecoration(
color: chatThemeData.colorTheme.whiteSnow, color: chatThemeData.colorTheme.whiteSnow,
),
child: widget.channelPreviewBuilder?.call(context, channel) ?? child: widget.channelPreviewBuilder?.call(context, channel) ??
ChannelPreview( ChannelPreview(
onLongPress: widget.onChannelLongPress, onLongPress: widget.onChannelLongPress,
@@ -564,7 +562,6 @@ class _ChannelListViewState extends State<ChannelListView> {
), ),
), ),
), ),
),
); );
} else { } else {
return _buildQueryProgressIndicator(context, channelsBloc); return _buildQueryProgressIndicator(context, channelsBloc);
@@ -636,12 +633,10 @@ class _ChannelListViewState extends State<ChannelListView> {
context, context,
ChannelsBlocState channelsProvider, ChannelsBlocState channelsProvider,
) => ) =>
StreamBuilder<bool>( BetterStreamBuilder<bool>(
stream: channelsProvider.queryChannelsLoading, stream: channelsProvider.queryChannelsLoading,
initialData: false, initialData: false,
builder: (context, snapshot) { errorBuilder: (context, err) => Container(
if (snapshot.hasError) {
return Container(
color: StreamChatTheme.of(context) color: StreamChatTheme.of(context)
.colorTheme .colorTheme
.accentRed .accentRed
@@ -652,17 +647,15 @@ class _ChannelListViewState extends State<ChannelListView> {
child: Text('Error loading channels'), child: Text('Error loading channels'),
), ),
), ),
); ),
} builder: (context, data) => data
return snapshot.data!
? const Center( ? const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) )
: const Offstage(); : const Offstage());
});
Widget _separatorBuilder(context, i) { Widget _separatorBuilder(context, i) {
final effect = StreamChatTheme.of(context).colorTheme.borderBottom; final effect = StreamChatTheme.of(context).colorTheme.borderBottom;
@@ -26,11 +26,11 @@ class ChannelName extends StatelessWidget {
final client = StreamChat.of(context); final client = StreamChat.of(context);
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
return StreamBuilder<Map<String, dynamic>>( return BetterStreamBuilder<Map<String, Object?>>(
stream: channel.extraDataStream, stream: channel.extraDataStream,
initialData: channel.extraData, initialData: channel.extraData,
builder: (context, snapshot) => _buildName( builder: (context, data) => _buildName(
snapshot.data!, data,
channel.state?.members, channel.state?.members,
client, client,
), ),
@@ -1,4 +1,5 @@
import 'package:collection/collection.dart' show IterableExtension; import 'package:collection/collection.dart'
show IterableExtension, ListEquality;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
@@ -69,12 +70,12 @@ class ChannelPreview extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme; final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme;
final streamChatState = StreamChat.of(context); final streamChatState = StreamChat.of(context);
return BetterStreamBuilder<bool>(
return StreamBuilder<bool>(
stream: channel.isMutedStream, stream: channel.isMutedStream,
initialData: channel.isMuted, initialData: channel.isMuted,
builder: (context, snapshot) => Opacity( builder: (context, data) => AnimatedOpacity(
opacity: snapshot.data! ? 0.5 : 1, opacity: data ? 0.5 : 1,
duration: const Duration(milliseconds: 300),
child: ListTile( child: ListTile(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -103,14 +104,16 @@ class ChannelPreview extends StatelessWidget {
textStyle: channelPreviewTheme.title, textStyle: channelPreviewTheme.title,
), ),
), ),
StreamBuilder<List<Member>>( BetterStreamBuilder<List<Member>?>(
stream: channel.state?.membersStream, stream: channel.state?.membersStream,
initialData: channel.state?.members, initialData: channel.state?.members,
builder: (context, snapshot) { comparator: const ListEquality().equals,
if (!snapshot.hasData || builder: (context, members) {
snapshot.data!.isEmpty || if (members?.isEmpty == true ||
!snapshot.data!.any((Member e) => members?.any((Member e) =>
e.user!.id == channel.client.state.user?.id)) { e.user!.id ==
channel.client.state.user?.id) !=
true) {
return const SizedBox(); return const SizedBox();
} }
return UnreadIndicator( return UnreadIndicator(
@@ -159,14 +162,14 @@ class ChannelPreview extends StatelessWidget {
)); ));
} }
Widget _buildDate(BuildContext context) => StreamBuilder<DateTime?>( Widget _buildDate(BuildContext context) => BetterStreamBuilder<DateTime?>(
stream: channel.lastMessageAtStream, stream: channel.lastMessageAtStream,
initialData: channel.lastMessageAt, initialData: channel.lastMessageAt,
builder: (context, snapshot) { builder: (context, data) {
if (!snapshot.hasData) { if (data == null) {
return const SizedBox(); return const Offstage();
} }
final lastMessageAt = snapshot.data!.toLocal(); final lastMessageAt = data.toLocal();
String stringDate; String stringDate;
final now = DateTime.now(); final now = DateTime.now();
@@ -219,12 +222,12 @@ class ChannelPreview extends StatelessWidget {
} }
Widget _buildLastMessage(BuildContext context) => Widget _buildLastMessage(BuildContext context) =>
StreamBuilder<List<Message>?>( BetterStreamBuilder<List<Message>?>(
stream: channel.state!.messagesStream, stream: channel.state!.messagesStream,
initialData: channel.state!.messages, initialData: channel.state!.messages,
builder: (context, snapshot) { builder: (context, data) {
final lastMessage = snapshot.data final lastMessage =
?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); data?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted);
if (lastMessage == null) { if (lastMessage == null) {
return const SizedBox(); return const SizedBox();
} }
@@ -12,15 +12,11 @@ class ConnectionStatusBuilder extends StatelessWidget {
const ConnectionStatusBuilder({ const ConnectionStatusBuilder({
Key? key, Key? key,
required this.statusBuilder, required this.statusBuilder,
this.initialStatus = ConnectionStatus.disconnected,
this.connectionStatusStream, this.connectionStatusStream,
this.errorBuilder, this.errorBuilder,
this.loadingBuilder, this.loadingBuilder,
}) : super(key: key); }) : super(key: key);
/// The connection status that will be used to create the initial snapshot.
final ConnectionStatus initialStatus;
/// The asynchronous computation to which this builder is currently connected. /// The asynchronous computation to which this builder is currently connected.
final Stream<ConnectionStatus>? connectionStatusStream; final Stream<ConnectionStatus>? connectionStatusStream;
@@ -38,22 +34,18 @@ class ConnectionStatusBuilder extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final stream = connectionStatusStream ?? final stream = connectionStatusStream ??
StreamChat.of(context).client.wsConnectionStatusStream; StreamChat.of(context).client.wsConnectionStatusStream;
return StreamBuilder<ConnectionStatus>( final client = StreamChat.of(context).client;
initialData: initialStatus, return BetterStreamBuilder<ConnectionStatus>(
initialData: client.wsConnectionStatus,
stream: stream, stream: stream,
builder: (context, snapshot) { loadingBuilder: loadingBuilder,
if (snapshot.hasError) { errorBuilder: (context, error) {
if (errorBuilder != null) { if (errorBuilder != null) {
return errorBuilder!(context, snapshot.error); return errorBuilder!(context, error);
} }
return const Offstage(); return const Offstage();
}
if (!snapshot.hasData) {
if (loadingBuilder != null) return loadingBuilder!(context);
return const Offstage();
}
return statusBuilder(context, snapshot.data!);
}, },
builder: statusBuilder,
); );
} }
} }
@@ -114325,6 +114325,9 @@ class Emoji {
/// Get all Emojis /// Get all Emojis
static List<Emoji> all() => List.unmodifiable(_emojis); static List<Emoji> all() => List.unmodifiable(_emojis);
static Iterable<String> chars() =>
_emojis.map((e) => e.char).whereType<String>();
/// Returns Emoji by [char] and character /// Returns Emoji by [char] and character
static Emoji? byChar(String char) { static Emoji? byChar(String char) {
return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == char); return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == char);
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
final _emojis = Emoji.all(); final _emojiChars = Emoji.chars();
/// String extension /// String extension
extension StringExtension on String { extension StringExtension on String {
@@ -17,10 +17,10 @@ extension StringExtension on String {
/// 1 to 3 emojis: big size with no text bubble. /// 1 to 3 emojis: big size with no text bubble.
/// 4+ emojis or emojis+text: standard size with text bubble. /// 4+ emojis or emojis+text: standard size with text bubble.
bool get isOnlyEmoji { bool get isOnlyEmoji {
if (isEmpty) return false;
if (length > 3) return false;
final characters = trim().characters; final characters = trim().characters;
if (characters.isEmpty) return false; return characters.every(_emojiChars.contains);
if (characters.length > 3) return false;
return characters.every((c) => _emojis.map((e) => e.char).contains(c));
} }
} }
@@ -147,30 +147,8 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
widget.message.attachments.any((it) => it.type == 'file') == true; widget.message.attachments.any((it) => it.type == 'file') == true;
final streamChatThemeData = StreamChatTheme.of(context); final streamChatThemeData = StreamChatTheme.of(context);
return GestureDetector(
behavior: HitTestBehavior.translucent, final child = Center(
onTap: () => Navigator.maybePop(context),
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: Container(
color: streamChatThemeData.colorTheme.overlay,
),
),
),
if (_showActions)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, snapshot) => Transform.scale(
scale: val,
child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -180,17 +158,12 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
if (widget.showReactions && if (widget.showReactions &&
(widget.message.status == (widget.message.status == MessageSendingStatus.sent))
MessageSendingStatus.sent))
Align( Align(
alignment: Alignment( alignment: Alignment(
user?.id == widget.message.user?.id user?.id == widget.message.user?.id
? (divFactor >= 1.0 ? (divFactor >= 1.0 ? -0.2 : (1.2 - divFactor))
? -0.2 : (divFactor >= 1.0 ? 0.2 : -(1.2 - divFactor)),
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2
: -(1.2 - divFactor)),
0), 0),
child: ReactionPicker( child: ReactionPicker(
message: widget.message, message: widget.message,
@@ -203,8 +176,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
reverse: widget.reverse, reverse: widget.reverse,
attachmentBorderRadiusGeometry: widget attachmentBorderRadiusGeometry: widget
.attachmentBorderRadiusGeometry .attachmentBorderRadiusGeometry
?.mirrorBorderIfReversed( ?.mirrorBorderIfReversed(reverse: !widget.reverse),
reverse: !widget.reverse),
message: widget.message.copyWith( message: widget.message.copyWith(
text: widget.message.text!.length > 200 text: widget.message.text!.length > 200
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
@@ -224,13 +196,10 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
textPadding: EdgeInsets.symmetric( textPadding: EdgeInsets.symmetric(
vertical: 8, vertical: 8,
horizontal: horizontal: widget.message.text!.isOnlyEmoji ? 0 : 16.0,
widget.message.text!.isOnlyEmoji ? 0 : 16.0,
), ),
showReactionPickerIndicator: showReactionPickerIndicator: widget.showReactions &&
widget.showReactions && (widget.message.status == MessageSendingStatus.sent),
(widget.message.status ==
MessageSendingStatus.sent),
showSendingIndicator: false, showSendingIndicator: false,
shape: widget.messageShape, shape: widget.messageShape,
attachmentShape: widget.attachmentShape, attachmentShape: widget.attachmentShape,
@@ -252,12 +221,10 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.stretch,
CrossAxisAlignment.stretch,
children: [ children: [
if (widget.showReplyMessage && if (widget.showReplyMessage &&
widget.message.status == widget.message.status == MessageSendingStatus.sent)
MessageSendingStatus.sent)
_buildReplyButton(context), _buildReplyButton(context),
if (widget.showThreadReplyMessage && if (widget.showThreadReplyMessage &&
(widget.message.status == (widget.message.status ==
@@ -266,14 +233,10 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
_buildThreadReplyButton(context), _buildThreadReplyButton(context),
if (widget.showResendMessage) if (widget.showResendMessage)
_buildResendMessage(context), _buildResendMessage(context),
if (widget.showEditMessage) if (widget.showEditMessage) _buildEditMessage(context),
_buildEditMessage(context), if (widget.showCopyMessage) _buildCopyButton(context),
if (widget.showCopyMessage) if (widget.showFlagButton) _buildFlagButton(context),
_buildCopyButton(context), if (widget.showPinButton) _buildPinButton(context),
if (widget.showFlagButton)
_buildFlagButton(context),
if (widget.showPinButton)
_buildPinButton(context),
if (widget.showDeleteMessage) if (widget.showDeleteMessage)
_buildDeleteButton(context), _buildDeleteButton(context),
...widget.customActions ...widget.customActions
@@ -284,8 +247,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
].insertBetween( ].insertBetween(
Container( Container(
height: 1, height: 1,
color: streamChatThemeData color: streamChatThemeData.colorTheme.greyWhisper,
.colorTheme.greyWhisper,
), ),
), ),
), ),
@@ -296,9 +258,35 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
), ),
), ),
), ),
);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Navigator.maybePop(context),
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: Container(
color: streamChatThemeData.colorTheme.overlay,
), ),
), ),
), ),
if (_showActions)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
], ],
), ),
); );
@@ -268,6 +268,8 @@ class MessageInputState extends State<MessageInput> {
/// The editing controller passed to the input TextField /// The editing controller passed to the input TextField
late final TextEditingController textEditingController; late final TextEditingController textEditingController;
late StreamChatThemeData _streamChatTheme;
bool get _hasQuotedMessage => widget.quotedMessage != null; bool get _hasQuotedMessage => widget.quotedMessage != null;
@override @override
@@ -305,9 +307,10 @@ class MessageInputState extends State<MessageInput> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final streamChatThemeData = StreamChatTheme.of(context); Widget child = DecoratedBox(
Widget child = Container( decoration: BoxDecoration(
color: streamChatThemeData.messageInputTheme.inputBackground, color: _streamChatTheme.messageInputTheme.inputBackground,
),
child: SafeArea( child: SafeArea(
child: GestureDetector( child: GestureDetector(
onPanUpdate: (details) { onPanUpdate: (details) {
@@ -332,7 +335,7 @@ class MessageInputState extends State<MessageInput> {
Padding( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: StreamSvgIcon.reply( child: StreamSvgIcon.reply(
color: streamChatThemeData.colorTheme.greyGainsboro, color: _streamChatTheme.colorTheme.greyGainsboro,
), ),
), ),
const Text( const Text(
@@ -390,9 +393,7 @@ class MessageInputState extends State<MessageInput> {
], ],
); );
Widget _buildDmCheckbox() { Widget _buildDmCheckbox() => Row(
final streamChatThemeData = StreamChatTheme.of(context);
return Row(
children: [ children: [
Container( Container(
height: 16, height: 16,
@@ -401,7 +402,7 @@ class MessageInputState extends State<MessageInput> {
border: _sendAsDm border: _sendAsDm
? null ? null
: Border.all( : Border.all(
color: streamChatThemeData.colorTheme.black.withOpacity(.5), color: _streamChatTheme.colorTheme.black.withOpacity(.5),
width: 2, width: 2,
), ),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -410,8 +411,8 @@ class MessageInputState extends State<MessageInput> {
child: Material( child: Material(
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
color: _sendAsDm color: _sendAsDm
? streamChatThemeData.colorTheme.accentBlue ? _streamChatTheme.colorTheme.accentBlue
: streamChatThemeData.colorTheme.white, : _streamChatTheme.colorTheme.white,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
setState(() { setState(() {
@@ -426,7 +427,7 @@ class MessageInputState extends State<MessageInput> {
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
firstChild: StreamSvgIcon.check( firstChild: StreamSvgIcon.check(
size: 16, size: 16,
color: streamChatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
), ),
secondChild: const SizedBox( secondChild: const SizedBox(
height: 16, height: 16,
@@ -441,14 +442,13 @@ class MessageInputState extends State<MessageInput> {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
'Also send as direct message', 'Also send as direct message',
style: streamChatThemeData.textTheme.footnote.copyWith( style: _streamChatTheme.textTheme.footnote.copyWith(
color: streamChatThemeData.colorTheme.black.withOpacity(0.5), color: _streamChatTheme.colorTheme.black.withOpacity(0.5),
), ),
), ),
), ),
], ],
); );
}
Widget _animateSendButton(BuildContext context) { Widget _animateSendButton(BuildContext context) {
final sendButton = widget.activeSendButton != null final sendButton = widget.activeSendButton != null
@@ -463,8 +463,7 @@ class MessageInputState extends State<MessageInput> {
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
firstChild: sendButton, firstChild: sendButton,
secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), secondChild: widget.idleSendButton ?? _buildIdleSendButton(context),
duration: duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!,
StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration!,
alignment: Alignment.center, alignment: Alignment.center,
); );
} }
@@ -478,16 +477,18 @@ class MessageInputState extends State<MessageInput> {
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
firstChild: IconButton( firstChild: IconButton(
onPressed: () => setState(() => _actionsShrunk = false), onPressed: () {
if (_actionsShrunk) {
setState(() => _actionsShrunk = false);
}
},
icon: Transform.rotate( icon: Transform.rotate(
angle: (widget.actionsLocation == ActionsLocation.right || angle: (widget.actionsLocation == ActionsLocation.right ||
widget.actionsLocation == ActionsLocation.rightInside) widget.actionsLocation == ActionsLocation.rightInside)
? pi ? pi
: 0, : 0,
child: StreamSvgIcon.emptyCircleLeft( child: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context) color: _streamChatTheme.messageInputTheme.expandButtonColor,
.messageInputTheme
.expandButtonColor,
), ),
), ),
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
@@ -522,7 +523,6 @@ class MessageInputState extends State<MessageInput> {
} }
Expanded _buildTextInput(BuildContext context) { Expanded _buildTextInput(BuildContext context) {
final theme = StreamChatTheme.of(context);
final margin = (widget.sendButtonLocation == SendButtonLocation.inside final margin = (widget.sendButtonLocation == SendButtonLocation.inside
? const EdgeInsets.only(right: 8) ? const EdgeInsets.only(right: 8)
: EdgeInsets.zero) + : EdgeInsets.zero) +
@@ -530,23 +530,21 @@ class MessageInputState extends State<MessageInput> {
? const EdgeInsets.only(left: 8) ? const EdgeInsets.only(left: 8)
: EdgeInsets.zero); : EdgeInsets.zero);
return Expanded( return Expanded(
child: Center(
child: Container( child: Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
margin: margin, margin: margin,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: theme.messageInputTheme.borderRadius, borderRadius: _streamChatTheme.messageInputTheme.borderRadius,
gradient: _focusNode.hasFocus gradient: _focusNode.hasFocus
? theme.messageInputTheme.activeBorderGradient ? _streamChatTheme.messageInputTheme.activeBorderGradient
: theme.messageInputTheme.idleBorderGradient, : _streamChatTheme.messageInputTheme.idleBorderGradient,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(1.5), padding: const EdgeInsets.all(1.5),
child: Container( child: DecoratedBox(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: theme.messageInputTheme.borderRadius, borderRadius: _streamChatTheme.messageInputTheme.borderRadius,
color: theme.messageInputTheme.inputBackground, color: _streamChatTheme.messageInputTheme.inputBackground,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -564,7 +562,7 @@ class MessageInputState extends State<MessageInput> {
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
controller: textEditingController, controller: textEditingController,
focusNode: _focusNode, focusNode: _focusNode,
style: theme.messageInputTheme.inputTextStyle, style: _streamChatTheme.messageInputTheme.inputTextStyle,
autofocus: widget.autofocus, autofocus: widget.autofocus,
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
decoration: _getInputDecoration(), decoration: _getInputDecoration(),
@@ -576,18 +574,16 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
), ),
),
); );
} }
InputDecoration _getInputDecoration() { InputDecoration _getInputDecoration() {
final theme = StreamChatTheme.of(context); final passedDecoration = _streamChatTheme.messageInputTheme.inputDecoration;
final passedDecoration = theme.messageInputTheme.inputDecoration;
return InputDecoration( return InputDecoration(
isDense: true, isDense: true,
hintText: _getHint(), hintText: _getHint(),
hintStyle: theme.messageInputTheme.inputTextStyle!.copyWith( hintStyle: _streamChatTheme.messageInputTheme.inputTextStyle!.copyWith(
color: theme.colorTheme.grey, color: _streamChatTheme.colorTheme.grey,
), ),
border: const OutlineInputBorder( border: const OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
@@ -625,7 +621,7 @@ class MessageInputState extends State<MessageInput> {
constraints: BoxConstraints.tight(const Size(64, 24)), constraints: BoxConstraints.tight(const Size(64, 24)),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: theme.colorTheme.accentBlue, color: _streamChatTheme.colorTheme.accentBlue,
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Row( child: Row(
@@ -637,7 +633,8 @@ class MessageInputState extends State<MessageInput> {
), ),
Text( Text(
_chosenCommand?.name.toUpperCase() ?? '', _chosenCommand?.name.toUpperCase() ?? '',
style: theme.textTheme.footnoteBold.copyWith( style:
_streamChatTheme.textTheme.footnoteBold.copyWith(
color: Colors.white, color: Colors.white,
), ),
), ),
@@ -828,33 +825,19 @@ class MessageInputState extends State<MessageInput> {
final renderBox = context.findRenderObject() as RenderBox; final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size; final size = renderBox.size;
return OverlayEntry( final child = Padding(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, wid) {
final streamChatThemeData = StreamChatTheme.of(context);
return Transform.scale(
scale: val,
child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Card( child: Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
color: streamChatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
child: Container( child: Container(
constraints: BoxConstraints.loose( constraints: BoxConstraints.loose(const Size.fromHeight(400)),
const Size.fromHeight(400)),
decoration: BoxDecoration( decoration: BoxDecoration(
color: streamChatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
borderRadius: BorderRadius.circular(8)), borderRadius: BorderRadius.circular(8)),
child: ListView( child: ListView(
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
@@ -870,16 +853,14 @@ class MessageInputState extends State<MessageInput> {
horizontal: 8, horizontal: 8,
), ),
child: StreamSvgIcon.lightning( child: StreamSvgIcon.lightning(
color: streamChatThemeData color: _streamChatTheme.colorTheme.accentBlue,
.colorTheme.accentBlue,
), ),
), ),
Text( Text(
'Instant Commands', 'Instant Commands',
style: TextStyle( style: TextStyle(
color: streamChatThemeData color:
.colorTheme.black _streamChatTheme.colorTheme.black.withOpacity(.5),
.withOpacity(.5),
), ),
) )
], ],
@@ -909,17 +890,14 @@ class MessageInputState extends State<MessageInput> {
TextSpan( TextSpan(
text: c.name.capitalize(), text: c.name.capitalize(),
style: const TextStyle( style: const TextStyle(
fontWeight: fontWeight: FontWeight.bold),
FontWeight.bold),
children: [ children: [
TextSpan( TextSpan(
text: text: ' /${c.name} ${c.args}',
' /${c.name} ${c.args}', style: _streamChatTheme.textTheme.body
style: streamChatThemeData
.textTheme.body
.copyWith( .copyWith(
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
color: streamChatThemeData color: _streamChatTheme
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
.colorTheme .colorTheme
.grey, .grey,
@@ -938,19 +916,35 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
), ),
),
); );
}), return OverlayEntry(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
)); ));
} }
Widget _buildFilePickerSection() { Widget _buildFilePickerSection() {
if (!_openFilePickerSection) {
return const Offstage();
}
final _attachmentContainsFile = final _attachmentContainsFile =
_attachments.values.any((it) => it.type == 'file'); _attachments.values.any((it) => it.type == 'file');
final chatThemeData = StreamChatTheme.of(context);
Color _getIconColor(int index) { Color _getIconColor(int index) {
final streamChatThemeData = chatThemeData; final streamChatThemeData = _streamChatTheme;
switch (index) { switch (index) {
case 0: case 0:
return _attachments.isEmpty return _attachments.isEmpty
@@ -982,7 +976,7 @@ class MessageInputState extends State<MessageInput> {
_animateContainer ? const Duration(milliseconds: 300) : Duration.zero, _animateContainer ? const Duration(milliseconds: 300) : Duration.zero,
height: _openFilePickerSection ? _filePickerSize : 0, height: _openFilePickerSection ? _filePickerSize : 0,
child: Material( child: Material(
color: chatThemeData.colorTheme.whiteSmoke, color: _streamChatTheme.colorTheme.whiteSmoke,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1044,9 +1038,9 @@ class MessageInputState extends State<MessageInput> {
); );
}); });
}, },
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16), topLeft: Radius.circular(16),
topRight: Radius.circular(16), topRight: Radius.circular(16),
@@ -1057,11 +1051,12 @@ class MessageInputState extends State<MessageInput> {
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Container( child: SizedBox(
width: 40, width: 40,
height: 4, height: 4,
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: chatThemeData.colorTheme.whiteSmoke, color: _streamChatTheme.colorTheme.whiteSmoke,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@@ -1070,15 +1065,17 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
), ),
),
if (_openFilePickerSection) if (_openFilePickerSection)
Expanded( Expanded(
child: Container( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: _PickerWidget( child: _PickerWidget(
filePickerIndex: _filePickerIndex, filePickerIndex: _filePickerIndex,
streamChatTheme: _streamChatTheme,
containsFile: _attachmentContainsFile, containsFile: _attachmentContainsFile,
selectedMedias: _attachments.keys.toList(), selectedMedias: _attachments.keys.toList(),
onAddMoreFilesClick: pickFile, onAddMoreFilesClick: pickFile,
@@ -1150,7 +1147,6 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildCommandIcon(String iconType) { Widget _buildCommandIcon(String iconType) {
final chatThemeData = StreamChatTheme.of(context);
switch (iconType) { switch (iconType) {
case 'giphy': case 'giphy':
return CircleAvatar( return CircleAvatar(
@@ -1161,7 +1157,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'ban': case 'ban':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.iconUserDelete( child: StreamSvgIcon.iconUserDelete(
size: 16, size: 16,
@@ -1170,7 +1166,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'flag': case 'flag':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.flag( child: StreamSvgIcon.flag(
size: 14, size: 14,
@@ -1179,7 +1175,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'imgur': case 'imgur':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: ClipOval( child: ClipOval(
child: StreamSvgIcon.imgur( child: StreamSvgIcon.imgur(
@@ -1189,7 +1185,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'mute': case 'mute':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.mute( child: StreamSvgIcon.mute(
size: 16, size: 16,
@@ -1198,7 +1194,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'unban': case 'unban':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.userAdd( child: StreamSvgIcon.userAdd(
size: 16, size: 16,
@@ -1207,7 +1203,7 @@ class MessageInputState extends State<MessageInput> {
); );
case 'unmute': case 'unmute':
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.volumeUp( child: StreamSvgIcon.volumeUp(
size: 16, size: 16,
@@ -1216,7 +1212,7 @@ class MessageInputState extends State<MessageInput> {
); );
default: default:
return CircleAvatar( return CircleAvatar(
backgroundColor: chatThemeData.colorTheme.accentBlue, backgroundColor: _streamChatTheme.colorTheme.accentBlue,
radius: 12, radius: 12,
child: StreamSvgIcon.lightning( child: StreamSvgIcon.lightning(
size: 16, size: 16,
@@ -1253,32 +1249,18 @@ class MessageInputState extends State<MessageInput> {
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final renderBox = context.findRenderObject() as RenderBox; final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size; final size = renderBox.size;
final child = Card(
return OverlayEntry(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, wid) {
final chatThemeData = StreamChatTheme.of(context);
return Transform.scale(
scale: val,
child: Card(
margin: const EdgeInsets.all(8), margin: const EdgeInsets.all(8),
elevation: 2, elevation: 2,
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
child: Container( child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(240)), constraints: BoxConstraints.loose(const Size.fromHeight(240)),
decoration: BoxDecoration( decoration: BoxDecoration(
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
), ),
child: FutureBuilder<List<Member>>( child: FutureBuilder<List<Member>>(
future: queryMembers ?? Future.value(members), future: queryMembers ?? Future.value(members),
@@ -1294,7 +1276,7 @@ class MessageInputState extends State<MessageInput> {
.where((it) => it.user != null) .where((it) => it.user != null)
.map( .map(
(m) => Material( (m) => Material(
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (m.user != null) { if (m.user != null) {
@@ -1304,12 +1286,10 @@ class MessageInputState extends State<MessageInput> {
splits[splits.length - 1] = m.user!.name; splits[splits.length - 1] = m.user!.name;
final rejoin = splits.join('@'); final rejoin = splits.join('@');
textEditingController.value = textEditingController.value = TextEditingValue(
TextEditingValue(
text: rejoin + text: rejoin +
textEditingController.text.substring( textEditingController.text.substring(
textEditingController textEditingController.selection.start),
.selection.start),
selection: TextSelection.collapsed( selection: TextSelection.collapsed(
offset: rejoin.length, offset: rejoin.length,
), ),
@@ -1332,9 +1312,21 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
), ),
),
); );
}, return OverlayEntry(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
), ),
), ),
); );
@@ -1363,20 +1355,19 @@ class MessageInputState extends State<MessageInput> {
final renderBox = context.findRenderObject() as RenderBox; final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size; final size = renderBox.size;
return OverlayEntry(builder: (context) { return OverlayEntry(
final chatThemeData = StreamChatTheme.of(context); builder: (context) => Positioned(
return Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom, bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0, left: 0,
right: 0, right: 0,
child: Card( child: Card(
margin: const EdgeInsets.all(8), margin: const EdgeInsets.all(8),
elevation: 2, elevation: 2,
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
child: Container( child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(200)), constraints: BoxConstraints.loose(const Size.fromHeight(200)),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1387,7 +1378,7 @@ class MessageInputState extends State<MessageInput> {
offset: Offset(0, -4), offset: Offset(0, -4),
), ),
], ],
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
), ),
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
@@ -1400,16 +1391,18 @@ class MessageInputState extends State<MessageInput> {
child: Row( child: Row(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding:
const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile( child: StreamSvgIcon.smile(
color: chatThemeData.colorTheme.accentBlue, color:
_streamChatTheme.colorTheme.accentBlue,
), ),
), ),
Flexible( Flexible(
child: Text( child: Text(
'Emoji matching "$query"', 'Emoji matching "$query"',
style: TextStyle( style: TextStyle(
color: chatThemeData.colorTheme.black color: _streamChatTheme.colorTheme.black
.withOpacity(.5), .withOpacity(.5),
), ),
), ),
@@ -1443,8 +1436,7 @@ class MessageInputState extends State<MessageInput> {
}), }),
), ),
), ),
); ));
});
} }
void _chooseEmoji(List<String> splits, Emoji emoji) { void _chooseEmoji(List<String> splits, Emoji emoji) {
@@ -1483,7 +1475,7 @@ class MessageInputState extends State<MessageInput> {
reverse: true, reverse: true,
showBorder: !containsUrl, showBorder: !containsUrl,
message: widget.quotedMessage!, message: widget.quotedMessage!,
messageTheme: StreamChatTheme.of(context).otherMessageTheme, messageTheme: _streamChatTheme.otherMessageTheme,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
); );
} }
@@ -1568,9 +1560,7 @@ class MessageInputState extends State<MessageInput> {
); );
} }
Widget _buildRemoveButton(Attachment attachment) { Widget _buildRemoveButton(Attachment attachment) => SizedBox(
final chatThemeData = StreamChatTheme.of(context);
return SizedBox(
height: 24, height: 24,
width: 24, width: 24,
child: RawMaterialButton( child: RawMaterialButton(
@@ -1584,16 +1574,15 @@ class MessageInputState extends State<MessageInput> {
onPressed: () { onPressed: () {
setState(() => _attachments.remove(attachment.id)); setState(() => _attachments.remove(attachment.id));
}, },
fillColor: chatThemeData.colorTheme.black.withOpacity(.5), fillColor: _streamChatTheme.colorTheme.black.withOpacity(.5),
child: Center( child: Center(
child: StreamSvgIcon.close( child: StreamSvgIcon.close(
size: 24, size: 24,
color: chatThemeData.colorTheme.white, color: _streamChatTheme.colorTheme.white,
), ),
), ),
), ),
); );
}
Widget _buildAttachment(Attachment attachment) { Widget _buildAttachment(Attachment attachment) {
if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) ==
@@ -1623,18 +1612,15 @@ class MessageInputState extends State<MessageInput> {
fit: BoxFit.cover, fit: BoxFit.cover,
errorWidget: (_, obj, trace) => errorWidget: (_, obj, trace) =>
getFileTypeImage(attachment.extraData['other'] as String?), getFileTypeImage(attachment.extraData['other'] as String?),
progressIndicatorBuilder: (context, _, progress) { placeholder: (context, _) => Shimmer.fromColors(
final chatThemeData = StreamChatTheme.of(context); baseColor: _streamChatTheme.colorTheme.greyGainsboro,
return Shimmer.fromColors( highlightColor: _streamChatTheme.colorTheme.whiteSmoke,
baseColor: chatThemeData.colorTheme.greyGainsboro,
highlightColor: chatThemeData.colorTheme.whiteSmoke,
child: Image.asset( child: Image.asset(
'images/placeholder.png', 'images/placeholder.png',
fit: BoxFit.cover, fit: BoxFit.cover,
package: 'stream_chat_flutter', package: 'stream_chat_flutter',
), ),
); ),
},
); );
case 'video': case 'video':
return Stack( return Stack(
@@ -1666,14 +1652,13 @@ class MessageInputState extends State<MessageInput> {
Widget _buildCommandButton() { Widget _buildCommandButton() {
final s = textEditingController.text.trim(); final s = textEditingController.text.trim();
final chatThemeData = StreamChatTheme.of(context);
return IconButton( return IconButton(
icon: StreamSvgIcon.lightning( icon: StreamSvgIcon.lightning(
color: s.isNotEmpty color: s.isNotEmpty
? chatThemeData.colorTheme.greyGainsboro ? _streamChatTheme.colorTheme.greyGainsboro
: (_commandsOverlay != null : (_commandsOverlay != null
? chatThemeData.messageInputTheme.actionButtonColor ? _streamChatTheme.messageInputTheme.actionButtonColor
: chatThemeData.messageInputTheme.actionButtonIdleColor), : _streamChatTheme.messageInputTheme.actionButtonIdleColor),
), ),
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
constraints: const BoxConstraints.tightFor( constraints: const BoxConstraints.tightFor(
@@ -1708,13 +1693,11 @@ class MessageInputState extends State<MessageInput> {
); );
} }
Widget _buildAttachmentButton() { Widget _buildAttachmentButton() => IconButton(
final chatThemeData = StreamChatTheme.of(context);
return IconButton(
icon: StreamSvgIcon.attach( icon: StreamSvgIcon.attach(
color: _openFilePickerSection color: _openFilePickerSection
? chatThemeData.messageInputTheme.actionButtonColor ? _streamChatTheme.messageInputTheme.actionButtonColor
: chatThemeData.messageInputTheme.actionButtonIdleColor, : _streamChatTheme.messageInputTheme.actionButtonIdleColor,
), ),
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
constraints: const BoxConstraints.tightFor( constraints: const BoxConstraints.tightFor(
@@ -1741,7 +1724,6 @@ class MessageInputState extends State<MessageInput> {
} }
}, },
); );
}
/// Show the attachment modal, making the user choose where to /// Show the attachment modal, making the user choose where to
/// pick a media from /// pick a media from
@@ -1948,8 +1930,7 @@ class MessageInputState extends State<MessageInput> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: StreamSvgIcon( child: StreamSvgIcon(
assetName: _getIdleSendIcon(), assetName: _getIdleSendIcon(),
color: color: _streamChatTheme.messageInputTheme.sendButtonIdleColor,
StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor,
), ),
); );
@@ -1965,8 +1946,7 @@ class MessageInputState extends State<MessageInput> {
), ),
icon: StreamSvgIcon( icon: StreamSvgIcon(
assetName: _getSendIcon(), assetName: _getSendIcon(),
color: color: _streamChatTheme.messageInputTheme.sendButtonColor,
StreamChatTheme.of(context).messageInputTheme.sendButtonColor,
), ),
), ),
); );
@@ -2083,9 +2063,8 @@ class MessageInputState extends State<MessageInput> {
StreamSubscription? _keyboardListener; StreamSubscription? _keyboardListener;
void _showErrorAlert(String description) { void _showErrorAlert(String description) {
final chatThemeData = StreamChatTheme.of(context);
showModalBottomSheet( showModalBottomSheet(
backgroundColor: chatThemeData.colorTheme.white, backgroundColor: _streamChatTheme.colorTheme.white,
context: context, context: context,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
@@ -2099,7 +2078,7 @@ class MessageInputState extends State<MessageInput> {
height: 26, height: 26,
), ),
StreamSvgIcon.error( StreamSvgIcon.error(
color: chatThemeData.colorTheme.accentRed, color: _streamChatTheme.colorTheme.accentRed,
size: 24, size: 24,
), ),
const SizedBox( const SizedBox(
@@ -2107,7 +2086,7 @@ class MessageInputState extends State<MessageInput> {
), ),
Text( Text(
'Something went wrong', 'Something went wrong',
style: chatThemeData.textTheme.headlineBold, style: _streamChatTheme.textTheme.headlineBold,
), ),
const SizedBox( const SizedBox(
height: 7, height: 7,
@@ -2123,7 +2102,7 @@ class MessageInputState extends State<MessageInput> {
height: 36, height: 36,
), ),
Container( Container(
color: chatThemeData.colorTheme.black.withOpacity(.08), color: _streamChatTheme.colorTheme.black.withOpacity(.08),
height: 1, height: 1,
), ),
Row( Row(
@@ -2135,8 +2114,8 @@ class MessageInputState extends State<MessageInput> {
}, },
child: Text( child: Text(
'OK', 'OK',
style: chatThemeData.textTheme.bodyBold style: _streamChatTheme.textTheme.bodyBold
.copyWith(color: chatThemeData.colorTheme.accentBlue), .copyWith(color: _streamChatTheme.colorTheme.accentBlue),
), ),
), ),
], ],
@@ -2169,6 +2148,7 @@ class MessageInputState extends State<MessageInput> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context);
if (widget.editMessage != null && !_initialized) { if (widget.editMessage != null && !_initialized) {
FocusScope.of(context).requestFocus(_focusNode); FocusScope.of(context).requestFocus(_focusNode);
_initialized = true; _initialized = true;
@@ -2233,6 +2213,7 @@ class _PickerWidget extends StatefulWidget {
required this.selectedMedias, required this.selectedMedias,
required this.onAddMoreFilesClick, required this.onAddMoreFilesClick,
required this.onMediaSelected, required this.onMediaSelected,
required this.streamChatTheme,
}) : super(key: key); }) : super(key: key);
final int filePickerIndex; final int filePickerIndex;
@@ -2240,6 +2221,7 @@ class _PickerWidget extends StatefulWidget {
final List<String> selectedMedias; final List<String> selectedMedias;
final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; final void Function(DefaultAttachmentTypes) onAddMoreFilesClick;
final void Function(AssetEntity) onMediaSelected; final void Function(AssetEntity) onMediaSelected;
final StreamChatThemeData streamChatTheme;
@override @override
__PickerWidgetState createState() => __PickerWidgetState(); __PickerWidgetState createState() => __PickerWidgetState();
@@ -2266,7 +2248,6 @@ class __PickerWidgetState extends State<_PickerWidget> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final chatThemeData = StreamChatTheme.of(context);
if (snapshot.data!) { if (snapshot.data!) {
if (widget.containsFile) { if (widget.containsFile) {
return GestureDetector( return GestureDetector(
@@ -2275,12 +2256,12 @@ class __PickerWidgetState extends State<_PickerWidget> {
}, },
child: Container( child: Container(
constraints: const BoxConstraints.expand(), constraints: const BoxConstraints.expand(),
color: chatThemeData.colorTheme.whiteSmoke, color: widget.streamChatTheme.colorTheme.whiteSmoke,
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
'Add more files', 'Add more files',
style: TextStyle( style: TextStyle(
color: chatThemeData.colorTheme.accentBlue, color: widget.streamChatTheme.colorTheme.accentBlue,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -2298,7 +2279,7 @@ class __PickerWidgetState extends State<_PickerWidget> {
PhotoManager.openSetting(); PhotoManager.openSetting();
}, },
child: Container( child: Container(
color: chatThemeData.colorTheme.whiteSmoke, color: widget.streamChatTheme.colorTheme.whiteSmoke,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -2307,21 +2288,21 @@ class __PickerWidgetState extends State<_PickerWidget> {
'svgs/icon_picture_empty_state.svg', 'svgs/icon_picture_empty_state.svg',
package: 'stream_chat_flutter', package: 'stream_chat_flutter',
height: 140, height: 140,
color: chatThemeData.colorTheme.greyGainsboro, color: widget.streamChatTheme.colorTheme.greyGainsboro,
), ),
Text( Text(
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
'Please enable access to your photos \nand videos so you can share them with friends.', 'Please enable access to your photos \nand videos so you can share them with friends.',
style: chatThemeData.textTheme.body style: widget.streamChatTheme.textTheme.body.copyWith(
.copyWith(color: chatThemeData.colorTheme.grey), color: widget.streamChatTheme.colorTheme.grey),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Center( Center(
child: Text( child: Text(
'Allow access to your gallery', 'Allow access to your gallery',
style: chatThemeData.textTheme.bodyBold.copyWith( style: widget.streamChatTheme.textTheme.bodyBold.copyWith(
color: chatThemeData.colorTheme.accentBlue, color: widget.streamChatTheme.colorTheme.accentBlue,
), ),
), ),
), ),
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@@ -281,8 +282,10 @@ class _MessageListViewState extends State<MessageListView> {
void Function(Message)? _onThreadTap; void Function(Message)? _onThreadTap;
bool _showScrollToBottom = false; bool _showScrollToBottom = false;
late final ItemPositionsListener _itemPositionListener; late final ItemPositionsListener _itemPositionListener;
late final Stream<Iterable<ItemPosition>> _itemPositionStream;
int? _messageListLength; int? _messageListLength;
StreamChannelState? streamChannel; StreamChannelState? streamChannel;
late StreamChatThemeData _streamTheme;
int? get _initialIndex { int? get _initialIndex {
if (widget.initialScrollIndex != null) return widget.initialScrollIndex; if (widget.initialScrollIndex != null) return widget.initialScrollIndex;
@@ -324,9 +327,7 @@ class _MessageListViewState extends State<MessageListView> {
final MessageListController _messageListController = MessageListController(); final MessageListController _messageListController = MessageListController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => MessageListCore(
final chatThemeData = StreamChatTheme.of(context);
return MessageListCore(
messageFilter: widget.messageFilter, messageFilter: widget.messageFilter,
loadingBuilder: widget.loadingBuilder ?? loadingBuilder: widget.loadingBuilder ??
(context) => const Center( (context) => const Center(
@@ -336,12 +337,12 @@ class _MessageListViewState extends State<MessageListView> {
(context) => Center( (context) => Center(
child: Text( child: Text(
'No chats here yet...', 'No chats here yet...',
style: chatThemeData.textTheme.footnote.copyWith( style: _streamTheme.textTheme.footnote.copyWith(
color: chatThemeData.colorTheme.black.withOpacity(.5)), color: _streamTheme.colorTheme.black.withOpacity(.5)),
), ),
), ),
messageListBuilder: messageListBuilder: widget.messageListBuilder ??
widget.messageListBuilder ?? (context, list) => _buildListView(list), (context, list) => _buildListView(list),
messageListController: _messageListController, messageListController: _messageListController,
parentMessage: widget.parentMessage, parentMessage: widget.parentMessage,
showScrollToBottom: widget.showScrollToBottom, showScrollToBottom: widget.showScrollToBottom,
@@ -349,12 +350,11 @@ class _MessageListViewState extends State<MessageListView> {
(BuildContext context, Object error) => Center( (BuildContext context, Object error) => Center(
child: Text( child: Text(
'Something went wrong', 'Something went wrong',
style: chatThemeData.textTheme.footnote.copyWith( style: _streamTheme.textTheme.footnote.copyWith(
color: chatThemeData.colorTheme.black.withOpacity(.5)), color: _streamTheme.colorTheme.black.withOpacity(.5)),
), ),
), ),
); );
}
Widget _buildListView(List<Message> data) { Widget _buildListView(List<Message> data) {
messages = data; messages = data;
@@ -400,8 +400,7 @@ class _MessageListViewState extends State<MessageListView> {
} }
return InfoTile( return InfoTile(
// ignore: avoid_bool_literals_in_conditional_expressions showMessage: widget.showConnectionStateTile && showStatus,
showMessage: widget.showConnectionStateTile ? showStatus : false,
tileAnchor: Alignment.topCenter, tileAnchor: Alignment.topCenter,
childAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter,
message: statusString, message: statusString,
@@ -440,6 +439,7 @@ class _MessageListViewState extends State<MessageListView> {
physics: widget.scrollPhysics, physics: widget.scrollPhysics,
itemScrollController: _scrollController, itemScrollController: _scrollController,
reverse: true, reverse: true,
addAutomaticKeepAlives: false,
itemCount: itemCount:
messages.length + 2 + (_isThreadConversation ? 1 : 0), messages.length + 2 + (_isThreadConversation ? 1 : 0),
separatorBuilder: (context, i) { separatorBuilder: (context, i) {
@@ -447,10 +447,9 @@ class _MessageListViewState extends State<MessageListView> {
if (i == 0) return const SizedBox(height: 30); if (i == 0) return const SizedBox(height: 30);
if (i == messages.length + 1) { if (i == messages.length + 1) {
final replyCount = widget.parentMessage!.replyCount; final replyCount = widget.parentMessage!.replyCount;
final chatThemeData = StreamChatTheme.of(context);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: chatThemeData.colorTheme.bgGradient, gradient: _streamTheme.colorTheme.bgGradient,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -458,7 +457,7 @@ class _MessageListViewState extends State<MessageListView> {
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
'$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: chatThemeData style: _streamTheme
.channelTheme.channelHeaderTheme.subtitle, .channelTheme.channelHeaderTheme.subtitle,
), ),
), ),
@@ -570,9 +569,18 @@ class _MessageListViewState extends State<MessageListView> {
if (widget.showScrollToBottom) _buildScrollToBottom(), if (widget.showScrollToBottom) _buildScrollToBottom(),
Positioned( Positioned(
top: 20, top: 20,
child: ValueListenableBuilder<Iterable<ItemPosition>>( child: BetterStreamBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions, initialData: _itemPositionListener.itemPositions.value,
builder: (context, values, _) { stream: _itemPositionStream,
comparator: (a, b) {
if (a == null) {
return false;
}
final aTop = _getTopElement(a)?.index;
final bTop = _getTopElement(b)?.index;
return aTop == bTop;
},
builder: (context, values) {
final items = _itemPositionListener.itemPositions.value; final items = _itemPositionListener.itemPositions.value;
if (items.isEmpty || messages.isEmpty) { if (items.isEmpty || messages.isEmpty) {
return const SizedBox(); return const SizedBox();
@@ -620,8 +628,8 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>( Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2( stream: Rx.combineLatest2(
streamChannel!.channel.state!.isUpToDateStream, streamChannel!.channel.state!.isUpToDateStream.distinct(),
streamChannel!.channel.state!.unreadCountStream, streamChannel!.channel.state!.unreadCountStream.distinct(),
(bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount),
), ),
builder: (_, snapshot) { builder: (_, snapshot) {
@@ -639,7 +647,6 @@ class _MessageListViewState extends State<MessageListView> {
final showUnreadCount = unreadCount > 0 && final showUnreadCount = unreadCount > 0 &&
streamChannel!.channel.state!.members.any((e) => streamChannel!.channel.state!.members.any((e) =>
e.userId == streamChannel!.channel.client.state.user!.id); e.userId == streamChannel!.channel.client.state.user!.id);
final chatThemeData = StreamChatTheme.of(context);
return Positioned( return Positioned(
bottom: 8, bottom: 8,
right: 8, right: 8,
@@ -649,7 +656,7 @@ class _MessageListViewState extends State<MessageListView> {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
FloatingActionButton( FloatingActionButton(
backgroundColor: chatThemeData.colorTheme.white, backgroundColor: _streamTheme.colorTheme.white,
onPressed: () { onPressed: () {
if (unreadCount > 0) { if (unreadCount > 0) {
streamChannel!.channel.markRead(); streamChannel!.channel.markRead();
@@ -668,7 +675,7 @@ class _MessageListViewState extends State<MessageListView> {
} }
}, },
child: StreamSvgIcon.down( child: StreamSvgIcon.down(
color: chatThemeData.colorTheme.black, color: _streamTheme.colorTheme.black,
), ),
), ),
if (showUnreadCount) if (showUnreadCount)
@@ -699,44 +706,13 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildLoadingIndicator( Widget _buildLoadingIndicator(
StreamChannelState streamChannel, StreamChannelState streamChannel,
QueryDirection direction, QueryDirection direction,
) { ) =>
final stream = direction == QueryDirection.top _LoadingIndicator(
? streamChannel.queryTopMessages direction: direction,
: streamChannel.queryBottomMessages; streamTheme: _streamTheme,
return StreamBuilder<bool>( streamChannel: streamChannel,
key: const Key('LOADING-INDICATOR'), isThreadConversation: _isThreadConversation,
stream: stream,
initialData: false,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
color: StreamChatTheme.of(context)
.colorTheme
.accentRed
.withOpacity(.2),
child: const Center(
child: Text('Error loading messages'),
),
); );
}
if (!snapshot.data!) {
if (!_isThreadConversation && direction == QueryDirection.top) {
return const SizedBox(
height: 52,
width: double.infinity,
);
}
return const Offstage();
}
return const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
);
},
);
}
Widget _buildTopMessage( Widget _buildTopMessage(
BuildContext context, BuildContext context,
@@ -803,8 +779,10 @@ class _MessageListViewState extends State<MessageListView> {
} }
} }
if (mounted) { if (mounted) {
if (_showScrollToBottom == isVisible) {
setState(() => _showScrollToBottom = !isVisible); setState(() => _showScrollToBottom = !isVisible);
} }
}
}, },
child: messageWidget, child: messageWidget,
); );
@@ -820,7 +798,6 @@ class _MessageListViewState extends State<MessageListView> {
final currentUserMember = final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id); members.firstWhere((e) => e.user!.id == currentUser!.id);
final chatThemeData = StreamChatTheme.of(context);
return MessageWidget( return MessageWidget(
showReplyMessage: false, showReplyMessage: false,
showResendMessage: false, showResendMessage: false,
@@ -849,8 +826,8 @@ class _MessageListViewState extends State<MessageListView> {
borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null,
showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show,
messageTheme: isMyMessage messageTheme: isMyMessage
? chatThemeData.ownMessageTheme ? _streamTheme.ownMessageTheme
: chatThemeData.otherMessageTheme, : _streamTheme.otherMessageTheme,
onShowMessage: widget.onShowMessage, onShowMessage: widget.onShowMessage,
onReturnAction: (action) { onReturnAction: (action) {
switch (action) { switch (action) {
@@ -964,7 +941,6 @@ class _MessageListViewState extends State<MessageListView> {
final currentUserMember = final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id); members.firstWhere((e) => e.user!.id == currentUser!.id);
final chatThemeData = StreamChatTheme.of(context);
Widget child = MessageWidget( Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'), key: ValueKey<String>('MESSAGE-${message.id}'),
message: message, message: message,
@@ -1051,8 +1027,8 @@ class _MessageListViewState extends State<MessageListView> {
horizontal: isOnlyEmoji ? 0 : 16.0, horizontal: isOnlyEmoji ? 0 : 16.0,
), ),
messageTheme: isMyMessage messageTheme: isMyMessage
? chatThemeData.ownMessageTheme ? _streamTheme.ownMessageTheme
: chatThemeData.otherMessageTheme, : _streamTheme.otherMessageTheme,
readList: readList, readList: readList,
allRead: allRead, allRead: allRead,
onShowMessage: widget.onShowMessage, onShowMessage: widget.onShowMessage,
@@ -1093,7 +1069,7 @@ class _MessageListViewState extends State<MessageListView> {
widget.onMessageSwiped?.call(message); widget.onMessageSwiped?.call(message);
}, },
backgroundIcon: StreamSvgIcon.reply( backgroundIcon: StreamSvgIcon.reply(
color: chatThemeData.colorTheme.accentBlue, color: _streamTheme.colorTheme.accentBlue,
), ),
child: child, child: child,
), ),
@@ -1103,7 +1079,7 @@ class _MessageListViewState extends State<MessageListView> {
if (!initialMessageHighlightComplete && if (!initialMessageHighlightComplete &&
widget.highlightInitialMessage && widget.highlightInitialMessage &&
_isInitialMessage(message.id)) { _isInitialMessage(message.id)) {
final colorTheme = chatThemeData.colorTheme; final colorTheme = _streamTheme.colorTheme;
final highlightColor = final highlightColor =
widget.messageHighlightColor ?? colorTheme.highlight; widget.messageHighlightColor ?? colorTheme.highlight;
child = TweenAnimationBuilder<Color?>( child = TweenAnimationBuilder<Color?>(
@@ -1133,6 +1109,8 @@ class _MessageListViewState extends State<MessageListView> {
_scrollController = widget.scrollController ?? ItemScrollController(); _scrollController = widget.scrollController ?? ItemScrollController();
_itemPositionListener = _itemPositionListener =
widget.itemPositionListener ?? ItemPositionsListener.create(); widget.itemPositionListener ?? ItemPositionsListener.create();
_itemPositionStream =
_valueListenableToStreamAdapter(_itemPositionListener.itemPositions);
_getOnThreadTap(); _getOnThreadTap();
super.initState(); super.initState();
@@ -1141,6 +1119,7 @@ class _MessageListViewState extends State<MessageListView> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
final newStreamChannel = StreamChannel.of(context); final newStreamChannel = StreamChannel.of(context);
_streamTheme = StreamChatTheme.of(context);
if (newStreamChannel != streamChannel) { if (newStreamChannel != streamChannel) {
streamChannel = newStreamChannel; streamChannel = newStreamChannel;
@@ -1186,14 +1165,14 @@ class _MessageListViewState extends State<MessageListView> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => StreamBuilder<Message>( builder: (_) => BetterStreamBuilder<Message>(
stream: streamChannel!.channel.state!.messagesStream.map( stream: streamChannel!.channel.state!.messagesStream.map(
(messages) => (messages) =>
messages!.firstWhere((m) => m.id == message.id)), messages!.firstWhere((m) => m.id == message.id)),
initialData: message, initialData: message,
builder: (_, snapshot) => StreamChannel( builder: (_, data) => StreamChannel(
channel: streamChannel!.channel, channel: streamChannel!.channel,
child: widget.threadBuilder!(context, snapshot.data), child: widget.threadBuilder!(context, data),
), ),
), ),
), ),
@@ -1211,3 +1190,79 @@ class _MessageListViewState extends State<MessageListView> {
super.dispose(); super.dispose();
} }
} }
class _LoadingIndicator extends StatelessWidget {
const _LoadingIndicator({
Key? key,
required this.streamTheme,
required this.isThreadConversation,
required this.direction,
required this.streamChannel,
}) : super(key: key);
final StreamChatThemeData streamTheme;
final bool isThreadConversation;
final QueryDirection direction;
final StreamChannelState streamChannel;
@override
Widget build(BuildContext context) {
final stream = direction == QueryDirection.top
? streamChannel.queryTopMessages
: streamChannel.queryBottomMessages;
return BetterStreamBuilder<bool>(
key: Key('LOADING-INDICATOR $direction'),
stream: stream,
initialData: false,
errorBuilder: (context, error) => Container(
color: streamTheme.colorTheme.accentRed.withOpacity(.2),
child: const Center(
child: Text('Error loading messages'),
),
),
builder: (context, data) {
if (!data) {
if (!isThreadConversation && direction == QueryDirection.top) {
return const SizedBox(
height: 52,
width: double.infinity,
);
}
return const Offstage();
}
return const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
);
},
);
}
}
Stream<T> _valueListenableToStreamAdapter<T>(ValueListenable<T> listenable) {
// ignore: close_sinks
late StreamController<T> _controller;
void listener() {
_controller.add(listenable.value);
}
void start() {
listenable.addListener(listener);
}
void end() {
listenable.removeListener(listener);
}
_controller = StreamController<T>(
onListen: start,
onPause: end,
onResume: start,
onCancel: end,
);
return _controller.stream;
}
@@ -77,33 +77,10 @@ class MessageReactionsModal extends StatelessWidget {
final divFactor = message.attachments.isNotEmpty == true final divFactor = message.attachments.isNotEmpty == true
? 1 ? 1
: (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize));
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, snapshot) {
final hasFileAttachment = final hasFileAttachment =
message.attachments.any((it) => it.type == 'file') == true; message.attachments.any((it) => it.type == 'file') == true;
return GestureDetector(
behavior: HitTestBehavior.translucent, final child = Center(
onTap: () => Navigator.maybePop(context),
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: Container(
color: StreamChatTheme.of(context).colorTheme.overlay,
),
),
),
Transform.scale(
scale: val,
child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -116,12 +93,8 @@ class MessageReactionsModal extends StatelessWidget {
Align( Align(
alignment: Alignment( alignment: Alignment(
user!.id == message.user!.id user!.id == message.user!.id
? (divFactor >= 1.0 ? (divFactor >= 1.0 ? -0.2 : (1.2 - divFactor))
? -0.2 : (divFactor >= 1.0 ? 0.2 : -(1.2 - divFactor)),
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2
: -(1.2 - divFactor)),
0), 0),
child: ReactionPicker( child: ReactionPicker(
message: message, message: message,
@@ -147,17 +120,14 @@ class MessageReactionsModal extends StatelessWidget {
shape: messageShape, shape: messageShape,
attachmentShape: attachmentShape, attachmentShape: attachmentShape,
padding: const EdgeInsets.all(0), padding: const EdgeInsets.all(0),
attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry
attachmentBorderRadiusGeometry ?.mirrorBorderIfReversed(reverse: !reverse),
?.mirrorBorderIfReversed(
reverse: !reverse),
attachmentPadding: EdgeInsets.all( attachmentPadding: EdgeInsets.all(
hasFileAttachment ? 4 : 2, hasFileAttachment ? 4 : 2,
), ),
textPadding: EdgeInsets.symmetric( textPadding: EdgeInsets.symmetric(
vertical: 8, vertical: 8,
horizontal: horizontal: message.text!.isOnlyEmoji ? 0 : 16.0,
message.text!.isOnlyEmoji ? 0 : 16.0,
), ),
showReactionPickerIndicator: showReactions && showReactionPickerIndicator: showReactions &&
(message.status == MessageSendingStatus.sent), (message.status == MessageSendingStatus.sent),
@@ -167,23 +137,51 @@ class MessageReactionsModal extends StatelessWidget {
), ),
if (message.latestReactions?.isNotEmpty == true) ...[ if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_buildReactionCard(context), _buildReactionCard(
context,
user,
),
] ]
], ],
), ),
), ),
), ),
);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Navigator.maybePop(context),
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
), ),
child: DecoratedBox(
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.overlay,
),
),
),
),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, widget) => Transform.scale(
scale: val,
child: widget,
),
child: child,
), ),
], ],
), ),
); );
},
);
} }
Widget _buildReactionCard(BuildContext context) { Widget _buildReactionCard(BuildContext context, User? user) {
final currentUser = StreamChat.of(context).user;
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
return Card( return Card(
color: chatThemeData.colorTheme.white, color: chatThemeData.colorTheme.white,
@@ -210,7 +208,7 @@ class MessageReactionsModal extends StatelessWidget {
children: message.latestReactions! children: message.latestReactions!
.map((e) => _buildReaction( .map((e) => _buildReaction(
e, e,
currentUser!, user!,
context, context,
)) ))
.toList(), .toList(),
@@ -413,37 +413,39 @@ class _MessageWidgetState extends State<MessageWidget>
bool get showTimeStamp => widget.showTimestamp; bool get showTimeStamp => widget.showTimestamp;
bool get isMessageRead => widget.readList?.isNotEmpty == true; late final bool isMessageRead = widget.readList?.isNotEmpty == true;
bool get showInChannel => widget.showInChannelIndicator; bool get showInChannel => widget.showInChannelIndicator;
bool get hasQuotedMessage => widget.message.quotedMessage != null; bool get hasQuotedMessage => widget.message.quotedMessage != null;
bool get isSendFailed => widget.message.status == MessageSendingStatus.failed; late final bool isSendFailed =
widget.message.status == MessageSendingStatus.failed;
bool get isUpdateFailed => late final bool isUpdateFailed =
widget.message.status == MessageSendingStatus.failed_update; widget.message.status == MessageSendingStatus.failed_update;
bool get isDeleteFailed => late final bool isDeleteFailed =
widget.message.status == MessageSendingStatus.failed_delete; widget.message.status == MessageSendingStatus.failed_delete;
bool get isFailedState => isSendFailed || isUpdateFailed || isDeleteFailed; late final bool isFailedState =
isSendFailed || isUpdateFailed || isDeleteFailed;
bool get isGiphy => late final bool isGiphy =
widget.message.attachments.any((element) => element.type == 'giphy') == widget.message.attachments.any((element) => element.type == 'giphy') ==
true; true;
bool get hasNonUrlAttachments => late final bool isOnlyEmoji = widget.message.text?.isOnlyEmoji == true;
widget.message.attachments
late final bool hasNonUrlAttachments = widget.message.attachments
.where((it) => it.ogScrapeUrl == null) .where((it) => it.ogScrapeUrl == null)
.isNotEmpty == .isNotEmpty ==
true; true;
bool get hasUrlAttachments => late final bool hasUrlAttachments =
widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true; widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true;
bool get showBottomRow => late final bool showBottomRow = showThreadReplyIndicator ||
showThreadReplyIndicator ||
showUsername || showUsername ||
showTimeStamp || showTimeStamp ||
showInChannel || showInChannel ||
@@ -453,6 +455,9 @@ class _MessageWidgetState extends State<MessageWidget>
@override @override
bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true; bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true;
late StreamChatThemeData _streamChatTheme;
late StreamChatState _streamChat;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -466,7 +471,7 @@ class _MessageWidgetState extends State<MessageWidget>
? MaterialType.card ? MaterialType.card
: MaterialType.transparency, : MaterialType.transparency,
color: widget.message.pinned && widget.showPinHighlight color: widget.message.pinned && widget.showPinHighlight
? StreamChatTheme.of(context).colorTheme.highlight ? _streamChatTheme.colorTheme.highlight
: null, : null,
child: Portal( child: Portal(
child: InkWell( child: InkWell(
@@ -527,7 +532,8 @@ class _MessageWidgetState extends State<MessageWidget>
transform: Matrix4.translationValues( transform: Matrix4.translationValues(
widget.reverse ? 12 : -12, 0, 0), widget.reverse ? 12 : -12, 0, 0),
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxWidth: 22 * 6.0), maxWidth: 22 * 6.0,
),
child: _buildReactionIndicator(context), child: _buildReactionIndicator(context),
), ),
portalAnchor: portalAnchor:
@@ -572,7 +578,7 @@ class _MessageWidgetState extends State<MessageWidget>
), ),
) )
: Card( : Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
elevation: 0, elevation: 0,
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
horizontal: (isFailedState horizontal: (isFailedState
@@ -626,9 +632,8 @@ class _MessageWidgetState extends State<MessageWidget>
top: -8, top: -8,
child: CustomPaint( child: CustomPaint(
painter: ReactionBubblePainter( painter: ReactionBubblePainter(
StreamChatTheme.of(context) _streamChatTheme
.colorTheme .colorTheme.white,
.white,
Colors.transparent, Colors.transparent,
Colors.transparent, Colors.transparent,
tailCirclesSpace: 1, tailCirclesSpace: 1,
@@ -673,14 +678,20 @@ class _MessageWidgetState extends State<MessageWidget>
); );
} }
@override
void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context);
_streamChat = StreamChat.of(context);
super.didChangeDependencies();
}
Widget _buildQuotedMessage() { Widget _buildQuotedMessage() {
final isMyMessage = final isMyMessage = widget.message.user?.id == _streamChat.user?.id;
widget.message.user?.id == StreamChat.of(context).user?.id;
final onTap = widget.message.quotedMessage?.isDeleted != true && final onTap = widget.message.quotedMessage?.isDeleted != true &&
widget.onQuotedMessageTap != null widget.onQuotedMessageTap != null
? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId) ? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId)
: null; : null;
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = _streamChatTheme;
return QuotedMessageWidget( return QuotedMessageWidget(
onTap: onTap, onTap: onTap,
message: widget.message.quotedMessage!, message: widget.message.quotedMessage!,
@@ -695,7 +706,7 @@ class _MessageWidgetState extends State<MessageWidget>
Widget get _bottomRow { Widget get _bottomRow {
if (isDeleted) { if (isDeleted) {
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = _streamChatTheme;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -849,36 +860,16 @@ class _MessageWidgetState extends State<MessageWidget>
); );
} }
Widget _buildThreadParticipantsIndicator(Iterable<User> threadParticipants) { Widget _buildThreadParticipantsIndicator(Iterable<User> threadParticipants) =>
var padding = 0.0; _ThreadParticipants(
return Stack( streamChatTheme: _streamChatTheme,
children: threadParticipants.map((user) { threadParticipants: threadParticipants,
padding += 8.0;
return Positioned(
right: padding - 8,
bottom: 0,
top: 0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: StreamChatTheme.of(context).colorTheme.white,
),
padding: const EdgeInsets.all(1),
child: UserAvatar(
user: user,
constraints: BoxConstraints.loose(const Size.fromRadius(7)),
showOnlineStatus: false,
),
),
); );
}).toList(),
);
}
Widget _buildReactionIndicator( Widget _buildReactionIndicator(
BuildContext context, BuildContext context,
) { ) {
final ownId = StreamChat.of(context).user!.id; final ownId = _streamChat.user!.id;
final reactionsMap = <String, Reaction>{}; final reactionsMap = <String, Reaction>{};
widget.message.latestReactions?.forEach((element) { widget.message.latestReactions?.forEach((element) {
if (!reactionsMap.containsKey(element.type) || if (!reactionsMap.containsKey(element.type) ||
@@ -918,7 +909,7 @@ class _MessageWidgetState extends State<MessageWidget>
showDialog( showDialog(
context: context, context: context,
barrierColor: StreamChatTheme.of(context).colorTheme.overlay, barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: channel, channel: channel,
child: MessageActionsModal( child: MessageActionsModal(
@@ -969,7 +960,7 @@ class _MessageWidgetState extends State<MessageWidget>
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
showDialog( showDialog(
context: context, context: context,
barrierColor: StreamChatTheme.of(context).colorTheme.overlay, barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: channel, channel: channel,
child: MessageReactionsModal( child: MessageReactionsModal(
@@ -1000,7 +991,7 @@ class _MessageWidgetState extends State<MessageWidget>
side: hasFiles side: hasFiles
? widget.attachmentBorderSide ?? ? widget.attachmentBorderSide ??
BorderSide( BorderSide(
color: StreamChatTheme.of(context).colorTheme.greyWhisper, color: _streamChatTheme.colorTheme.greyWhisper,
) )
: BorderSide.none, : BorderSide.none,
borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero, borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero,
@@ -1010,7 +1001,7 @@ class _MessageWidgetState extends State<MessageWidget>
ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder( ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder(
side: widget.borderSide ?? side: widget.borderSide ??
BorderSide( BorderSide(
color: StreamChatTheme.of(context).colorTheme.greyWhisper, color: _streamChatTheme.colorTheme.greyWhisper,
), ),
borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero,
); );
@@ -1101,7 +1092,7 @@ class _MessageWidgetState extends State<MessageWidget>
Text( Text(
widget.readList!.length.toString(), widget.readList!.length.toString(),
style: style.copyWith( style: style.copyWith(
color: StreamChatTheme.of(context).colorTheme.accentBlue, color: _streamChatTheme.colorTheme.accentBlue,
), ),
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
@@ -1158,7 +1149,7 @@ class _MessageWidgetState extends State<MessageWidget>
Widget _buildPinnedMessage(Message message) { Widget _buildPinnedMessage(Message message) {
final pinnedBy = message.pinnedBy; final pinnedBy = message.pinnedBy;
final pinnedByMe = StreamChat.of(context).user!.id == pinnedBy!.id; final pinnedByMe = _streamChat.user!.id == pinnedBy!.id;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8),
@@ -1174,7 +1165,7 @@ class _MessageWidgetState extends State<MessageWidget>
Text( Text(
'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}', 'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}',
style: TextStyle( style: TextStyle(
color: StreamChatTheme.of(context).colorTheme.grey, color: _streamChatTheme.colorTheme.grey,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
@@ -1184,9 +1175,7 @@ class _MessageWidgetState extends State<MessageWidget>
); );
} }
bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji; late final bool isPinned = widget.message.pinned;
bool get isPinned => widget.message.pinned;
Color? _getBackgroundColor() { Color? _getBackgroundColor() {
if (hasQuotedMessage) { if (hasQuotedMessage) {
@@ -1194,7 +1183,7 @@ class _MessageWidgetState extends State<MessageWidget>
} }
if (hasUrlAttachments) { if (hasUrlAttachments) {
return StreamChatTheme.of(context).colorTheme.blueAlice; return _streamChatTheme.colorTheme.blueAlice;
} }
if (isOnlyEmoji) { if (isOnlyEmoji) {
@@ -1226,6 +1215,45 @@ class _MessageWidgetState extends State<MessageWidget>
} }
} }
class _ThreadParticipants extends StatelessWidget {
const _ThreadParticipants({
Key? key,
required StreamChatThemeData streamChatTheme,
required this.threadParticipants,
}) : _streamChatTheme = streamChatTheme,
super(key: key);
final StreamChatThemeData _streamChatTheme;
final Iterable<User> threadParticipants;
@override
Widget build(BuildContext context) {
var padding = 0.0;
return Stack(
children: threadParticipants.map((user) {
padding += 8.0;
return Positioned(
right: padding - 8,
bottom: 0,
top: 0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _streamChatTheme.colorTheme.white,
),
padding: const EdgeInsets.all(1),
child: UserAvatar(
user: user,
constraints: BoxConstraints.loose(const Size.fromRadius(7)),
showOnlineStatus: false,
),
),
);
}).toList(),
);
}
}
class _ThreadReplyPainter extends CustomPainter { class _ThreadReplyPainter extends CustomPainter {
const _ThreadReplyPainter({ const _ThreadReplyPainter({
this.context, this.context,
@@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.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:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Widget builder for quoted message attachment thumnail /// Widget builder for quoted message attachment thumnail
typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function(
@@ -217,7 +217,7 @@ class QuotedMessageWidget extends StatelessWidget {
} }
child = AbsorbPointer(child: child); child = AbsorbPointer(child: child);
return Material( return Material(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
type: MaterialType.transparency, type: MaterialType.transparency,
shape: attachment.type == 'file' ? null : _getDefaultShape(context), shape: attachment.type == 'file' ? null : _getDefaultShape(context),
child: child, child: child,
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:ezanimation/ezanimation.dart'; import 'package:ezanimation/ezanimation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/extension.dart';
@@ -50,13 +48,7 @@ class _ReactionPickerState extends State<ReactionPicker>
triggerAnimations(); triggerAnimations();
} }
return TweenAnimationBuilder<double>( final child = Material(
tween: Tween(begin: 0, end: 1),
curve: Curves.easeInOutBack,
duration: const Duration(milliseconds: 500),
builder: (context, val, wid) => Transform.scale(
scale: val,
child: Material(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
color: chatThemeData.colorTheme.white, color: chatThemeData.colorTheme.white,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
@@ -72,11 +64,18 @@ class _ReactionPickerState extends State<ReactionPicker>
children: reactionIcons children: reactionIcons
.map<Widget>((reactionIcon) { .map<Widget>((reactionIcon) {
final ownReactionIndex = widget.message.ownReactions final ownReactionIndex = widget.message.ownReactions
?.indexWhere((reaction) => ?.indexWhere(
reaction.type == reactionIcon.type) ?? (reaction) => reaction.type == reactionIcon.type) ??
-1; -1;
final index = reactionIcons.indexOf(reactionIcon); final index = reactionIcons.indexOf(reactionIcon);
final child = StreamSvgIcon(
assetName: reactionIcon.assetName,
color: ownReactionIndex != -1
? chatThemeData.colorTheme.accentBlue
: Theme.of(context).iconTheme.color!.withOpacity(.5),
);
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints.tightFor( constraints: const BoxConstraints.tightFor(
height: 24, height: 24,
@@ -95,8 +94,7 @@ class _ReactionPickerState extends State<ReactionPicker>
if (ownReactionIndex != -1) { if (ownReactionIndex != -1) {
removeReaction( removeReaction(
context, context,
widget.message widget.message.ownReactions![ownReactionIndex],
.ownReactions![ownReactionIndex],
); );
} else { } else {
sendReaction( sendReaction(
@@ -107,27 +105,12 @@ class _ReactionPickerState extends State<ReactionPicker>
}, },
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animations[index], animation: animations[index],
builder: (context, val) => Transform.scale( builder: (context, child) => Transform.scale(
scale: animations[index].value, scale: animations[index].value,
child: StreamSvgIcon( child: child,
assetName: reactionIcon.assetName,
height: max(
0,
animations[index].value * 24.0,
), ),
width: max( child: child,
0,
animations[index].value * 24.0,
), ),
color: ownReactionIndex != -1
? chatThemeData
.colorTheme.accentBlue
: Theme.of(context)
.iconTheme
.color!
.withOpacity(.5),
),
)),
), ),
); );
}) })
@@ -137,8 +120,18 @@ class _ReactionPickerState extends State<ReactionPicker>
.toList(), .toList(),
), ),
), ),
);
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
curve: Curves.easeInOutBack,
duration: const Duration(milliseconds: 500),
builder: (context, val, widget) => Transform.scale(
scale: val,
child: widget,
), ),
)); child: child,
);
} }
void triggerAnimations() async { void triggerAnimations() async {
@@ -33,13 +33,22 @@ class TypingIndicator extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final channelState = final channelState =
channel?.state ?? StreamChannel.of(context).channel.state!; channel?.state ?? StreamChannel.of(context).channel.state!;
return StreamBuilder<List<User>>(
final altWidget = Align(
key: const Key('alternative'),
alignment: alignment,
child: Container(
child: alternativeWidget ?? const Offstage(),
),
);
return BetterStreamBuilder<List<User>>(
initialData: channelState.typingEvents, initialData: channelState.typingEvents,
stream: channelState.typingEventsStream, stream: channelState.typingEventsStream,
builder: (context, snapshot) => AnimatedSwitcher( builder: (context, data) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: snapshot.data?.isNotEmpty == true child: data.isNotEmpty == true
? Padding( ? Padding(
key: const Key('main'),
padding: padding, padding: padding,
child: Align( child: Align(
key: const Key('typings'), key: const Key('typings'),
@@ -54,7 +63,7 @@ class TypingIndicator extends StatelessWidget {
), ),
Text( Text(
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
' ${snapshot.data![0].name}${snapshot.data!.length == 1 ? '' : ' and ${snapshot.data!.length - 1} more'} ${snapshot.data!.length == 1 ? 'is' : 'are'} typing', ' ${data[0].name}${data.length == 1 ? '' : ' and ${data.length - 1} more'} ${data.length == 1 ? 'is' : 'are'} typing',
maxLines: 1, maxLines: 1,
style: style, style: style,
), ),
@@ -62,13 +71,7 @@ class TypingIndicator extends StatelessWidget {
), ),
), ),
) )
: Align( : altWidget,
key: const Key('alternative'),
alignment: alignment,
child: Container(
child: alternativeWidget ?? const Offstage(),
),
),
), ),
); );
} }
@@ -17,16 +17,16 @@ class UnreadIndicator extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = StreamChat.of(context).client; final client = StreamChat.of(context).client;
return IgnorePointer( return IgnorePointer(
child: StreamBuilder<int?>( child: BetterStreamBuilder<int?>(
stream: cid != null stream: cid != null
? client.state.channels[cid]?.state?.unreadCountStream ? client.state.channels[cid]?.state?.unreadCountStream
: client.state.totalUnreadCountStream, : client.state.totalUnreadCountStream,
initialData: cid != null initialData: cid != null
? client.state.channels[cid]?.state?.unreadCount ? client.state.channels[cid]?.state?.unreadCount
: client.state.totalUnreadCount, : client.state.totalUnreadCount,
builder: (context, snapshot) { builder: (context, data) {
if (!snapshot.hasData || snapshot.data == 0) { if (data == null || data == 0) {
return const SizedBox(); return const Offstage();
} }
return Material( return Material(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -42,7 +42,7 @@ class UnreadIndicator extends StatelessWidget {
), ),
child: Center( child: Center(
child: Text( child: Text(
'${snapshot.data! > 99 ? '99+' : snapshot.data}', '${data > 99 ? '99+' : data}',
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.white, color: Colors.white,
@@ -40,7 +40,7 @@ class UrlAttachment extends StatelessWidget {
children: [ children: [
if (urlAttachment.imageUrl != null) if (urlAttachment.imageUrl != null)
Container( Container(
clipBehavior: Clip.antiAliasWithSaveLayer, clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
@@ -332,7 +334,7 @@ Widget wrapAttachmentWidget(
bool reverse, bool reverse,
) => ) =>
Material( Material(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.hardEdge,
shape: attachmentShape, shape: attachmentShape,
type: MaterialType.transparency, type: MaterialType.transparency,
child: attachmentWidget, child: attachmentWidget,
@@ -107,6 +107,8 @@ void main() {
]); ]);
when(() => client.wsConnectionStatusStream) when(() => client.wsConnectionStatusStream)
.thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected));
when(() => client.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
when(() => clientState.totalUnreadCountStream) when(() => clientState.totalUnreadCountStream)
.thenAnswer((i) => Stream.value(1)); .thenAnswer((i) => Stream.value(1));
@@ -53,15 +53,6 @@ void main() {
) )
])); ]));
when(() => channelState.typingEvents).thenAnswer((i) => [
User(id: 'other-user', extraData: {'name': 'demo'})
]);
when(() => channelState.typingEventsStream)
.thenAnswer((i) => Stream.value([
User(id: 'other-user', extraData: {'name': 'demo'}),
User(id: 'other-user', extraData: {'name': 'demo'}),
]));
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: StreamChat( home: StreamChat(
client: client, client: client,
@@ -75,7 +66,6 @@ void main() {
)); ));
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
expect(find.byType(StreamSvgIcon), findsNWidgets(8));
expect(find.byKey(const Key('messageInputText')), findsOneWidget); expect(find.byKey(const Key('messageInputText')), findsOneWidget);
}, },
); );
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
class MockClient extends Mock implements StreamChatClient {} class MockClient extends Mock implements StreamChatClient {
MockClient() {
when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected);
}
}
class MockClientState extends Mock implements ClientState {} class MockClientState extends Mock implements ClientState {}
@@ -17,7 +21,12 @@ class MockChannel extends Mock implements Channel {
} }
} }
class MockChannelState extends Mock implements ChannelClientState {} class MockChannelState extends Mock implements ChannelClientState {
MockChannelState() {
when(() => typingEvents).thenReturn([]);
when(() => typingEventsStream).thenAnswer((_) => Stream.value([]));
}
}
class MockNavigatorObserver extends Mock implements NavigatorObserver {} class MockNavigatorObserver extends Mock implements NavigatorObserver {}
@@ -83,7 +83,7 @@ void main() {
), ),
)); ));
expect(find.byType(SizedBox), findsOneWidget); expect(find.text('0'), findsNothing);
}, },
); );
@@ -0,0 +1,109 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
/// A more efficient [StreamBuilder]
/// It requires [initialData] and will rebuild
/// only when the new data is different than the current data
/// The [comparator] is used to check if the new data is different
class BetterStreamBuilder<T> extends StatefulWidget {
/// Creates a new BetterStreamBuilder
const BetterStreamBuilder({
required this.stream,
required this.initialData,
required this.builder,
this.loadingBuilder,
this.errorBuilder,
this.comparator,
Key? key,
}) : super(key: key);
/// The stream to listen to
final Stream<T>? stream;
/// The initial data available
final T initialData;
/// Comparator used to check if the new data is different than the last one
final bool Function(T?, T)? comparator;
/// Builder that builds based on the new snapshot
final Widget Function(BuildContext context, T data) builder;
/// Builder that builds when the data is null
final Widget Function(BuildContext context)? loadingBuilder;
/// Builder used when there is an error
final Widget Function(BuildContext context, Object error)? errorBuilder;
@override
_BetterStreamBuilderState createState() => _BetterStreamBuilderState<T>();
}
class _BetterStreamBuilderState<T> extends State<BetterStreamBuilder<T>> {
T? _lastEvent;
StreamSubscription? _subscription;
Object? _lastError;
@override
Widget build(BuildContext context) {
if (_lastError != null) {
return widget.errorBuilder!(context, _lastError!);
}
if (_lastEvent == null) {
return widget.loadingBuilder?.call(context) ?? const Offstage();
}
return widget.builder(context, _lastEvent ?? widget.initialData);
}
@override
void initState() {
_lastEvent = widget.initialData;
_subscription = widget.stream?.listen(
_onEvent,
onError: _onError,
);
super.initState();
}
@override
void didUpdateWidget(covariant BetterStreamBuilder<T> oldWidget) {
if (oldWidget.stream != widget.stream) {
_subscription?.cancel();
_subscription = widget.stream?.listen(
_onEvent,
onError: _onError,
);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
void _onError(error) {
if (widget.errorBuilder != null && error != _lastError) {
if (mounted) {
setState(() {});
}
_lastError = error;
}
}
void _onEvent(T event) {
_lastError = null;
final isEqual = widget.comparator != null
? widget.comparator!(_lastEvent, event)
: event == _lastEvent;
if (!isEqual) {
if (mounted) {
setState(() {});
}
_lastEvent = event;
}
}
}
@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
import 'package:stream_chat_flutter_core/src/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/stream_channel.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart';
@@ -127,6 +129,10 @@ class MessageListCoreState extends State<MessageListCore> {
.map((threads) => threads[widget.parentMessage!.id]) .map((threads) => threads[widget.parentMessage!.id])
: _streamChannel!.channel.state?.messagesStream; : _streamChannel!.channel.state?.messagesStream;
final initialData = _isThreadConversation
? _streamChannel!.channel.state?.threads[widget.parentMessage!.id]
: _streamChannel!.channel.state?.messages;
bool defaultFilter(Message m) { bool defaultFilter(Message m) {
final isMyMessage = m.user?.id == _currentUser?.id; final isMyMessage = m.user?.id == _currentUser?.id;
final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true;
@@ -134,19 +140,19 @@ class MessageListCoreState extends State<MessageListCore> {
return true; return true;
} }
return StreamBuilder<List<Message>?>( return BetterStreamBuilder<List<Message>?>(
stream: messagesStream?.map((messages) => initialData: initialData,
comparator: const ListEquality().equals,
stream: messagesStream!.map(
(messages) =>
messages?.where(widget.messageFilter ?? defaultFilter).toList( messages?.where(widget.messageFilter ?? defaultFilter).toList(
growable: false, growable: false,
)), ),
builder: (context, snapshot) { ),
if (snapshot.hasError) { errorBuilder: widget.errorWidgetBuilder,
return widget.errorWidgetBuilder(context, snapshot.error!); loadingBuilder: widget.loadingBuilder,
} else if (!snapshot.hasData) { builder: (context, data) {
return widget.loadingBuilder(context); final messageList = data?.reversed.toList(growable: false) ?? [];
} else {
final messageList =
snapshot.data?.reversed.toList(growable: false) ?? [];
if (messageList.isEmpty && !_isThreadConversation) { if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) { if (_upToDate) {
return widget.emptyBuilder(context); return widget.emptyBuilder(context);
@@ -155,7 +161,6 @@ class MessageListCoreState extends State<MessageListCore> {
_messages = messageList; _messages = messageList;
} }
return widget.messageListBuilder(context, _messages); return widget.messageListBuilder(context, _messages);
}
}, },
); );
} }
@@ -3,6 +3,7 @@ library stream_chat_flutter_core;
export 'package:connectivity_plus/connectivity_plus.dart'; export 'package:connectivity_plus/connectivity_plus.dart';
export 'package:stream_chat/stream_chat.dart'; export 'package:stream_chat/stream_chat.dart';
export 'src/better_stream_builder.dart';
export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channel_list_core.dart' hide ChannelListCoreState;
export 'src/channels_bloc.dart'; export 'src/channels_bloc.dart';
export 'src/lazy_load_scroll_view.dart'; export 'src/lazy_load_scroll_view.dart';
@@ -100,6 +100,7 @@ void main() {
when(() => mockChannel.state.isUpToDate).thenReturn(true); when(() => mockChannel.state.isUpToDate).thenReturn(true);
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value([])); .thenAnswer((_) => Stream.value([]));
when(() => mockChannel.state.messages).thenReturn([]);
await tester.pumpWidget( await tester.pumpWidget(
StreamChannel( StreamChannel(
@@ -133,6 +134,7 @@ void main() {
when(() => mockChannel.state.isUpToDate).thenReturn(true); when(() => mockChannel.state.isUpToDate).thenReturn(true);
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value([])); .thenAnswer((_) => Stream.value([]));
when(() => mockChannel.state.messages).thenReturn([]);
when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true));
await tester.pumpWidget( await tester.pumpWidget(
@@ -174,6 +176,7 @@ void main() {
when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.messages).thenReturn(messages);
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages)); .thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true));
await tester.pumpWidget( await tester.pumpWidget(
@@ -220,6 +223,7 @@ void main() {
const error = 'Error! Error! Error!'; const error = 'Error! Error! Error!';
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.error(error)); .thenAnswer((_) => Stream.error(error));
when(() => mockChannel.state.messages).thenReturn([]);
await tester.pumpWidget( await tester.pumpWidget(
Directionality( Directionality(
@@ -259,6 +263,7 @@ void main() {
const messages = <Message>[]; const messages = <Message>[];
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages)); .thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget( await tester.pumpWidget(
Directionality( Directionality(
@@ -305,6 +310,7 @@ void main() {
const messages = <Message>[]; const messages = <Message>[];
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages)); .thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget( await tester.pumpWidget(
Directionality( Directionality(
@@ -349,6 +355,7 @@ void main() {
final messages = _generateMessages(); final messages = _generateMessages();
when(() => mockChannel.state.messagesStream) when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages)); .thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget( await tester.pumpWidget(
Directionality( Directionality(
@@ -4,6 +4,10 @@ import 'package:stream_chat/stream_chat.dart';
class MockLogger extends Mock implements Logger {} class MockLogger extends Mock implements Logger {}
class MockClient extends Mock implements StreamChatClient { class MockClient extends Mock implements StreamChatClient {
MockClient() {
when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected);
}
@override @override
final Logger logger = MockLogger(); final Logger logger = MockLogger();
@@ -235,6 +235,14 @@ void main() {
final mockClient = MockClient(); final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore'); const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child'); const childKey = Key('child');
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(() => mockClient.disconnect()).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCore = StreamChatCore( final streamChatCore = StreamChatCore(
key: streamChatCoreKey, key: streamChatCoreKey,
client: mockClient, client: mockClient,
@@ -247,13 +255,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCoreState = tester.state<StreamChatCoreState>( final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey), find.byKey(streamChatCoreKey),
); );
@@ -323,6 +324,14 @@ void main() {
const childKey = Key('child'); const childKey = Key('child');
final _connectivityController = final _connectivityController =
BehaviorSubject.seeded(ConnectivityResult.none); BehaviorSubject.seeded(ConnectivityResult.none);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(() => mockClient.disconnect()).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCore = StreamChatCore( final streamChatCore = StreamChatCore(
key: streamChatCoreKey, key: streamChatCoreKey,
client: mockClient, client: mockClient,
@@ -335,13 +344,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
_connectivityController.add(ConnectivityResult.mobile); _connectivityController.add(ConnectivityResult.mobile);
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
@@ -397,6 +399,14 @@ void main() {
const childKey = Key('child'); const childKey = Key('child');
final _connectivityController = final _connectivityController =
BehaviorSubject.seeded(ConnectivityResult.none); BehaviorSubject.seeded(ConnectivityResult.none);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(() => mockClient.disconnect()).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCore = StreamChatCore( final streamChatCore = StreamChatCore(
key: streamChatCoreKey, key: streamChatCoreKey,
client: mockClient, client: mockClient,
@@ -409,13 +419,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCoreState = tester.state<StreamChatCoreState>( final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey), find.byKey(streamChatCoreKey),
); );