merge origin/develop into ref/segregate-api-layer

Signed-off-by: Sahil Kumar <[email protected]>
This commit is contained in:
Sahil Kumar
2021-06-15 17:32:09 +05:30
62 changed files with 2287 additions and 1620 deletions
+7
View File
@@ -1,3 +1,10 @@
## 2.0.0-nullsafety.5
- Minor fixes
- Performance improvements
- Fixed `skip_push` in `client.sendMessage`
- Added partial message update method
## 2.0.0-nullsafety.2
- Added new `Filter.raw` constructor
@@ -4,15 +4,15 @@ import 'dart:math';
import 'package:collection/collection.dart'
show IterableExtension, ListEquality;
import 'package:dio/dio.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/stream_chat.dart';
/// This a the class that manages a specific channel.
@@ -70,8 +70,11 @@ class Channel {
true;
/// Returns true if the channel is muted as a stream
Stream<bool>? get isMutedStream => _client.state.userStream.map((event) =>
event!.channelMutes.any((element) => element.channel.cid == cid) == true);
Stream<bool>? get isMutedStream => _client.state.userStream
.map((event) =>
event!.channelMutes.any((element) => element.channel.cid == cid) ==
true)
.distinct();
/// True if the channel is a group
bool get isGroup => memberCount != 2;
@@ -253,7 +256,10 @@ class Channel {
String messageId,
Iterable<String> attachmentIds,
) {
final message = state!.messages.firstWhereOrNull(
final message = [
...state!.messages,
...state!.threads.values.expand((messages) => messages),
].firstWhereOrNull(
(it) => it.id == messageId,
);
@@ -351,9 +357,13 @@ class Channel {
}
/// Send a [message] to this channel.
/// If [skipPush] is true the message will not send a push notification
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually sending the message.
Future<SendMessageResponse> sendMessage(Message message) async {
Future<SendMessageResponse> sendMessage(
Message message, {
bool skipPush = false,
}) async {
_checkInitialized();
// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
@@ -386,7 +396,6 @@ class Channel {
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
// ignore: unawaited_futures
_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
@@ -396,7 +405,12 @@ class Channel {
message = await attachmentsUploadCompleter.future;
}
final response = await _client.sendMessage(message, id!, type);
final response = await _client.sendMessage(
message,
id!,
type,
skipPush: skipPush,
);
state!.addMessage(response.message);
return response;
} catch (e) {
@@ -411,6 +425,8 @@ class Channel {
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(Message message) async {
final originalMessage = message;
// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter
@@ -432,12 +448,11 @@ class Channel {
state?.addMessage(message);
try {
if (message.attachments.any((it) => !it.uploadState.isSuccess) == true) {
if (message.attachments.any((it) => !it.uploadState.isSuccess)) {
final attachmentsUploadCompleter = Completer<Message>();
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
// ignore: unawaited_futures
_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
@@ -455,6 +470,40 @@ class Channel {
state?.addMessage(m);
return response;
} catch (e) {
if (e is StreamChatNetworkError) {
if (e.isRetriable) {
state!._retryQueue.add([message]);
} else {
state?.addMessage(originalMessage);
}
}
rethrow;
}
}
/// Partially updates the [message] in this channel.
/// Use [set] to define values to be set
/// Use [unset] to define values to be unset
Future<UpdateMessageResponse> partialUpdateMessage(
Message message, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
try {
final response = await _client.partialUpdateMessage(
message.id,
set: set,
unset: unset,
);
final updatedMessage = response.message.copyWith(
ownReactions: message.ownReactions,
);
state?.addMessage(updatedMessage);
return response;
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
@@ -527,17 +576,23 @@ class Channel {
Duration(seconds: timeoutOrExpirationDate.toInt()),
);
}
return updateMessage(
message.copyWith(
pinned: true,
pinExpires: pinExpires,
),
return partialUpdateMessage(
message,
set: {
'pinned': true,
'pin_expires': pinExpires?.toUtc().toIso8601String(),
},
);
}
/// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) =>
updateMessage(message.copyWith(pinned: false));
partialUpdateMessage(
message,
set: {
'pinned': false,
},
);
/// Send a file to this channel
Future<SendFileResponse> sendFile(
@@ -747,11 +802,12 @@ class Channel {
}
/// Edit the channel custom data
Future<PartialUpdateChannelResponse> updatePartial(
Map<String, dynamic> channelData,
) async {
Future<PartialUpdateChannelResponse> updatePartial({
Map<String, Object?>? set,
List<String>? unset,
}) async {
_checkInitialized();
return _client.updateChannelPartial(id!, type, channelData);
return _client.updateChannelPartial(id!, type, set: set, unset: unset);
}
/// Delete this channel. Messages are permanently removed.
@@ -856,9 +912,9 @@ class Channel {
/// particular message as read
Future<EmptyResponse> markRead({String? messageId}) async {
_checkInitialized();
client.state.totalUnreadCount = max(
0, (client.state.totalUnreadCount ?? 0) - (state!.unreadCount ?? 0));
state!._unreadCountController.add(0);
client.state.totalUnreadCount =
max(0, (client.state.totalUnreadCount) - (state!.unreadCount));
state!.unreadCount = 0;
return _client.markChannelRead(id!, type, messageId: messageId);
}
@@ -1252,7 +1308,7 @@ class ChannelClientState {
(r) => r.user.id == _channel._client.state.user?.id,
);
if (userRead != null) {
_unreadCountController.add(userRead.unreadMessages);
unreadCount = userRead.unreadMessages;
}
}
@@ -1432,7 +1488,7 @@ class ChannelClientState {
}
if (_countMessageAsUnread(message)) {
_unreadCountController.add(_unreadCountController.value + 1);
unreadCount += 1;
}
}));
}
@@ -1488,7 +1544,7 @@ class ChannelClientState {
if (userReadIndex != null && userReadIndex != -1) {
final userRead = readList.removeAt(userReadIndex);
if (userRead.user.id == _channel._client.state.user!.id) {
_unreadCountController.add(0);
unreadCount = 0;
}
readList.add(Read(
user: event.user!,
@@ -1508,7 +1564,7 @@ class ChannelClientState {
/// Channel message list as a stream
Stream<List<Message>?> get messagesStream => channelStateStream
.map((cs) => cs.messages)
.distinct((prev, next) => const ListEquality().equals(prev, next));
.distinct(const ListEquality().equals);
/// Channel pinned message list
List<Message>? get pinnedMessages => _channelState.pinnedMessages.toList();
@@ -1538,7 +1594,7 @@ class ChannelClientState {
_channel.client.state.usersStream,
(members, users) =>
members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(),
);
).distinct(const ListEquality().equals);
/// Channel watcher count
int? get watcherCount => _channelState.watcherCount;
@@ -1568,11 +1624,13 @@ class ChannelClientState {
final BehaviorSubject<int> _unreadCountController = BehaviorSubject.seeded(0);
set unreadCount(int value) => _unreadCountController.add(value);
/// Unread count getter as a stream
Stream<int> get unreadCountStream => _unreadCountController.stream;
Stream<int> get unreadCountStream => _unreadCountController.stream.distinct();
/// Unread count getter
int? get unreadCount => _unreadCountController.value;
int get unreadCount => _unreadCountController.value;
bool _countMessageAsUnread(Message message) {
final userId = _channel.client.state.user?.id;
@@ -1708,7 +1766,9 @@ class ChannelClientState {
List<User> get typingEvents => _typingEventsController.value;
/// 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 =
BehaviorSubject.seeded([]);
+57 -31
View File
@@ -196,7 +196,7 @@ class StreamChatClient {
_wsConnectionStatusController.add(status);
/// The current status value of the websocket connection
ConnectionStatus? get wsConnectionStatus =>
ConnectionStatus get wsConnectionStatus =>
_wsConnectionStatusController.value;
/// This notifies the connection status of the websocket connection.
@@ -745,13 +745,15 @@ class StreamChatClient {
/// Updates the [channelId] of type [ChannelType] data with [data]
Future<PartialUpdateChannelResponse> updateChannelPartial(
String channelId,
String channelType,
Map<String, dynamic> data,
) =>
String channelType, {
Map<String, Object?>? set,
List<String>? unset,
}) =>
_chatApi.channel.updateChannelPartial(
channelId,
channelType,
data,
set: set,
unset: unset,
);
/// Add a device for Push Notifications.
@@ -1125,12 +1127,14 @@ class StreamChatClient {
Future<SendMessageResponse> sendMessage(
Message message,
String channelId,
String channelType,
) =>
String channelType, {
bool skipPush = false,
}) =>
_chatApi.message.sendMessage(
channelId,
channelType,
message,
skipPush: skipPush,
);
/// Lists all the message replies for the [parentId]
@@ -1157,6 +1161,20 @@ class StreamChatClient {
Future<UpdateMessageResponse> updateMessage(Message message) =>
_chatApi.message.updateMessage(message);
/// Partially update the given [messageId]
/// Use [set] to define values to be set
/// Use [unset] to define values to be unset
Future<UpdateMessageResponse> partialUpdateMessage(
String messageId, {
Map<String, Object?>? set,
List<String>? unset,
}) =>
_chatApi.message.partialUpdateMessage(
messageId,
set: set,
unset: unset,
);
/// Deletes the given message
Future<EmptyResponse> deleteMessage(String messageId) =>
_chatApi.message.deleteMessage(messageId);
@@ -1192,7 +1210,7 @@ class StreamChatClient {
/// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds
/// to be added to [DateTime.now]
Future<UpdateMessageResponse> pinMessage(
Message message, {
String messageId, {
Object? /*num|DateTime*/ timeoutOrExpirationDate,
}) {
assert(() {
@@ -1212,17 +1230,23 @@ class StreamChatClient {
Duration(seconds: timeoutOrExpirationDate.toInt()),
);
}
return updateMessage(
message.copyWith(
pinned: true,
pinExpires: pinExpires,
),
return partialUpdateMessage(
messageId,
set: {
'pinned': true,
'pin_expires': pinExpires?.toUtc().toIso8601String(),
},
);
}
/// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) =>
updateMessage(message.copyWith(pinned: false));
Future<UpdateMessageResponse> unpinMessage(String messageId) =>
partialUpdateMessage(
messageId,
set: {
'pinned': false,
},
);
/// Closes the [_ws] connection and resets the [state]
/// If [flushChatPersistence] is true the client deletes all offline
@@ -1275,23 +1299,25 @@ class ClientState {
.map((e) => e.me)
.listen((user) {
_userController.add(user);
if (user?.totalUnreadCount != null) {
_totalUnreadCountController.add(user?.totalUnreadCount);
final totalUnreadCount = user?.totalUnreadCount;
if (totalUnreadCount != null) {
_totalUnreadCountController.add(totalUnreadCount);
}
if (user?.unreadChannels != null) {
_unreadChannelsController.add(user?.unreadChannels);
final unreadChannels = user?.unreadChannels;
if (unreadChannels != null) {
_unreadChannelsController.add(unreadChannels);
}
}),
_client
.on()
.where((event) => event.unreadChannels != null)
.map((e) => e.unreadChannels)
.map((event) => event.unreadChannels)
.whereType<int>()
.listen(_unreadChannelsController.add),
_client
.on()
.where((event) => event.totalUnreadCount != null)
.map((e) => e.totalUnreadCount)
.map((event) => event.totalUnreadCount)
.whereType<int>()
.listen(_totalUnreadCountController.add),
]);
@@ -1305,8 +1331,8 @@ class ClientState {
final _subscriptions = <StreamSubscription>[];
/// Used internally for optimistic update of unread count
set totalUnreadCount(int? unreadCount) {
_totalUnreadCountController.add(unreadCount ?? 0);
set totalUnreadCount(int unreadCount) {
_totalUnreadCountController.add(unreadCount);
}
void _listenChannelHidden() {
@@ -1374,16 +1400,16 @@ class ClientState {
Stream<Map<String, User>> get usersStream => _usersController.stream;
/// The current unread channels count
int? get unreadChannels => _unreadChannelsController.valueOrNull;
int get unreadChannels => _unreadChannelsController.value;
/// The current unread channels count as a stream
Stream<int?> get unreadChannelsStream => _unreadChannelsController.stream;
Stream<int> get unreadChannelsStream => _unreadChannelsController.stream;
/// The current total unread messages count
int? get totalUnreadCount => _totalUnreadCountController.valueOrNull;
int get totalUnreadCount => _totalUnreadCountController.value;
/// The current total unread messages count as a stream
Stream<int?> get totalUnreadCountStream => _totalUnreadCountController.stream;
Stream<int> get totalUnreadCountStream => _totalUnreadCountController.stream;
/// The current list of channels in memory as a stream
Stream<Map<String, Channel>> get channelsStream => _channelsController.stream;
@@ -1399,8 +1425,8 @@ class ClientState {
final _channelsController = BehaviorSubject<Map<String, Channel>>.seeded({});
final _userController = BehaviorSubject<OwnUser?>();
final _usersController = BehaviorSubject<Map<String, User>>.seeded({});
final _unreadChannelsController = BehaviorSubject<int?>();
final _totalUnreadCountController = BehaviorSubject<int?>();
final _unreadChannelsController = BehaviorSubject<int>.seeded(0);
final _totalUnreadCountController = BehaviorSubject<int>.seeded(0);
/// Call this method to dispose this object
void dispose() {
@@ -109,12 +109,16 @@ class ChannelApi {
/// Updates the [channelId] of type [ChannelType] data with [data]
Future<PartialUpdateChannelResponse> updateChannelPartial(
String channelId,
String channelType,
Map<String, dynamic> data,
) async {
String channelType, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
final response = await _client.patch(
_getChannelUrl(channelId, channelType),
data: data,
data: {
if (set != null) 'set': set,
if (unset != null) 'unset': unset,
},
);
return PartialUpdateChannelResponse.fromJson(response.data);
}
@@ -14,11 +14,15 @@ class MessageApi {
Future<SendMessageResponse> sendMessage(
String channelId,
String channelType,
Message message,
) async {
Message message, {
bool skipPush = false,
}) async {
final response = await _client.post(
'/channels/$channelType/$channelId/message',
data: {'message': message},
data: {
'message': message,
'skip_push': skipPush,
},
);
return SendMessageResponse.fromJson(response.data);
}
@@ -56,6 +60,24 @@ class MessageApi {
return UpdateMessageResponse.fromJson(response.data);
}
/// Partially update the given [messageId]
/// Use [set] to define values to be set
/// Use [unset] to define values to be unset
Future<UpdateMessageResponse> partialUpdateMessage(
String messageId, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
final response = await _client.put(
'/messages/$messageId',
data: {
if (set != null) 'set': set,
if (unset != null) 'unset': unset,
},
);
return UpdateMessageResponse.fromJson(response.data);
}
/// Deletes the given [messageId]
Future<EmptyResponse> deleteMessage(
String messageId,
@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/models/user.dart';
@@ -6,7 +7,7 @@ part 'member.g.dart';
/// The class that contains the information about the user membership
/// in a channel
@JsonSerializable()
class Member {
class Member extends Equatable {
/// Constructor used for json serialization
Member({
this.user,
@@ -98,4 +99,19 @@ class Member {
/// Serialize to json
Map<String, dynamic> toJson() => _$MemberToJson(this);
@override
List<Object?> get props => [
user,
inviteAcceptedAt,
inviteRejectedAt,
invited,
role,
userId,
isModerator,
banned,
shadowBanned,
createdAt,
updatedAt,
];
}
@@ -73,7 +73,6 @@ class Message extends Equatable {
this.extraData = const {},
this.deletedAt,
this.status = MessageSendingStatus.sent,
this.skipPush = false,
}) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(),
createdAt = createdAt ?? DateTime.now(),
@@ -158,10 +157,6 @@ class Message extends Equatable {
@JsonKey(defaultValue: false)
final bool silent;
/// If true the message will not send a push notification
@JsonKey(defaultValue: false)
final bool skipPush;
/// If true the message is shadowed
@JsonKey(
includeIfNull: false,
@@ -253,7 +248,6 @@ class Message extends Equatable {
'pinned_at',
'pin_expires',
'pinned_by',
'skip_push',
];
/// Serialize to json
@@ -291,7 +285,6 @@ class Message extends Equatable {
User? pinnedBy,
Map<String, Object?>? extraData,
MessageSendingStatus? status,
bool? skipPush,
}) {
assert(() {
if (pinExpires is! DateTime &&
@@ -331,7 +324,6 @@ class Message extends Equatable {
pinnedBy: pinnedBy ?? this.pinnedBy,
pinExpires:
pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?,
skipPush: skipPush ?? this.skipPush,
);
}
@@ -398,7 +390,6 @@ class Message extends Equatable {
pinnedBy,
extraData,
status,
skipPush,
];
}
@@ -67,7 +67,6 @@ Message _$MessageFromJson(Map<String, dynamic> json) {
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
skipPush: json['skip_push'] as bool? ?? false,
);
}
@@ -97,7 +96,6 @@ Map<String, dynamic> _$MessageToJson(Message instance) {
writeNotNull('thread_participants', readonly(instance.threadParticipants));
val['show_in_channel'] = instance.showInChannel;
val['silent'] = instance.silent;
val['skip_push'] = instance.skipPush;
writeNotNull('shadowed', readonly(instance.shadowed));
writeNotNull('command', readonly(instance.command));
writeNotNull('created_at', readonly(instance.createdAt));
@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
@@ -5,7 +6,7 @@ part 'user.g.dart';
/// The class that defines the user model
@JsonSerializable()
class User {
class User extends Equatable {
/// Constructor used for json serialization
User({
required this.id,
@@ -129,4 +130,17 @@ class User {
banned: banned ?? this.banned,
teams: teams ?? this.teams,
);
@override
List<Object?> get props => [
id,
role,
teams,
createdAt,
updatedAt,
lastActive,
online,
banned,
extraData,
];
}
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names
const PACKAGE_VERSION = '2.0.0-nullsafety.2';
const PACKAGE_VERSION = '2.0.0-nullsafety.5';
+2 -2
View File
@@ -1,7 +1,7 @@
name: stream_chat
homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications.
version: 2.0.0-nullsafety.2
version: 2.0.0-nullsafety.5
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -30,4 +30,4 @@ dev_dependencies:
freezed: ^0.14.1+3
json_serializable: ^4.1.0
mocktail: ^0.1.1
test: ^1.16.8
test: ^1.17.7
@@ -425,6 +425,54 @@ void main() {
});
});
test('`.partialUpdateMessage`', () async {
final message = Message(id: 'test-message-id');
const set = {'text': 'Update Message text'};
const unset = ['pinExpires'];
final updateMessageResponse = UpdateMessageResponse()
..message = message.copyWith(text: set['text'], pinExpires: null);
when(
() => client.partialUpdateMessage(message.id, set: set, unset: unset),
).thenAnswer((_) async => updateMessageResponse);
channel.state?.messagesStream.skip(1).listen(print);
expectLater(
// skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1),
emitsInOrder([
[
isSameMessageAs(
updateMessageResponse.message.copyWith(
status: MessageSendingStatus.sent,
),
matchText: true,
matchSendingStatus: true,
),
],
]),
);
final res = await channel.partialUpdateMessage(
message,
set: set,
unset: unset,
);
expect(res, isNotNull);
expect(res.message.id, message.id);
expect(res.message.id, message.id);
expect(res.message.text, set['text']);
expect(res.message.pinExpires, isNull);
verify(
() => client.partialUpdateMessage(message.id, set: set, unset: unset),
).called(1);
});
group('`.deleteMessage`', () {
test('should work fine', () async {
const messageId = 'test-message-id';
@@ -493,21 +541,21 @@ void main() {
() async {
final message = Message(id: 'test-message-id');
when(() => client.updateMessage(any(that: isSameMessageAs(message))))
.thenAnswer((invocation) async => UpdateMessageResponse()
..message = (invocation.positionalArguments.first as Message)
.copyWith(status: MessageSendingStatus.sent));
when(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith(
pinned: true,
pinExpires: null,
status: MessageSendingStatus.sent,
));
expectLater(
// skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1),
emitsInOrder([
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.updating),
matchSendingStatus: true,
),
],
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
@@ -523,8 +571,11 @@ void main() {
expect(res.message.pinned, isTrue);
expect(res.message.pinExpires, isNull);
verify(() => client.updateMessage(any(that: isSameMessageAs(message))))
.called(1);
verify(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).called(1);
});
test(
@@ -533,21 +584,23 @@ void main() {
final message = Message(id: 'test-message-id');
const timeoutOrExpirationDate = 300; // 300 seconds
when(() => client.updateMessage(any(that: isSameMessageAs(message))))
.thenAnswer((invocation) async => UpdateMessageResponse()
..message = (invocation.positionalArguments.first as Message)
.copyWith(status: MessageSendingStatus.sent));
when(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith(
pinned: true,
pinExpires: DateTime.now().add(
const Duration(seconds: timeoutOrExpirationDate),
),
status: MessageSendingStatus.sent,
));
expectLater(
// skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1),
emitsInOrder([
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.updating),
matchSendingStatus: true,
),
],
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
@@ -566,9 +619,11 @@ void main() {
expect(res.message.pinned, isTrue);
expect(res.message.pinExpires, isNotNull);
verify(() =>
client.updateMessage(any(that: isSameMessageAs(message))))
.called(1);
verify(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).called(1);
},
);
@@ -579,21 +634,21 @@ void main() {
final timeoutOrExpirationDate =
DateTime.now().add(const Duration(days: 3)); // 3 days
when(() => client.updateMessage(any(that: isSameMessageAs(message))))
.thenAnswer((invocation) async => UpdateMessageResponse()
..message = (invocation.positionalArguments.first as Message)
.copyWith(status: MessageSendingStatus.sent));
when(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith(
pinned: true,
pinExpires: timeoutOrExpirationDate,
status: MessageSendingStatus.sent,
));
expectLater(
// skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1),
emitsInOrder([
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.updating),
matchSendingStatus: true,
),
],
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
@@ -613,9 +668,11 @@ void main() {
expect(res.message.pinExpires, isNotNull);
expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc());
verify(
() => client.updateMessage(any(that: isSameMessageAs(message))),
).called(1);
verify(() => client.partialUpdateMessage(
message.id,
set: any(named: 'set'),
unset: any(named: 'unset'),
)).called(1);
},
);
@@ -640,21 +697,19 @@ void main() {
test('`.unpinMessage`', () async {
final message = Message(id: 'test-message-id', pinned: true);
when(() => client.updateMessage(any(that: isSameMessageAs(message))))
.thenAnswer((invocation) async => UpdateMessageResponse()
..message = (invocation.positionalArguments.first as Message)
.copyWith(status: MessageSendingStatus.sent));
when(() => client.partialUpdateMessage(
message.id,
set: {'pinned': false},
)).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith(
pinned: false,
status: MessageSendingStatus.sent,
));
expectLater(
// skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1),
emitsInOrder([
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.updating),
matchSendingStatus: true,
),
],
[
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
@@ -669,44 +724,12 @@ void main() {
expect(res, isNotNull);
expect(res.message.pinned, isFalse);
verify(
() => client.updateMessage(any(that: isSameMessageAs(message))),
).called(1);
verify(() => client.partialUpdateMessage(
message.id,
set: {'pinned': false},
)).called(1);
});
//
// /// Send a file to this channel
// Future<SendFileResponse> sendFile(
// AttachmentFile file, {
// ProgressCallback? onSendProgress,
// CancelToken? cancelToken,
// }) {
// _checkInitialized();
// return _client.sendFile(
// file,
// id!,
// type,
// onSendProgress: onSendProgress,
// cancelToken: cancelToken,
// );
// }
//
// /// Send an image to this channel
// Future<SendImageResponse> sendImage(
// AttachmentFile file, {
// ProgressCallback? onSendProgress,
// CancelToken? cancelToken,
// }) {
// _checkInitialized();
// return _client.sendImage(
// file,
// id!,
// type,
// onSendProgress: onSendProgress,
// cancelToken: cancelToken,
// );
// }
group('`.search`', () {
final filter = Filter.in_('cid', const [channelCid]);
@@ -1123,40 +1146,44 @@ void main() {
});
test('`.updatePartial`', () async {
const channelData = {
const set = {
'name': 'Stream Team',
'profile_image': 'test-profile-image',
};
const unset = ['tag', 'last_name'];
final channelModel = ChannelModel(
cid: channelCid,
extraData: {
'coolness': 999,
...channelData,
...set,
},
);
when(() => client.updateChannelPartial(
channelId,
channelType,
channelData,
set: set,
unset: unset,
)).thenAnswer(
(_) async => PartialUpdateChannelResponse()..channel = channelModel,
);
final res = await channel.updatePartial(channelData);
final res = await channel.updatePartial(set: set, unset: unset);
expect(res, isNotNull);
expect(res.channel.cid, channelModel.cid);
expect(
res.channel.extraData,
{'coolness': 999, ...channelData},
{'coolness': 999, ...set},
);
verify(() => client.updateChannelPartial(
channelId,
channelType,
channelData,
set: set,
unset: unset,
)).called(1);
});
@@ -1380,12 +1407,6 @@ void main() {
when(() => client.markChannelRead(channelId, channelType,
messageId: messageId)).thenAnswer((_) async => EmptyResponse());
expectLater(
// skipping first seed unread count -> 0 unread count
channel.state?.unreadCountStream.skip(1),
emitsInOrder([0]),
);
final res = await channel.markRead(messageId: messageId);
expect(res, isNotNull);
@@ -190,30 +190,39 @@ void main() {
test('updateChannelPartial', () async {
const channelId = 'test-channel-id';
const channelType = 'test-channel-type';
const data = {'name': 'test-channel-name'};
const set = {
'name': 'Stream Team',
'profile_image': 'test-profile-image',
};
const unset = ['tag', 'last_name'];
final path = _getChannelUrl(channelId, channelType);
final channelModel = ChannelModel(
id: channelId,
type: channelType,
extraData: data,
extraData: set,
);
when(() => client.patch(path, data: any(named: 'data')))
.thenAnswer((_) async => successResponse(path, data: {
'channel': channelModel.toJson(),
}));
when(
() => client.patch(path, data: {'set': set, 'unset': unset}),
).thenAnswer((_) async => successResponse(path, data: {
'channel': channelModel.toJson(),
}));
final res = await channelApi.updateChannelPartial(
channelId,
channelType,
data,
set: set,
unset: unset,
);
expect(res, isNotNull);
verify(() => client.patch(path, data: any(named: 'data'))).called(1);
verify(
() => client.patch(path, data: {'set': set, 'unset': unset}),
).called(1);
verifyNoMoreInteractions(client);
});
@@ -110,6 +110,40 @@ void main() {
verifyNoMoreInteractions(client);
});
test('partialUpdateMessage', () async {
const messageId = 'test-message-id';
const set = {'text': 'Update Message text'};
const unset = ['pinExpires'];
const path = '/messages/$messageId';
final message = Message(id: 'test-message-id', text: set['text']);
when(() => client.put(
path,
data: {'set': set, 'unset': unset},
)).thenAnswer(
(_) async => successResponse(path, data: {'message': message.toJson()}),
);
final res = await messageApi.partialUpdateMessage(
messageId,
set: set,
unset: unset,
);
expect(res, isNotNull);
expect(res.message.id, message.id);
expect(res.message.text, set['text']);
expect(res.message.pinExpires, isNull);
verify(() => client.put(
path,
data: {'set': set, 'unset': unset},
)).called(1);
verifyNoMoreInteractions(client);
});
test('deleteMessage', () async {
const messageId = 'test-message-id';
@@ -902,7 +902,6 @@ void main() {
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"skip_push": false,
"silent": false,
"pinned": false,
"pinned_at": null,
@@ -919,7 +918,6 @@ void main() {
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"skip_push": false,
"silent": false,
"pinned": false,
"pinned_at": null,
@@ -929,7 +927,6 @@ void main() {
{
"id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0",
"text": "test message",
"skip_push": false,
"attachments": [],
"parent_id": null,
"quoted_message": null,
@@ -952,7 +949,6 @@ void main() {
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"skip_push": false,
"status": "SENT",
"silent": false,
"pinned": false,
@@ -964,7 +960,6 @@ void main() {
"id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe",
"text": "test",
"attachments": [],
"skip_push": false,
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
@@ -982,7 +977,6 @@ void main() {
"text": "hi",
"attachments": [],
"parent_id": null,
"skip_push": false,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
@@ -1000,7 +994,6 @@ void main() {
"attachments": [],
"parent_id": null,
"quoted_message": null,
"skip_push": false,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
@@ -1023,7 +1016,6 @@ void main() {
"status": "SENT",
"silent": false,
"pinned": false,
"skip_push": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
@@ -1041,7 +1033,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1053,7 +1044,6 @@ void main() {
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"skip_push": false,
"mentioned_users": [],
"status": "SENT",
"silent": false,
@@ -1071,7 +1061,6 @@ void main() {
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"skip_push": false,
"status": "SENT",
"silent": false,
"pinned": false,
@@ -1090,7 +1079,6 @@ void main() {
"mentioned_users": [],
"status": "SENT",
"silent": false,
"skip_push": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
@@ -1100,7 +1088,6 @@ void main() {
"id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055",
"text": "what are you doing?",
"attachments": [],
"skip_push": false,
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
@@ -1118,7 +1105,6 @@ void main() {
"text": "👍",
"attachments": [],
"parent_id": null,
"skip_push": false,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
@@ -1134,7 +1120,6 @@ void main() {
"id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21",
"text": "sdasas",
"attachments": [],
"skip_push": false,
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
@@ -1155,7 +1140,6 @@ void main() {
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"skip_push": false,
"mentioned_users": [],
"status": "SENT",
"silent": false,
@@ -1168,7 +1152,6 @@ void main() {
"id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d",
"text": "nhisagdhsadz",
"attachments": [],
"skip_push": false,
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
@@ -1187,7 +1170,6 @@ void main() {
"attachments": [],
"parent_id": null,
"quoted_message": null,
"skip_push": false,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
@@ -1204,7 +1186,6 @@ void main() {
"attachments": [],
"parent_id": null,
"quoted_message": null,
"skip_push": false,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
@@ -1212,7 +1193,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1229,7 +1209,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1246,7 +1225,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1263,7 +1241,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1280,7 +1257,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1297,7 +1273,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
},
@@ -1314,7 +1289,6 @@ void main() {
"silent": false,
"pinned": false,
"pinned_at": null,
"skip_push": false,
"pin_expires": null,
"pinned_by": null
}
@@ -133,7 +133,6 @@ void main() {
"id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f",
"text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA",
"silent": false,
"skip_push": false,
"attachments": [
{
"type": "video",
+1 -8
View File
@@ -87,15 +87,8 @@ class FakeClientState extends Fake implements ClientState {
@override
OwnUser? get user => OwnUser(id: 'test-user-id');
var _totalUnreadCount = 0;
@override
int? get totalUnreadCount => _totalUnreadCount;
@override
set totalUnreadCount(int? unreadCount) {
_totalUnreadCount += unreadCount ?? 0;
}
int totalUnreadCount = 0;
}
class FakeMessage extends Fake implements Message {}
@@ -40,11 +40,13 @@ class _IsSameEventAs extends Matcher {
Matcher isSameMessageAs(
Message targetMessage, {
bool matchText = false,
bool matchReactions = false,
bool matchSendingStatus = false,
}) =>
_IsSameMessageAs(
targetMessage: targetMessage,
matchText: matchText,
matchReactions: matchReactions,
matchSendingStatus: matchSendingStatus,
);
@@ -52,11 +54,13 @@ Matcher isSameMessageAs(
class _IsSameMessageAs extends Matcher {
const _IsSameMessageAs({
required this.targetMessage,
this.matchText = false,
this.matchReactions = false,
this.matchSendingStatus = false,
});
final Message targetMessage;
final bool matchText;
final bool matchReactions;
final bool matchSendingStatus;
@@ -67,6 +71,9 @@ class _IsSameMessageAs extends Matcher {
@override
bool matches(covariant Message message, Map matchState) {
var matches = message.id == targetMessage.id;
if (matchText) {
matches &= message.text == targetMessage.text;
}
if (matchSendingStatus) {
matches &= message.status == targetMessage.status;
}
@@ -1,3 +1,11 @@
## 2.0.0-nullsafety.5
- Minor fixes and improvements
- Updated `stream_chat_core` dependency
- Performance improvements
- Added pinMessage ui support
- Added `MessageListView.threadSeparatorBuilder` property
## 2.0.0-nullsafety.4
- Minor fixes and improvements
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.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/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/src/utils.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/src/upload_progress_indicator.dart';
// ignore: always_use_package_imports
import 'attachment_widget.dart';
@@ -103,7 +103,7 @@ class FileAttachment extends AttachmentWidget {
Widget _getFileTypeImage(BuildContext context) {
if (isImageAttachment) {
return Material(
clipBehavior: Clip.antiAlias,
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
@@ -154,7 +154,7 @@ class FileAttachment extends AttachmentWidget {
if (isVideoAttachment) {
return Material(
clipBehavior: Clip.antiAlias,
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
@@ -53,7 +53,7 @@ class GiphyAttachment extends AttachmentWidget {
Card(
color: StreamChatTheme.of(context).colorTheme.white,
elevation: 2,
clipBehavior: Clip.antiAlias,
clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(16),
@@ -74,16 +74,16 @@ class ImageAttachment extends AttachmentWidget {
if (imageUri.host == 'stream-io-cdn.com') {
imageUri = imageUri.replace(queryParameters: {
...imageUri.queryParameters,
'h': '500',
'w': '500',
'h': '400',
'w': '400',
'crop': 'center',
'resize': 'crop',
});
} else if (imageUri.host == 'stream-cloud-uploads.imgix.net') {
imageUri = imageUri.replace(queryParameters: {
...imageUri.queryParameters,
'height': '500',
'width': '500',
'height': '400',
'width': '400',
'fit': 'crop',
});
}
@@ -92,10 +92,10 @@ class ImageAttachment extends AttachmentWidget {
return _buildImageAttachment(
context,
CachedNetworkImage(
cacheKey: imageUri.path,
cacheKey: imageUrl,
height: size?.height,
width: size?.width,
placeholder: (_, __) {
placeholder: (context, __) {
final image = Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
@@ -133,8 +133,7 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
}
return InfoTile(
// ignore: avoid_bool_literals_in_conditional_expressions
showMessage: showConnectionStateTile ? showStatus : false,
showMessage: showConnectionStateTile && showStatus,
message: statusString,
child: AppBar(
textTheme: Theme.of(context).textTheme,
@@ -83,26 +83,28 @@ class ChannelImage extends StatelessWidget {
Widget build(BuildContext context) {
final streamChat = StreamChat.of(context);
final channel = this.channel ?? StreamChannel.of(context).channel;
return StreamBuilder<Map<String, dynamic>>(
return BetterStreamBuilder<Map<String, dynamic>>(
stream: channel.extraDataStream,
initialData: channel.extraData,
builder: (context, snapshot) {
builder: (context, data) {
String? image;
final chatThemeData = StreamChatTheme.of(context);
if (snapshot.data!.containsKey('image') == true) {
image = snapshot.data!['image'];
if (data.containsKey('image') == true) {
image = data['image'];
} else if (channel.state?.members.length == 2) {
final otherMember = channel.state?.members
.firstWhere((member) => member.user?.id != streamChat.user?.id);
return StreamBuilder<User>(
stream: streamChat.client.state.usersStream.map(
(users) => users[otherMember?.userId] ?? otherMember!.user!),
return BetterStreamBuilder<User?>(
stream: streamChat.client.state.usersStream
.map((users) =>
users[otherMember?.userId] ?? otherMember!.user!)
.distinct(),
initialData: otherMember!.user,
builder: (context, snapshot) => UserAvatar(
builder: (context, user) => UserAvatar(
borderRadius: borderRadius ??
chatThemeData
.channelPreviewTheme.avatarTheme?.borderRadius,
user: snapshot.data ?? otherMember.user!,
user: user ?? otherMember.user!,
constraints: constraints ??
chatThemeData
.channelPreviewTheme.avatarTheme?.constraints,
@@ -153,9 +155,7 @@ class ChannelImage extends StatelessWidget {
imageUrl: image,
errorWidget: (_, __, ___) => Center(
child: Text(
snapshot.data?.containsKey('name') ?? false
? snapshot.data!['name'][0]
: '',
data.containsKey('name') ? data['name'][0] : '',
style: TextStyle(
color: chatThemeData.colorTheme.white,
fontWeight: FontWeight.bold,
@@ -25,14 +25,14 @@ class ChannelInfo extends StatelessWidget {
@override
Widget build(BuildContext context) {
final client = StreamChat.of(context).client;
return StreamBuilder<List<Member>>(
stream: channel.state?.membersStream,
initialData: channel.state?.members,
builder: (context, snapshot) => ConnectionStatusBuilder(
return BetterStreamBuilder<List<Member>>(
stream: channel.state!.membersStream,
initialData: channel.state!.members,
builder: (context, data) => ConnectionStatusBuilder(
statusBuilder: (context, status) {
switch (status) {
case ConnectionStatus.connected:
return _buildConnectedTitleState(context, snapshot.data);
return _buildConnectedTitleState(context, data);
case ConnectionStatus.connecting:
return _buildConnectingTitleState(context);
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/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:shimmer/shimmer.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/utils.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
@@ -257,10 +256,7 @@ class _ChannelListViewState extends State<ChannelListView> {
}
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: child,
);
return child;
}
Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder(
@@ -477,104 +473,105 @@ class _ChannelListViewState extends State<ChannelListView> {
Widget _listItemBuilder(BuildContext context, int i, List<Channel> channels) {
final channelsBloc = ChannelsBloc.of(context);
final onTap = _getChannelTap(context);
final chatThemeData = StreamChatTheme.of(context);
final backgroundColor = chatThemeData.colorTheme.whiteSmoke;
if (i < channels.length) {
final channel = channels[i];
final onTap = _getChannelTap(context);
final chatThemeData = StreamChatTheme.of(context);
final backgroundColor = chatThemeData.colorTheme.whiteSmoke;
return StreamChannel(
key: ValueKey<String>('CHANNEL-${channel.id}'),
key: ValueKey<String>('CHANNEL-${channel.cid}'),
channel: channel,
child: Builder(
builder: (context) => Slidable(
controller: _slideController,
enabled: widget.swipeToAction,
actionPane: const SlidableBehindActionPane(),
actionExtentRatio: 0.12,
secondaryActions: widget.swipeActions
?.map((e) => IconSlideAction(
color: e.color,
iconWidget: e.iconWidget,
onTap: () {
e.onTap?.call(channel);
},
))
.toList() ??
<Widget>[
child: Slidable(
controller: _slideController,
enabled: widget.swipeToAction,
actionPane: const SlidableBehindActionPane(),
actionExtentRatio: 0.12,
secondaryActions: widget.swipeActions
?.map((e) => IconSlideAction(
color: e.color,
iconWidget: e.iconWidget,
onTap: () {
e.onTap?.call(channel);
},
))
.toList() ??
<Widget>[
IconSlideAction(
color: backgroundColor,
icon: Icons.more_horiz,
onTap: widget.onMoreDetailsPressed != null
? () {
widget.onMoreDetailsPressed!(channel);
}
: () {
showModalBottomSheet(
clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
context: context,
builder: (context) => StreamChannel(
channel: channel,
child: ChannelBottomSheet(
onViewInfoTap: () {
widget.onViewInfoTap?.call(channel);
},
),
),
);
},
),
if ([
'admin',
'owner',
].contains(channel.state!.members
.firstWhereOrNull(
(m) => m.userId == channel.client.state.user?.id)
?.role))
IconSlideAction(
color: backgroundColor,
icon: Icons.more_horiz,
onTap: widget.onMoreDetailsPressed != null
iconWidget: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed,
),
onTap: widget.onDeletePressed != null
? () {
widget.onMoreDetailsPressed!(channel);
widget.onDeletePressed!(channel);
}
: () {
showModalBottomSheet(
clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
context: context,
builder: (context) => StreamChannel(
channel: channel,
child: ChannelBottomSheet(
onViewInfoTap: () {
widget.onViewInfoTap?.call(channel);
},
),
: () async {
final res = await showConfirmationDialog(
context,
title: 'Delete Conversation',
okText: 'DELETE',
question:
// ignore: lines_longer_than_80_chars
'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed,
),
);
if (res == true) {
await channel.delete();
}
},
),
if ([
'admin',
'owner',
].contains(channel.state!.members
.firstWhereOrNull(
(m) => m.userId == channel.client.state.user?.id)
?.role))
IconSlideAction(
color: backgroundColor,
iconWidget: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed,
),
onTap: widget.onDeletePressed != null
? () {
widget.onDeletePressed!(channel);
}
: () async {
final res = await showConfirmationDialog(
context,
title: 'Delete Conversation',
okText: 'DELETE',
question:
// ignore: lines_longer_than_80_chars
'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed,
),
);
if (res == true) {
await channel.delete();
}
},
),
],
child: Container(
],
child: DecoratedBox(
decoration: BoxDecoration(
color: chatThemeData.colorTheme.whiteSnow,
child: widget.channelPreviewBuilder?.call(context, channel) ??
ChannelPreview(
onLongPress: widget.onChannelLongPress,
channel: channel,
onImageTap: () => widget.onImageTap?.call(channel),
onTap: (channel) => onTap(channel, widget.channelWidget),
),
),
child: widget.channelPreviewBuilder?.call(context, channel) ??
ChannelPreview(
onLongPress: widget.onChannelLongPress,
channel: channel,
onImageTap: () => widget.onImageTap?.call(channel),
onTap: (channel) => onTap(channel, widget.channelWidget),
),
),
),
);
@@ -648,12 +645,10 @@ class _ChannelListViewState extends State<ChannelListView> {
context,
ChannelsBlocState channelsProvider,
) =>
StreamBuilder<bool>(
BetterStreamBuilder<bool>(
stream: channelsProvider.queryChannelsLoading,
initialData: false,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
errorBuilder: (context, err) => Container(
color: StreamChatTheme.of(context)
.colorTheme
.accentRed
@@ -664,17 +659,15 @@ class _ChannelListViewState extends State<ChannelListView> {
child: Text('Error loading channels'),
),
),
);
}
return snapshot.data!
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
)
: const Offstage();
});
),
builder: (context, data) => data
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
)
: const Offstage());
Widget _separatorBuilder(context, i) {
final effect = StreamChatTheme.of(context).colorTheme.borderBottom;
@@ -26,11 +26,11 @@ class ChannelName extends StatelessWidget {
final client = StreamChat.of(context);
final channel = StreamChannel.of(context).channel;
return StreamBuilder<Map<String, dynamic>>(
return BetterStreamBuilder<Map<String, Object?>>(
stream: channel.extraDataStream,
initialData: channel.extraData,
builder: (context, snapshot) => _buildName(
snapshot.data!,
builder: (context, data) => _buildName(
data,
channel.state?.members,
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/widgets.dart';
import 'package:jiffy/jiffy.dart';
@@ -69,12 +70,12 @@ class ChannelPreview extends StatelessWidget {
Widget build(BuildContext context) {
final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme;
final streamChatState = StreamChat.of(context);
return StreamBuilder<bool>(
return BetterStreamBuilder<bool>(
stream: channel.isMutedStream,
initialData: channel.isMuted,
builder: (context, snapshot) => Opacity(
opacity: snapshot.data! ? 0.5 : 1,
builder: (context, data) => AnimatedOpacity(
opacity: data ? 0.5 : 1,
duration: const Duration(milliseconds: 300),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(
@@ -103,14 +104,16 @@ class ChannelPreview extends StatelessWidget {
textStyle: channelPreviewTheme.title,
),
),
StreamBuilder<List<Member>>(
BetterStreamBuilder<List<Member>?>(
stream: channel.state?.membersStream,
initialData: channel.state?.members,
builder: (context, snapshot) {
if (!snapshot.hasData ||
snapshot.data!.isEmpty ||
!snapshot.data!.any((Member e) =>
e.user!.id == channel.client.state.user?.id)) {
comparator: const ListEquality().equals,
builder: (context, members) {
if (members?.isEmpty == true ||
members?.any((Member e) =>
e.user!.id ==
channel.client.state.user?.id) !=
true) {
return const SizedBox();
}
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,
initialData: channel.lastMessageAt,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
builder: (context, data) {
if (data == null) {
return const Offstage();
}
final lastMessageAt = snapshot.data!.toLocal();
final lastMessageAt = data.toLocal();
String stringDate;
final now = DateTime.now();
@@ -219,12 +222,12 @@ class ChannelPreview extends StatelessWidget {
}
Widget _buildLastMessage(BuildContext context) =>
StreamBuilder<List<Message>?>(
BetterStreamBuilder<List<Message>?>(
stream: channel.state!.messagesStream,
initialData: channel.state!.messages,
builder: (context, snapshot) {
final lastMessage = snapshot.data
?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted);
builder: (context, data) {
final lastMessage =
data?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted);
if (lastMessage == null) {
return const SizedBox();
}
@@ -12,15 +12,11 @@ class ConnectionStatusBuilder extends StatelessWidget {
const ConnectionStatusBuilder({
Key? key,
required this.statusBuilder,
this.initialStatus = ConnectionStatus.disconnected,
this.connectionStatusStream,
this.errorBuilder,
this.loadingBuilder,
}) : 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.
final Stream<ConnectionStatus>? connectionStatusStream;
@@ -38,22 +34,18 @@ class ConnectionStatusBuilder extends StatelessWidget {
Widget build(BuildContext context) {
final stream = connectionStatusStream ??
StreamChat.of(context).client.wsConnectionStatusStream;
return StreamBuilder<ConnectionStatus>(
initialData: initialStatus,
final client = StreamChat.of(context).client;
return BetterStreamBuilder<ConnectionStatus>(
initialData: client.wsConnectionStatus,
stream: stream,
builder: (context, snapshot) {
if (snapshot.hasError) {
if (errorBuilder != null) {
return errorBuilder!(context, snapshot.error);
}
return const Offstage();
loadingBuilder: loadingBuilder,
errorBuilder: (context, error) {
if (errorBuilder != null) {
return errorBuilder!(context, error);
}
if (!snapshot.hasData) {
if (loadingBuilder != null) return loadingBuilder!(context);
return const Offstage();
}
return statusBuilder(context, snapshot.data!);
return const Offstage();
},
builder: statusBuilder,
);
}
}
@@ -114325,6 +114325,9 @@ class Emoji {
/// Get all 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
static Emoji? byChar(String 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/stream_chat_flutter.dart';
final _emojis = Emoji.all();
final _emojiChars = Emoji.chars();
/// String extension
extension StringExtension on String {
@@ -17,10 +17,10 @@ extension StringExtension on String {
/// 1 to 3 emojis: big size with no text bubble.
/// 4+ emojis or emojis+text: standard size with text bubble.
bool get isOnlyEmoji {
if (isEmpty) return false;
if (length > 3) return false;
final characters = trim().characters;
if (characters.isEmpty) return false;
if (characters.length > 3) return false;
return characters.every((c) => _emojis.map((e) => e.char).contains(c));
return characters.every(_emojiChars.contains);
}
}
@@ -27,6 +27,8 @@ class MessageActionsModal extends StatefulWidget {
this.showResendMessage = true,
this.showThreadReplyMessage = true,
this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = false,
this.showUserAvatar = DisplayWidget.show,
this.editMessageInputBuilder,
this.messageShape,
@@ -35,6 +37,7 @@ class MessageActionsModal extends StatefulWidget {
this.customActions = const [],
this.attachmentBorderRadiusGeometry,
this.onCopyTap,
this.textBuilder,
}) : super(key: key);
/// Builder for edit message
@@ -79,6 +82,12 @@ class MessageActionsModal extends StatefulWidget {
/// Flag for showing flag action
final bool showFlagButton;
/// Flag for showing pin action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Flag for reversing message
final bool reverse;
@@ -97,6 +106,9 @@ class MessageActionsModal extends StatefulWidget {
/// List of custom actions
final List<MessageAction> customActions;
/// Customize the MessageWidget textBuilder
final Widget Function(BuildContext context, Message message)? textBuilder;
@override
_MessageActionsModalState createState() => _MessageActionsModalState();
}
@@ -135,6 +147,127 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
widget.message.attachments.any((it) => it.type == 'file') == true;
final streamChatThemeData = StreamChatTheme.of(context);
final numberOfReactions = streamChatThemeData.reactionIcons.length;
final shiftFactor =
numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0;
final child = Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: <Widget>[
if (widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent))
Align(
alignment: Alignment(
user?.id == widget.message.user?.id
? (divFactor >= 1.0
? -0.2 - shiftFactor
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2 + shiftFactor
: -(1.2 - divFactor)),
0),
child: ReactionPicker(
message: widget.message,
),
),
const SizedBox(height: 8),
IgnorePointer(
child: MessageWidget(
key: const Key('MessageWidget'),
reverse: widget.reverse,
attachmentBorderRadiusGeometry: widget
.attachmentBorderRadiusGeometry
?.mirrorBorderIfReversed(reverse: !widget.reverse),
message: widget.message.copyWith(
text: widget.message.text!.length > 200
// ignore: lines_longer_than_80_chars
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
messageTheme: widget.messageTheme,
showReactions: false,
showUsername: false,
showReplyMessage: false,
showUserAvatar: widget.showUserAvatar,
attachmentPadding: EdgeInsets.all(
hasFileAttachment ? 4 : 2,
),
showTimestamp: false,
translateUserAvatar: false,
padding: const EdgeInsets.all(0),
textPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: widget.message.text!.isOnlyEmoji ? 0 : 16.0,
),
showReactionPickerIndicator: widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent),
showSendingIndicator: false,
shape: widget.messageShape,
attachmentShape: widget.attachmentShape,
showPinHighlight: false,
textBuilder: widget.textBuilder,
),
),
const SizedBox(height: 8),
Padding(
padding: EdgeInsets.only(
left: widget.reverse ? 0 : 40,
),
child: SizedBox(
width: mediaQueryData.size.width * 0.75,
child: Material(
color: streamChatThemeData.colorTheme.whiteSnow,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.showReplyMessage &&
widget.message.status == MessageSendingStatus.sent)
_buildReplyButton(context),
if (widget.showThreadReplyMessage &&
(widget.message.status ==
MessageSendingStatus.sent) &&
widget.message.parentId == null)
_buildThreadReplyButton(context),
if (widget.showResendMessage)
_buildResendMessage(context),
if (widget.showEditMessage) _buildEditMessage(context),
if (widget.showCopyMessage) _buildCopyButton(context),
if (widget.showFlagButton) _buildFlagButton(context),
if (widget.showPinButton) _buildPinButton(context),
if (widget.showDeleteMessage)
_buildDeleteButton(context),
...widget.customActions
.map((action) => _buildCustomAction(
context,
action,
))
].insertBetween(
Container(
height: 1,
color: streamChatThemeData.colorTheme.greyWhisper,
),
),
),
),
),
),
],
),
),
),
);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Navigator.maybePop(context),
@@ -156,132 +289,11 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, snapshot) => Transform.scale(
builder: (context, val, child) => Transform.scale(
scale: val,
child: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: <Widget>[
if (widget.showReactions &&
(widget.message.status ==
MessageSendingStatus.sent))
Align(
alignment: Alignment(
user?.id == widget.message.user?.id
? (divFactor >= 1.0
? -0.2
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2
: -(1.2 - divFactor)),
0),
child: ReactionPicker(
message: widget.message,
),
),
const SizedBox(height: 8),
IgnorePointer(
child: MessageWidget(
key: const Key('MessageWidget'),
reverse: widget.reverse,
attachmentBorderRadiusGeometry: widget
.attachmentBorderRadiusGeometry
?.mirrorBorderIfReversed(
reverse: !widget.reverse),
message: widget.message.copyWith(
text: widget.message.text!.length > 200
// ignore: lines_longer_than_80_chars
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
messageTheme: widget.messageTheme,
showReactions: false,
showUsername: false,
showReplyMessage: false,
showUserAvatar: widget.showUserAvatar,
attachmentPadding: EdgeInsets.all(
hasFileAttachment ? 4 : 2,
),
showTimestamp: false,
translateUserAvatar: false,
padding: const EdgeInsets.all(0),
textPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal:
widget.message.text!.isOnlyEmoji ? 0 : 16.0,
),
showReactionPickerIndicator:
widget.showReactions &&
(widget.message.status ==
MessageSendingStatus.sent),
showSendingIndicator: false,
shape: widget.messageShape,
attachmentShape: widget.attachmentShape,
),
),
const SizedBox(height: 8),
Padding(
padding: EdgeInsets.only(
left: widget.reverse ? 0 : 40,
),
child: SizedBox(
width: mediaQueryData.size.width * 0.75,
child: Material(
color: streamChatThemeData.colorTheme.whiteSnow,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
if (widget.showReplyMessage &&
widget.message.status ==
MessageSendingStatus.sent)
_buildReplyButton(context),
if (widget.showThreadReplyMessage &&
(widget.message.status ==
MessageSendingStatus.sent) &&
widget.message.parentId == null)
_buildThreadReplyButton(context),
if (widget.showResendMessage)
_buildResendMessage(context),
if (widget.showEditMessage)
_buildEditMessage(context),
if (widget.showCopyMessage)
_buildCopyButton(context),
if (widget.showFlagButton)
_buildFlagButton(context),
if (widget.showDeleteMessage)
_buildDeleteButton(context),
...widget.customActions
.map((action) => _buildCustomAction(
context,
action,
))
].insertBetween(
Container(
height: 1,
color: streamChatThemeData
.colorTheme.greyWhisper,
),
),
),
),
),
),
],
),
),
),
),
child: child,
),
child: child,
),
],
),
@@ -360,6 +372,21 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
}
}
void _togglePin() async {
final channel = StreamChannel.of(context).channel;
Navigator.pop(context);
try {
if (!widget.message.pinned) {
await channel.pinMessage(widget.message);
} else {
await channel.unpinMessage(widget.message);
}
} catch (e) {
_showErrorAlert();
}
}
void _showDeleteDialog() async {
setState(() {
_showActions = false;
@@ -452,6 +479,29 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
);
}
Widget _buildPinButton(BuildContext context) {
final streamChatThemeData = StreamChatTheme.of(context);
return InkWell(
onTap: _togglePin,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
child: Row(
children: [
StreamSvgIcon.pin(
color: streamChatThemeData.primaryIconTheme.color,
size: 24,
),
const SizedBox(width: 16),
Text(
'${widget.message.pinned ? 'Unpin from' : 'Pin to'} Conversation',
style: streamChatThemeData.textTheme.body,
),
],
),
),
);
}
Widget _buildDeleteButton(BuildContext context) {
final isDeleteFailed =
widget.message.status == MessageSendingStatus.failed_delete;
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart';
@@ -156,8 +157,12 @@ class MessageListView extends StatefulWidget {
this.onMessageTap,
this.onSystemMessageTap,
this.onAttachmentTap,
this.textBuilder,
this.onLinkTap,
this.pinPermissions = const [],
this.textBuilder,
this.usernameBuilder,
this.showFloatingDateDivider = true,
this.threadSeparatorBuilder,
}) : super(key: key);
/// Function used to build a custom message widget
@@ -224,6 +229,9 @@ class MessageListView extends StatefulWidget {
/// Flag for showing tile on header
final bool showConnectionStateTile;
/// Flag for showing the floating date divider
final bool showFloatingDateDivider;
/// Function called when messages are fetched
final Widget Function(BuildContext, List<Message>)? messageListBuilder;
@@ -259,11 +267,20 @@ class MessageListView extends StatefulWidget {
final void Function(Message message, Attachment attachment)? onAttachmentTap;
/// Customize the MessageWidget textBuilder
final void Function(BuildContext context, Message message)? textBuilder;
final Widget Function(BuildContext context, Message message)? textBuilder;
/// Customize the MessageWidget usernameBuilder
final Widget Function(BuildContext context, Message message)? usernameBuilder;
/// Callback for when link is tapped
final void Function(String link)? onLinkTap;
/// A List of user types that have permission to pin messages
final List<String> pinPermissions;
/// Builder used to build the thread separator in case it's a thread view
final WidgetBuilder? threadSeparatorBuilder;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -273,8 +290,10 @@ class _MessageListViewState extends State<MessageListView> {
void Function(Message)? _onThreadTap;
bool _showScrollToBottom = false;
late final ItemPositionsListener _itemPositionListener;
late final Stream<Iterable<ItemPosition>> _itemPositionStream;
int? _messageListLength;
StreamChannelState? streamChannel;
late StreamChatThemeData _streamTheme;
int? get _initialIndex {
if (widget.initialScrollIndex != null) return widget.initialScrollIndex;
@@ -316,37 +335,34 @@ class _MessageListViewState extends State<MessageListView> {
final MessageListController _messageListController = MessageListController();
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return MessageListCore(
messageFilter: widget.messageFilter,
loadingBuilder: widget.loadingBuilder ??
(context) => const Center(
child: CircularProgressIndicator(),
),
emptyBuilder: widget.emptyBuilder ??
(context) => Center(
child: Text(
'No chats here yet...',
style: chatThemeData.textTheme.footnote.copyWith(
color: chatThemeData.colorTheme.black.withOpacity(.5)),
Widget build(BuildContext context) => MessageListCore(
messageFilter: widget.messageFilter,
loadingBuilder: widget.loadingBuilder ??
(context) => const Center(
child: CircularProgressIndicator(),
),
),
messageListBuilder:
widget.messageListBuilder ?? (context, list) => _buildListView(list),
messageListController: _messageListController,
parentMessage: widget.parentMessage,
showScrollToBottom: widget.showScrollToBottom,
errorWidgetBuilder: widget.errorWidgetBuilder ??
(BuildContext context, Object error) => Center(
child: Text(
'Something went wrong',
style: chatThemeData.textTheme.footnote.copyWith(
color: chatThemeData.colorTheme.black.withOpacity(.5)),
emptyBuilder: widget.emptyBuilder ??
(context) => Center(
child: Text(
'No chats here yet...',
style: _streamTheme.textTheme.footnote.copyWith(
color: _streamTheme.colorTheme.black.withOpacity(.5)),
),
),
),
);
}
messageListBuilder: widget.messageListBuilder ??
(context, list) => _buildListView(list),
messageListController: _messageListController,
parentMessage: widget.parentMessage,
showScrollToBottom: widget.showScrollToBottom,
errorWidgetBuilder: widget.errorWidgetBuilder ??
(BuildContext context, Object error) => Center(
child: Text(
'Something went wrong',
style: _streamTheme.textTheme.footnote.copyWith(
color: _streamTheme.colorTheme.black.withOpacity(.5)),
),
),
);
Widget _buildListView(List<Message> data) {
messages = data;
@@ -392,8 +408,7 @@ class _MessageListViewState extends State<MessageListView> {
}
return InfoTile(
// ignore: avoid_bool_literals_in_conditional_expressions
showMessage: widget.showConnectionStateTile ? showStatus : false,
showMessage: widget.showConnectionStateTile && showStatus,
tileAnchor: Alignment.topCenter,
childAnchor: Alignment.topCenter,
message: statusString,
@@ -432,29 +447,14 @@ class _MessageListViewState extends State<MessageListView> {
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: true,
addAutomaticKeepAlives: false,
itemCount:
messages.length + 2 + (_isThreadConversation ? 1 : 0),
separatorBuilder: (context, i) {
if (i == messages.length) return const Offstage();
if (i == 0) return const SizedBox(height: 30);
if (i == messages.length + 1) {
final replyCount = widget.parentMessage!.replyCount;
final chatThemeData = StreamChatTheme.of(context);
return Container(
decoration: BoxDecoration(
gradient: chatThemeData.colorTheme.bgGradient,
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
// ignore: lines_longer_than_80_chars
'$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}',
textAlign: TextAlign.center,
style: chatThemeData
.channelTheme.channelHeaderTheme.subtitle,
),
),
);
return _buildThreadSeparator();
}
final message = messages[i];
@@ -560,40 +560,73 @@ class _MessageListViewState extends State<MessageListView> {
},
),
if (widget.showScrollToBottom) _buildScrollToBottom(),
Positioned(
top: 20,
child: ValueListenableBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions,
builder: (context, values, _) {
final items = _itemPositionListener.itemPositions.value;
if (items.isEmpty || messages.isEmpty) {
return const SizedBox();
}
var index = _getTopElement(values)?.index;
if (index == null || index > messages.length) {
return const SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder!(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
},
),
),
if (widget.showFloatingDateDivider) _buildFloatingDateDivider(),
],
);
}
Widget _buildThreadSeparator() {
if (widget.threadSeparatorBuilder != null) {
return widget.threadSeparatorBuilder!.call(context);
}
final replyCount = widget.parentMessage!.replyCount;
return DecoratedBox(
decoration: BoxDecoration(
gradient: _streamTheme.colorTheme.bgGradient,
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
// ignore: lines_longer_than_80_chars
'$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}',
textAlign: TextAlign.center,
style: _streamTheme.channelTheme.channelHeaderTheme.subtitle,
),
),
);
}
Positioned _buildFloatingDateDivider() => Positioned(
top: 20,
child: BetterStreamBuilder<Iterable<ItemPosition>>(
initialData: _itemPositionListener.itemPositions.value,
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;
if (items.isEmpty || messages.isEmpty) {
return const SizedBox();
}
var index = _getTopElement(values)?.index;
if (index == null || index > messages.length) {
return const SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder!(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
},
),
);
Future<void> _paginateData(
StreamChannelState? channel, QueryDirection direction) =>
_messageListController.paginateData!(direction: direction);
@@ -612,8 +645,8 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2(
streamChannel!.channel.state!.isUpToDateStream,
streamChannel!.channel.state!.unreadCountStream,
streamChannel!.channel.state!.isUpToDateStream.distinct(),
streamChannel!.channel.state!.unreadCountStream.distinct(),
(bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount),
),
builder: (_, snapshot) {
@@ -631,7 +664,6 @@ class _MessageListViewState extends State<MessageListView> {
final showUnreadCount = unreadCount > 0 &&
streamChannel!.channel.state!.members.any((e) =>
e.userId == streamChannel!.channel.client.state.user!.id);
final chatThemeData = StreamChatTheme.of(context);
return Positioned(
bottom: 8,
right: 8,
@@ -641,7 +673,7 @@ class _MessageListViewState extends State<MessageListView> {
clipBehavior: Clip.none,
children: [
FloatingActionButton(
backgroundColor: chatThemeData.colorTheme.white,
backgroundColor: _streamTheme.colorTheme.white,
onPressed: () {
if (unreadCount > 0) {
streamChannel!.channel.markRead();
@@ -660,7 +692,7 @@ class _MessageListViewState extends State<MessageListView> {
}
},
child: StreamSvgIcon.down(
color: chatThemeData.colorTheme.black,
color: _streamTheme.colorTheme.black,
),
),
if (showUnreadCount)
@@ -691,44 +723,13 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildLoadingIndicator(
StreamChannelState streamChannel,
QueryDirection direction,
) {
final stream = direction == QueryDirection.top
? streamChannel.queryTopMessages
: streamChannel.queryBottomMessages;
return StreamBuilder<bool>(
key: const Key('LOADING-INDICATOR'),
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(),
),
);
},
);
}
) =>
_LoadingIndicator(
direction: direction,
streamTheme: _streamTheme,
streamChannel: streamChannel,
isThreadConversation: _isThreadConversation,
);
Widget _buildTopMessage(
BuildContext context,
@@ -795,7 +796,9 @@ class _MessageListViewState extends State<MessageListView> {
}
}
if (mounted) {
setState(() => _showScrollToBottom = !isVisible);
if (_showScrollToBottom == isVisible) {
setState(() => _showScrollToBottom = !isVisible);
}
}
},
child: messageWidget,
@@ -807,8 +810,11 @@ class _MessageListViewState extends State<MessageListView> {
) {
final isMyMessage = message.user!.id == StreamChat.of(context).user!.id;
final isOnlyEmoji = message.text!.isOnlyEmoji;
final currentUser = StreamChat.of(context).user;
final members = StreamChannel.of(context).channel.state?.members ?? [];
final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id);
final chatThemeData = StreamChatTheme.of(context);
return MessageWidget(
showReplyMessage: false,
showResendMessage: false,
@@ -837,8 +843,8 @@ class _MessageListViewState extends State<MessageListView> {
borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null,
showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show,
messageTheme: isMyMessage
? chatThemeData.ownMessageTheme
: chatThemeData.otherMessageTheme,
? _streamTheme.ownMessageTheme
: _streamTheme.otherMessageTheme,
onShowMessage: widget.onShowMessage,
onReturnAction: (action) {
switch (action) {
@@ -857,9 +863,10 @@ class _MessageListViewState extends State<MessageListView> {
}
FocusScope.of(context).unfocus();
},
textBuilder:
widget.textBuilder as Widget Function(BuildContext, Message)?,
textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
);
}
@@ -946,7 +953,11 @@ class _MessageListViewState extends State<MessageListView> {
? BorderSide.none
: null;
final chatThemeData = StreamChatTheme.of(context);
final currentUser = StreamChat.of(context).user;
final members = StreamChannel.of(context).channel.state?.members ?? [];
final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id);
Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
@@ -1033,8 +1044,8 @@ class _MessageListViewState extends State<MessageListView> {
horizontal: isOnlyEmoji ? 0 : 16.0,
),
messageTheme: isMyMessage
? chatThemeData.ownMessageTheme
: chatThemeData.otherMessageTheme,
? _streamTheme.ownMessageTheme
: _streamTheme.otherMessageTheme,
readList: readList,
allRead: allRead,
onShowMessage: widget.onShowMessage,
@@ -1056,9 +1067,10 @@ class _MessageListViewState extends State<MessageListView> {
FocusScope.of(context).unfocus();
},
onAttachmentTap: widget.onAttachmentTap,
textBuilder:
widget.textBuilder as Widget Function(BuildContext, Message)?,
textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
);
if (!message.isDeleted &&
@@ -1074,7 +1086,7 @@ class _MessageListViewState extends State<MessageListView> {
widget.onMessageSwiped?.call(message);
},
backgroundIcon: StreamSvgIcon.reply(
color: chatThemeData.colorTheme.accentBlue,
color: _streamTheme.colorTheme.accentBlue,
),
child: child,
),
@@ -1084,7 +1096,7 @@ class _MessageListViewState extends State<MessageListView> {
if (!initialMessageHighlightComplete &&
widget.highlightInitialMessage &&
_isInitialMessage(message.id)) {
final colorTheme = chatThemeData.colorTheme;
final colorTheme = _streamTheme.colorTheme;
final highlightColor =
widget.messageHighlightColor ?? colorTheme.highlight;
child = TweenAnimationBuilder<Color?>(
@@ -1114,6 +1126,8 @@ class _MessageListViewState extends State<MessageListView> {
_scrollController = widget.scrollController ?? ItemScrollController();
_itemPositionListener =
widget.itemPositionListener ?? ItemPositionsListener.create();
_itemPositionStream =
_valueListenableToStreamAdapter(_itemPositionListener.itemPositions);
_getOnThreadTap();
super.initState();
@@ -1122,6 +1136,7 @@ class _MessageListViewState extends State<MessageListView> {
@override
void didChangeDependencies() {
final newStreamChannel = StreamChannel.of(context);
_streamTheme = StreamChatTheme.of(context);
if (newStreamChannel != streamChannel) {
streamChannel = newStreamChannel;
@@ -1167,14 +1182,14 @@ class _MessageListViewState extends State<MessageListView> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamBuilder<Message>(
builder: (_) => BetterStreamBuilder<Message>(
stream: streamChannel!.channel.state!.messagesStream.map(
(messages) =>
messages!.firstWhere((m) => m.id == message.id)),
initialData: message,
builder: (_, snapshot) => StreamChannel(
builder: (_, data) => StreamChannel(
channel: streamChannel!.channel,
child: widget.threadBuilder!(context, snapshot.data),
child: widget.threadBuilder!(context, data),
),
),
),
@@ -1192,3 +1207,79 @@ class _MessageListViewState extends State<MessageListView> {
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;
}
@@ -23,6 +23,7 @@ class MessageReactionsModal extends StatelessWidget {
this.showUserAvatar = DisplayWidget.show,
this.onUserAvatarTap,
this.attachmentBorderRadiusGeometry,
this.textBuilder,
}) : super(key: key);
/// Message to display reactions of
@@ -52,6 +53,9 @@ class MessageReactionsModal extends StatelessWidget {
/// [BorderRadius] to apply to attachments
final BorderRadius? attachmentBorderRadiusGeometry;
/// Customize the MessageWidget textBuilder
final Widget Function(BuildContext context, Message message)? textBuilder;
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -73,111 +77,119 @@ class MessageReactionsModal extends StatelessWidget {
final divFactor = message.attachments.isNotEmpty == true
? 1
: (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize));
final hasFileAttachment =
message.attachments.any((it) => it.type == 'file') == true;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutBack,
builder: (context, val, snapshot) {
final hasFileAttachment =
message.attachments.any((it) => it.type == 'file') == true;
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: StreamChatTheme.of(context).colorTheme.overlay,
final numberOfReactions = StreamChatTheme.of(context).reactionIcons.length;
final shiftFactor =
numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0;
final child = Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (showReactions &&
(message.status == MessageSendingStatus.sent))
Align(
alignment: Alignment(
user!.id == message.user!.id
? (divFactor >= 1.0
? -0.2 - shiftFactor
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2 + shiftFactor
: -(1.2 - divFactor)),
0),
child: ReactionPicker(
message: message,
),
),
),
Transform.scale(
scale: val,
child: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (showReactions &&
(message.status == MessageSendingStatus.sent))
Align(
alignment: Alignment(
user!.id == message.user!.id
? (divFactor >= 1.0
? -0.2
: (1.2 - divFactor))
: (divFactor >= 1.0
? 0.2
: -(1.2 - divFactor)),
0),
child: ReactionPicker(
message: message,
),
),
const SizedBox(height: 8),
IgnorePointer(
child: MessageWidget(
key: const Key('MessageWidget'),
reverse: reverse,
message: message.copyWith(
text: message.text!.length > 200
? '${message.text!.substring(0, 200)}...'
: message.text,
),
messageTheme: messageTheme,
showReactions: false,
showUsername: false,
showUserAvatar: showUserAvatar,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: false,
shape: messageShape,
attachmentShape: attachmentShape,
padding: const EdgeInsets.all(0),
attachmentBorderRadiusGeometry:
attachmentBorderRadiusGeometry
?.mirrorBorderIfReversed(
reverse: !reverse),
attachmentPadding: EdgeInsets.all(
hasFileAttachment ? 4 : 2,
),
textPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal:
message.text!.isOnlyEmoji ? 0 : 16.0,
),
showReactionPickerIndicator: showReactions &&
(message.status == MessageSendingStatus.sent),
),
),
if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8),
_buildReactionCard(context),
]
],
),
),
const SizedBox(height: 8),
IgnorePointer(
child: MessageWidget(
key: const Key('MessageWidget'),
reverse: reverse,
message: message.copyWith(
text: message.text!.length > 200
? '${message.text!.substring(0, 200)}...'
: message.text,
),
messageTheme: messageTheme,
showReactions: false,
showUsername: false,
showUserAvatar: showUserAvatar,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: false,
shape: messageShape,
attachmentShape: attachmentShape,
padding: const EdgeInsets.all(0),
attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry
?.mirrorBorderIfReversed(reverse: !reverse),
attachmentPadding: EdgeInsets.all(
hasFileAttachment ? 4 : 2,
),
textPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: message.text!.isOnlyEmoji ? 0 : 16.0,
),
showReactionPickerIndicator: showReactions &&
(message.status == MessageSendingStatus.sent),
textBuilder: textBuilder,
showPinHighlight: false,
),
),
if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8),
_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) {
final currentUser = StreamChat.of(context).user;
Widget _buildReactionCard(BuildContext context, User? user) {
final chatThemeData = StreamChatTheme.of(context);
return Card(
color: chatThemeData.colorTheme.white,
@@ -204,7 +216,7 @@ class MessageReactionsModal extends StatelessWidget {
children: message.latestReactions!
.map((e) => _buildReaction(
e,
currentUser!,
user!,
context,
))
.toList(),
@@ -83,6 +83,8 @@ class MessageWidget extends StatefulWidget {
this.showResendMessage = true,
this.showCopyMessage = true,
this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = true,
this.onUserAvatarTap,
this.onLinkTap,
this.onMessageActions,
@@ -102,6 +104,7 @@ class MessageWidget extends StatefulWidget {
this.onQuotedMessageTap,
this.customActions = const [],
this.onAttachmentTap,
this.usernameBuilder,
}) : attachmentBuilders = {
'image': (context, message, attachments) {
final border = RoundedRectangleBorder(
@@ -265,6 +268,9 @@ class MessageWidget extends StatefulWidget {
/// Widget builder for building text
final Widget Function(BuildContext, Message)? textBuilder;
/// Widget builder for building username
final Widget Function(BuildContext, Message)? usernameBuilder;
/// Function called on long press
final void Function(BuildContext, Message)? onMessageActions;
@@ -367,6 +373,12 @@ class MessageWidget extends StatefulWidget {
/// Show flag action
final bool showFlagButton;
/// Show flag action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Builder for respective attachment types
final Map<String, AttachmentBuilder> attachmentBuilders;
@@ -401,37 +413,39 @@ class _MessageWidgetState extends State<MessageWidget>
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 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;
bool get isDeleteFailed =>
late final bool isDeleteFailed =
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') ==
true;
true;
bool get hasNonUrlAttachments =>
widget.message.attachments
late final bool isOnlyEmoji = widget.message.text?.isOnlyEmoji == true;
late final bool hasNonUrlAttachments = widget.message.attachments
.where((it) => it.ogScrapeUrl == null)
.isNotEmpty ==
true;
bool get hasUrlAttachments =>
late final bool hasUrlAttachments =
widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true;
bool get showBottomRow =>
showThreadReplyIndicator ||
late final bool showBottomRow = showThreadReplyIndicator ||
showUsername ||
showTimeStamp ||
showInChannel ||
@@ -441,6 +455,9 @@ class _MessageWidgetState extends State<MessageWidget>
@override
bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true;
late StreamChatThemeData _streamChatTheme;
late StreamChatState _streamChat;
@override
Widget build(BuildContext context) {
super.build(context);
@@ -450,7 +467,12 @@ class _MessageWidgetState extends State<MessageWidget>
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
return Material(
type: MaterialType.transparency,
type: widget.message.pinned && widget.showPinHighlight
? MaterialType.card
: MaterialType.transparency,
color: widget.message.pinned && widget.showPinHighlight
? _streamChatTheme.colorTheme.highlight
: null,
child: Portal(
child: InkWell(
onTap: () {
@@ -477,148 +499,165 @@ class _MessageWidgetState extends State<MessageWidget>
? AlignmentDirectional.bottomEnd
: AlignmentDirectional.bottomStart,
children: [
Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show &&
widget.message.user != null) ...[
_buildUserAvatar(),
const SizedBox(width: 4),
],
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(width: avatarWidth + 4),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(
widget.reverse ? 12 : -12, 0, 0),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0),
child: _buildReactionIndicator(context),
),
portalAnchor:
Alignment(widget.reverse ? 1 : -1, -1),
childAnchor:
Alignment(widget.reverse ? -1 : 1, -1),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Container(
// ignore: lines_longer_than_80_chars
margin: EdgeInsets.symmetric(
horizontal:
Padding(
padding: EdgeInsets.only(
bottom:
isPinned && widget.showPinHighlight ? 8.0 : 0.0,
),
child: Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message.pinned &&
widget.message.pinnedBy != null &&
widget.showPinHighlight)
_buildPinnedMessage(widget.message),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar ==
DisplayWidget.show &&
widget.message.user != null) ...[
_buildUserAvatar(),
const SizedBox(width: 4),
],
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(width: avatarWidth + 4),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(
widget.reverse ? 12 : -12, 0, 0),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0,
),
child: _buildReactionIndicator(context),
),
portalAnchor:
Alignment(widget.reverse ? 1 : -1, -1),
childAnchor:
Alignment(widget.reverse ? -1 : 1, -1),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Container(
// ignore: lines_longer_than_80_chars
margin: EdgeInsets.symmetric(
horizontal:
// ignore: lines_longer_than_80_chars
widget.showUserAvatar ==
// ignore: lines_longer_than_80_chars
DisplayWidget.gone
? 0
: 4.0),
child: DeletedMessage(
borderRadiusGeometry: widget
.borderRadiusGeometry,
borderSide:
widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Card(
clipBehavior: Clip.hardEdge,
elevation: 0,
margin: EdgeInsets.symmetric(
horizontal: (isFailedState
? 15.0
: 0.0) +
// ignore: lines_longer_than_80_chars
widget.showUserAvatar ==
// ignore: lines_longer_than_80_chars
(widget.showUserAvatar ==
DisplayWidget
.gone
? 0
: 4.0),
child: DeletedMessage(
borderRadiusGeometry: widget
.borderRadiusGeometry,
borderSide: widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Card(
clipBehavior: Clip.antiAlias,
elevation: 0,
margin: EdgeInsets.symmetric(
horizontal: (isFailedState
? 15.0
: 0.0) +
// ignore: lines_longer_than_80_chars
(widget.showUserAvatar ==
DisplayWidget.gone
? 0
: 4.0),
),
shape: widget.shape ??
RoundedRectangleBorder(
side: widget.borderSide ??
BorderSide(
color: widget
// ignore: lines_longer_than_80_chars
.messageTheme
// ignore: lines_longer_than_80_chars
.messageBorderColor ??
Colors.grey,
),
borderRadius: widget
// ignore: lines_longer_than_80_chars
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.end,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
if (hasQuotedMessage)
_buildQuotedMessage(),
if (hasNonUrlAttachments)
_parseAttachments(),
if (!isGiphy)
_buildTextBubble(),
],
),
shape: widget.shape ??
RoundedRectangleBorder(
side: widget
.borderSide ??
BorderSide(
color: widget
// ignore: lines_longer_than_80_chars
.messageTheme
// ignore: lines_longer_than_80_chars
.messageBorderColor ??
Colors.grey,
),
borderRadius: widget
// ignore: lines_longer_than_80_chars
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.end,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
if (hasQuotedMessage)
_buildQuotedMessage(),
if (hasNonUrlAttachments)
_parseAttachments(),
if (!isGiphy)
_buildTextBubble(),
],
),
),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: widget.reverse ? null : 4,
left: widget.reverse ? 4 : null,
top: -8,
child: CustomPaint(
painter: ReactionBubblePainter(
_streamChatTheme
.colorTheme.white,
Colors.transparent,
Colors.transparent,
tailCirclesSpace: 1,
),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: widget.reverse ? null : 4,
left: widget.reverse ? 4 : null,
top: -8,
child: CustomPaint(
painter: ReactionBubblePainter(
StreamChatTheme.of(context)
.colorTheme
.white,
Colors.transparent,
Colors.transparent,
tailCirclesSpace: 1,
),
),
),
],
],
),
),
),
),
],
),
if (showBottomRow)
SizedBox(height: context.textScaleFactor * 18.0),
],
],
),
if (showBottomRow)
SizedBox(height: context.textScaleFactor * 18.0),
],
),
),
if (showBottomRow)
Padding(
padding: EdgeInsets.only(left: leftPadding),
padding: EdgeInsets.only(
left: leftPadding,
bottom:
isPinned && widget.showPinHighlight ? 6.0 : 0.0,
),
child: _bottomRow,
),
if (isFailedState)
@@ -639,14 +678,20 @@ class _MessageWidgetState extends State<MessageWidget>
);
}
@override
void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context);
_streamChat = StreamChat.of(context);
super.didChangeDependencies();
}
Widget _buildQuotedMessage() {
final isMyMessage =
widget.message.user?.id == StreamChat.of(context).user?.id;
final isMyMessage = widget.message.user?.id == _streamChat.user?.id;
final onTap = widget.message.quotedMessage?.isDeleted != true &&
widget.onQuotedMessageTap != null
? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId)
: null;
final chatThemeData = StreamChatTheme.of(context);
final chatThemeData = _streamChatTheme;
return QuotedMessageWidget(
onTap: onTap,
message: widget.message.quotedMessage!,
@@ -661,7 +706,7 @@ class _MessageWidgetState extends State<MessageWidget>
Widget get _bottomRow {
if (isDeleted) {
final chatThemeData = StreamChatTheme.of(context);
final chatThemeData = _streamChatTheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -721,14 +766,7 @@ class _MessageWidgetState extends State<MessageWidget>
child: Text(msg, style: widget.messageTheme.replies),
),
],
if (showUsername)
Text(
widget.message.user!.name,
maxLines: 1,
key: usernameKey,
style: widget.messageTheme.messageAuthor,
overflow: TextOverflow.ellipsis,
),
if (showUsername) _buildUsername(usernameKey),
if (showTimeStamp)
Text(
Jiffy(widget.message.createdAt.toLocal()).jm,
@@ -791,6 +829,19 @@ class _MessageWidgetState extends State<MessageWidget>
);
}
Widget _buildUsername(Key usernameKey) {
if (widget.usernameBuilder != null) {
return widget.usernameBuilder!(context, widget.message);
}
return Text(
widget.message.user!.name,
maxLines: 1,
key: usernameKey,
style: widget.messageTheme.messageAuthor,
overflow: TextOverflow.ellipsis,
);
}
Widget _buildUrlAttachment() {
final urlAttachment = widget.message.attachments
.firstWhere((element) => element.ogScrapeUrl != null);
@@ -809,36 +860,16 @@ class _MessageWidgetState extends State<MessageWidget>
);
}
Widget _buildThreadParticipantsIndicator(Iterable<User> threadParticipants) {
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.of(context).colorTheme.white,
),
padding: const EdgeInsets.all(1),
child: UserAvatar(
user: user,
constraints: BoxConstraints.loose(const Size.fromRadius(7)),
showOnlineStatus: false,
),
),
);
}).toList(),
);
}
Widget _buildThreadParticipantsIndicator(Iterable<User> threadParticipants) =>
_ThreadParticipants(
streamChatTheme: _streamChatTheme,
threadParticipants: threadParticipants,
);
Widget _buildReactionIndicator(
BuildContext context,
) {
final ownId = StreamChat.of(context).user!.id;
final ownId = _streamChat.user!.id;
final reactionsMap = <String, Reaction>{};
widget.message.latestReactions?.forEach((element) {
if (!reactionsMap.containsKey(element.type) ||
@@ -878,10 +909,11 @@ class _MessageWidgetState extends State<MessageWidget>
showDialog(
context: context,
barrierColor: StreamChatTheme.of(context).colorTheme.overlay,
barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel(
channel: channel,
child: MessageActionsModal(
textBuilder: widget.textBuilder,
onCopyTap: (message) =>
Clipboard.setData(ClipboardData(text: message.text)),
attachmentBorderRadiusGeometry:
@@ -918,6 +950,7 @@ class _MessageWidgetState extends State<MessageWidget>
!isFailedState &&
widget.onThreadTap != null,
showFlagButton: widget.showFlagButton,
showPinButton: widget.showPinButton,
customActions: widget.customActions,
),
));
@@ -927,10 +960,11 @@ class _MessageWidgetState extends State<MessageWidget>
final channel = StreamChannel.of(context).channel;
showDialog(
context: context,
barrierColor: StreamChatTheme.of(context).colorTheme.overlay,
barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel(
channel: channel,
child: MessageReactionsModal(
textBuilder: widget.textBuilder,
attachmentBorderRadiusGeometry:
widget.attachmentBorderRadiusGeometry as BorderRadius?,
showUserAvatar:
@@ -957,7 +991,7 @@ class _MessageWidgetState extends State<MessageWidget>
side: hasFiles
? widget.attachmentBorderSide ??
BorderSide(
color: StreamChatTheme.of(context).colorTheme.greyWhisper,
color: _streamChatTheme.colorTheme.greyWhisper,
)
: BorderSide.none,
borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero,
@@ -967,7 +1001,7 @@ class _MessageWidgetState extends State<MessageWidget>
ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder(
side: widget.borderSide ??
BorderSide(
color: StreamChatTheme.of(context).colorTheme.greyWhisper,
color: _streamChatTheme.colorTheme.greyWhisper,
),
borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero,
);
@@ -1058,7 +1092,7 @@ class _MessageWidgetState extends State<MessageWidget>
Text(
widget.readList!.length.toString(),
style: style.copyWith(
color: StreamChatTheme.of(context).colorTheme.accentBlue,
color: _streamChatTheme.colorTheme.accentBlue,
),
),
const SizedBox(width: 2),
@@ -1113,7 +1147,35 @@ class _MessageWidgetState extends State<MessageWidget>
);
}
bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji;
Widget _buildPinnedMessage(Message message) {
final pinnedBy = message.pinnedBy;
final pinnedByMe = _streamChat.user!.id == pinnedBy!.id;
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.pin(
size: 16,
),
const SizedBox(
width: 4,
),
Text(
'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}',
style: TextStyle(
color: _streamChatTheme.colorTheme.grey,
fontSize: 13,
fontWeight: FontWeight.w400,
),
)
],
),
);
}
late final bool isPinned = widget.message.pinned;
Color? _getBackgroundColor() {
if (hasQuotedMessage) {
@@ -1121,7 +1183,7 @@ class _MessageWidgetState extends State<MessageWidget>
}
if (hasUrlAttachments) {
return StreamChatTheme.of(context).colorTheme.blueAlice;
return _streamChatTheme.colorTheme.blueAlice;
}
if (isOnlyEmoji) {
@@ -1153,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 {
const _ThreadReplyPainter({
this.context,
@@ -1,9 +1,9 @@
import 'package:cached_network_image/cached_network_image.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: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
typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function(
@@ -217,7 +217,7 @@ class QuotedMessageWidget extends StatelessWidget {
}
child = AbsorbPointer(child: child);
return Material(
clipBehavior: Clip.antiAlias,
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: attachment.type == 'file' ? null : _getDefaultShape(context),
child: child,
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/src/reaction_icon.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Creates reaction bubble widget for displaying over messages
@@ -130,13 +129,13 @@ class ReactionBubble extends StatelessWidget {
horizontal: 4,
),
child: reactionIcon != null
? StreamSvgIcon(
assetName: reactionIcon.assetName,
width: 16,
height: 16,
color: (!highlightOwnReactions || reaction.user?.id == userId)
? chatThemeData.colorTheme.accentBlue
: chatThemeData.colorTheme.black.withOpacity(.5),
? ConstrainedBox(
constraints: BoxConstraints.tight(const Size.square(16)),
child: reactionIcon.builder(
context,
!highlightOwnReactions || reaction.user?.id == userId,
16,
),
)
: Icon(
Icons.help_outline_rounded,
@@ -1,14 +1,20 @@
import 'package:flutter/material.dart';
/// Reaction icon data
class ReactionIcon {
/// Constructor for creating [ReactionIcon]
ReactionIcon({
required this.type,
required this.assetName,
required this.builder,
});
/// Type of reaction
final String type;
/// Asset to display for reaction
final String assetName;
final Widget Function(
BuildContext,
bool highlighted,
double size,
) builder;
}
@@ -1,9 +1,6 @@
import 'dart:math';
import 'package:ezanimation/ezanimation.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png)
@@ -50,95 +47,89 @@ class _ReactionPickerState extends State<ReactionPicker>
triggerAnimations();
}
return TweenAnimationBuilder<double>(
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),
color: chatThemeData.colorTheme.white,
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: reactionIcons
.map<Widget>((reactionIcon) {
final ownReactionIndex = widget.message.ownReactions
?.indexWhere((reaction) =>
reaction.type == reactionIcon.type) ??
-1;
final index = reactionIcons.indexOf(reactionIcon);
final child = Material(
borderRadius: BorderRadius.circular(24),
color: chatThemeData.colorTheme.white,
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: reactionIcons
.map<Widget>((reactionIcon) {
final ownReactionIndex = widget.message.ownReactions
?.indexWhere(
(reaction) => reaction.type == reactionIcon.type) ??
-1;
final index = reactionIcons.indexOf(reactionIcon);
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
child: RawMaterialButton(
elevation: 0,
shape: ContinuousRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
onPressed: () {
if (ownReactionIndex != -1) {
removeReaction(
context,
widget.message
.ownReactions![ownReactionIndex],
);
} else {
sendReaction(
context,
reactionIcon.type,
);
}
},
child: AnimatedBuilder(
animation: animations[index],
builder: (context, val) => Transform.scale(
scale: animations[index].value,
child: StreamSvgIcon(
assetName: reactionIcon.assetName,
height: max(
0,
animations[index].value * 24.0,
),
width: max(
0,
animations[index].value * 24.0,
),
color: ownReactionIndex != -1
? chatThemeData
.colorTheme.accentBlue
: Theme.of(context)
.iconTheme
.color!
.withOpacity(.5),
),
)),
),
);
})
.insertBetween(const SizedBox(
width: 16,
))
.toList(),
final child = reactionIcon.builder(
context,
ownReactionIndex != -1,
24,
);
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
),
),
));
child: RawMaterialButton(
elevation: 0,
shape: ContinuousRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
onPressed: () {
if (ownReactionIndex != -1) {
removeReaction(
context,
widget.message.ownReactions![ownReactionIndex],
);
} else {
sendReaction(
context,
reactionIcon.type,
);
}
},
child: AnimatedBuilder(
animation: animations[index],
builder: (context, child) => Transform.scale(
scale: animations[index].value,
child: child,
),
child: child,
),
),
);
})
.insertBetween(const SizedBox(
width: 16,
))
.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 {
@@ -217,10 +217,11 @@ class StreamChatThemeData {
TextTheme textTheme,
) {
final accentColor = colorTheme.accentBlue;
final iconTheme = IconThemeData(color: colorTheme.black.withOpacity(.5));
return StreamChatThemeData.raw(
textTheme: textTheme,
colorTheme: colorTheme,
primaryIconTheme: IconThemeData(color: colorTheme.black.withOpacity(.5)),
primaryIconTheme: iconTheme,
defaultChannelImage: (context, channel) => const SizedBox(),
defaultUserImage: (context, user) => Center(
child: CachedNetworkImage(
@@ -342,23 +343,63 @@ class StreamChatThemeData {
reactionIcons: [
ReactionIcon(
type: 'love',
assetName: 'Icon_love_reaction.svg',
builder: (context, highlighted, size) {
final theme = StreamChatTheme.of(context);
return StreamSvgIcon.loveReaction(
color: highlighted
? theme.colorTheme.accentBlue
: theme.primaryIconTheme.color!.withOpacity(.5),
size: size,
);
},
),
ReactionIcon(
type: 'like',
assetName: 'Icon_thumbs_up_reaction.svg',
builder: (context, highlighted, size) {
final theme = StreamChatTheme.of(context);
return StreamSvgIcon.thumbsUpReaction(
color: highlighted
? theme.colorTheme.accentBlue
: theme.primaryIconTheme.color!.withOpacity(.5),
size: size,
);
},
),
ReactionIcon(
type: 'sad',
assetName: 'Icon_thumbs_down_reaction.svg',
builder: (context, highlighted, size) {
final theme = StreamChatTheme.of(context);
return StreamSvgIcon.thumbsDownReaction(
color: highlighted
? theme.colorTheme.accentBlue
: theme.primaryIconTheme.color!.withOpacity(.5),
size: size,
);
},
),
ReactionIcon(
type: 'haha',
assetName: 'Icon_LOL_reaction.svg',
builder: (context, highlighted, size) {
final theme = StreamChatTheme.of(context);
return StreamSvgIcon.lolReaction(
color: highlighted
? theme.colorTheme.accentBlue
: theme.primaryIconTheme.color!.withOpacity(.5),
size: size,
);
},
),
ReactionIcon(
type: 'wow',
assetName: 'Icon_wut_reaction.svg',
builder: (context, highlighted, size) {
final theme = StreamChatTheme.of(context);
return StreamSvgIcon.wutReaction(
color: highlighted
? theme.colorTheme.accentBlue
: theme.primaryIconTheme.color!.withOpacity(.5),
size: size,
);
},
),
],
);
@@ -49,6 +49,66 @@ class StreamSvgIcon extends StatelessWidget {
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.loveReaction({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'Icon_love_reaction.svg',
color: color,
width: size,
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.thumbsUpReaction({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'Icon_thumbs_up_reaction.svg',
color: color,
width: size,
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.thumbsDownReaction({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'Icon_thumbs_down_reaction.svg',
color: color,
width: size,
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.lolReaction({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'Icon_LOL_reaction.svg',
color: color,
width: size,
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.wutReaction({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'Icon_wut_reaction.svg',
color: color,
width: size,
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.smile({
double? size,
@@ -889,6 +949,18 @@ class StreamSvgIcon extends StatelessWidget {
height: size,
);
/// [StreamSvgIcon] type
factory StreamSvgIcon.pin({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'icon_pin.svg',
color: color,
width: size,
height: size,
);
/// Name of icon asset
final String? assetName;
@@ -33,13 +33,22 @@ class TypingIndicator extends StatelessWidget {
Widget build(BuildContext context) {
final channelState =
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,
stream: channelState.typingEventsStream,
builder: (context, snapshot) => AnimatedSwitcher(
builder: (context, data) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: snapshot.data?.isNotEmpty == true
child: data.isNotEmpty == true
? Padding(
key: const Key('main'),
padding: padding,
child: Align(
key: const Key('typings'),
@@ -54,7 +63,7 @@ class TypingIndicator extends StatelessWidget {
),
Text(
// 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,
style: style,
),
@@ -62,13 +71,7 @@ class TypingIndicator extends StatelessWidget {
),
),
)
: Align(
key: const Key('alternative'),
alignment: alignment,
child: Container(
child: alternativeWidget ?? const Offstage(),
),
),
: altWidget,
),
);
}
@@ -17,16 +17,16 @@ class UnreadIndicator extends StatelessWidget {
Widget build(BuildContext context) {
final client = StreamChat.of(context).client;
return IgnorePointer(
child: StreamBuilder<int?>(
child: BetterStreamBuilder<int?>(
stream: cid != null
? client.state.channels[cid]?.state?.unreadCountStream
: client.state.totalUnreadCountStream,
initialData: cid != null
? client.state.channels[cid]?.state?.unreadCount
: client.state.totalUnreadCount,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == 0) {
return const SizedBox();
builder: (context, data) {
if (data == null || data == 0) {
return const Offstage();
}
return Material(
borderRadius: BorderRadius.circular(8),
@@ -42,7 +42,7 @@ class UnreadIndicator extends StatelessWidget {
),
child: Center(
child: Text(
'${snapshot.data! > 99 ? '99+' : snapshot.data}',
'${data > 99 ? '99+' : data}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
@@ -40,7 +40,7 @@ class UrlAttachment extends StatelessWidget {
children: [
if (urlAttachment.imageUrl != null)
Container(
clipBehavior: Clip.antiAliasWithSaveLayer,
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
@@ -332,7 +334,7 @@ Widget wrapAttachmentWidget(
bool reverse,
) =>
Material(
clipBehavior: Clip.antiAlias,
clipBehavior: Clip.hardEdge,
shape: attachmentShape,
type: MaterialType.transparency,
child: attachmentWidget,
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.694 1.52896C10.5304 1.36532 10.2937 1.29808 10.0685 1.35125C9.84327 1.40442 9.6616 1.57041 9.5884 1.78994L9.16613 3.05682L6.72027 5.50272C5.12809 5.14204 3.59999 5.79015 2.19557 7.19458C1.93481 7.45531 1.93481 7.87811 2.19557 8.13884L4.55625 10.4995L3.1399 11.9159C2.87915 12.1767 2.87915 12.5994 3.1399 12.8601C3.40065 13.1209 3.82342 13.1209 4.08417 12.8601L5.50051 11.4438L7.8612 13.8045C8.12193 14.0652 8.54473 14.0652 8.80547 13.8045C10.2099 12.4 10.858 10.872 10.4973 9.27978L12.9432 6.83391L14.2101 6.41161C14.4296 6.33843 14.5956 6.1568 14.6488 5.93158C14.7019 5.70636 14.6347 5.46967 14.4711 5.30604L10.694 1.52896ZM10.3832 3.62864L10.5137 3.23716L12.7629 5.48638L12.3714 5.61688C12.2731 5.64965 12.1837 5.70488 12.1104 5.77818L9.2776 8.61098C9.09873 8.78984 9.03633 9.05431 9.11627 9.29424C9.43453 10.2489 9.2382 11.2565 8.31287 12.3676L3.6324 7.68718C4.74353 6.76184 5.7511 6.56552 6.7058 6.88378C6.94573 6.96371 7.2102 6.90131 7.38906 6.72244L10.2219 3.88963C10.2951 3.81634 10.3504 3.72698 10.3832 3.62864Z" fill="#7A7A7A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5 -5
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 2.0.0-nullsafety.4
version: 2.0.0-nullsafety.5
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -11,7 +11,7 @@ environment:
dependencies:
cached_network_image: ^3.0.0
characters: ^1.1.0
chewie: ^1.0.0
chewie: ^1.2.0
collection: ^1.15.0
dio: ^4.0.0
ezanimation: ^0.5.0
@@ -25,7 +25,7 @@ dependencies:
flutter_svg: ^0.22.0
http_parser: ^4.0.0
image_gallery_saver: ^1.6.9
image_picker: ^0.7.4
image_picker: ^0.8.0
jiffy: ^4.1.0
lottie: ^1.0.1
meta: ^1.3.0
@@ -36,12 +36,12 @@ dependencies:
scrollable_positioned_list: ^0.2.0-nullsafety.0
share_plus: ^2.0.3
shimmer: ^2.0.0
stream_chat_flutter_core: ^2.0.0-nullsafety.3
stream_chat_flutter_core: ^2.0.0-nullsafety.5
substring_highlight: ^1.0.26
synchronized: ^3.0.0
url_launcher: ^6.0.3
video_compress: ^3.0.0
video_player: ^2.1.1
video_player: ^2.1.0
video_thumbnail: ^0.3.3
visibility_detector: ^0.2.0
@@ -107,6 +107,8 @@ void main() {
]);
when(() => client.wsConnectionStatusStream)
.thenAnswer((_) => Stream.value(ConnectionStatus.disconnected));
when(() => client.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
when(() => clientState.totalUnreadCountStream)
.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(
home: StreamChat(
client: client,
@@ -75,7 +66,6 @@ void main() {
));
expect(find.byType(TextField), findsOneWidget);
expect(find.byType(StreamSvgIcon), findsNWidgets(8));
expect(find.byKey(const Key('messageInputText')), findsOneWidget);
},
);
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
import 'package:mocktail/mocktail.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 {}
@@ -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 {}
@@ -83,7 +83,7 @@ void main() {
),
));
expect(find.byType(SizedBox), findsOneWidget);
expect(find.text('0'), findsNothing);
},
);
@@ -1,3 +1,10 @@
## 2.0.0-nullsafety.5
* Update llc dependency
* Minor fixes and improvements
* Performance improvements
* Monitor connection using `connectivity_plus` package
## 2.0.0-nullsafety.3
* Update llc dependency
@@ -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 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/typedef.dart';
@@ -127,6 +129,10 @@ class MessageListCoreState extends State<MessageListCore> {
.map((threads) => threads[widget.parentMessage!.id])
: _streamChannel!.channel.state?.messagesStream;
final initialData = _isThreadConversation
? _streamChannel!.channel.state?.threads[widget.parentMessage!.id]
: _streamChannel!.channel.state?.messages;
bool defaultFilter(Message m) {
final isMyMessage = m.user?.id == _currentUser?.id;
final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true;
@@ -134,28 +140,27 @@ class MessageListCoreState extends State<MessageListCore> {
return true;
}
return StreamBuilder<List<Message>?>(
stream: messagesStream?.map((messages) =>
messages?.where(widget.messageFilter ?? defaultFilter).toList(
growable: false,
)),
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorWidgetBuilder(context, snapshot.error!);
} else if (!snapshot.hasData) {
return widget.loadingBuilder(context);
} else {
final messageList =
snapshot.data?.reversed.toList(growable: false) ?? [];
if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) {
return widget.emptyBuilder(context);
}
} else {
_messages = messageList;
return BetterStreamBuilder<List<Message>?>(
initialData: initialData,
comparator: const ListEquality().equals,
stream: messagesStream!.map(
(messages) =>
messages?.where(widget.messageFilter ?? defaultFilter).toList(
growable: false,
),
),
errorBuilder: widget.errorWidgetBuilder,
loadingBuilder: widget.loadingBuilder,
builder: (context, data) {
final messageList = data?.reversed.toList(growable: false) ?? [];
if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) {
return widget.emptyBuilder(context);
}
return widget.messageListBuilder(context, _messages);
} else {
_messages = messageList;
}
return widget.messageListBuilder(context, _messages);
},
);
}
@@ -3,6 +3,7 @@ library stream_chat_flutter_core;
export 'package:connectivity_plus/connectivity_plus.dart';
export 'package:stream_chat/stream_chat.dart';
export 'src/better_stream_builder.dart';
export 'src/channel_list_core.dart' hide ChannelListCoreState;
export 'src/channels_bloc.dart';
export 'src/lazy_load_scroll_view.dart';
@@ -1,7 +1,7 @@
name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 2.0.0-nullsafety.3
version: 2.0.0-nullsafety.5
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -16,7 +16,7 @@ dependencies:
sdk: flutter
meta: ^1.3.0
rxdart: ^0.27.0
stream_chat: ^2.0.0-nullsafety.2
stream_chat: ^2.0.0-nullsafety.5
dependency_overrides:
stream_chat:
@@ -100,6 +100,7 @@ void main() {
when(() => mockChannel.state.isUpToDate).thenReturn(true);
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value([]));
when(() => mockChannel.state.messages).thenReturn([]);
await tester.pumpWidget(
StreamChannel(
@@ -133,6 +134,7 @@ void main() {
when(() => mockChannel.state.isUpToDate).thenReturn(true);
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value([]));
when(() => mockChannel.state.messages).thenReturn([]);
when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true));
await tester.pumpWidget(
@@ -174,6 +176,7 @@ void main() {
when(() => mockChannel.state.messages).thenReturn(messages);
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true));
await tester.pumpWidget(
@@ -220,6 +223,7 @@ void main() {
const error = 'Error! Error! Error!';
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.error(error));
when(() => mockChannel.state.messages).thenReturn([]);
await tester.pumpWidget(
Directionality(
@@ -259,6 +263,7 @@ void main() {
const messages = <Message>[];
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget(
Directionality(
@@ -305,6 +310,7 @@ void main() {
const messages = <Message>[];
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget(
Directionality(
@@ -349,6 +355,7 @@ void main() {
final messages = _generateMessages();
when(() => mockChannel.state.messagesStream)
.thenAnswer((_) => Stream.value(messages));
when(() => mockChannel.state.messages).thenReturn(messages);
await tester.pumpWidget(
Directionality(
@@ -4,6 +4,10 @@ import 'package:stream_chat/stream_chat.dart';
class MockLogger extends Mock implements Logger {}
class MockClient extends Mock implements StreamChatClient {
MockClient() {
when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected);
}
@override
final Logger logger = MockLogger();
@@ -235,6 +235,14 @@ void main() {
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
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(
key: streamChatCoreKey,
client: mockClient,
@@ -247,13 +255,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), 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>(
find.byKey(streamChatCoreKey),
);
@@ -323,6 +324,14 @@ void main() {
const childKey = Key('child');
final _connectivityController =
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(
key: streamChatCoreKey,
client: mockClient,
@@ -335,13 +344,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), 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);
await Future.delayed(const Duration(seconds: 1));
@@ -397,6 +399,14 @@ void main() {
const childKey = Key('child');
final _connectivityController =
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(
key: streamChatCoreKey,
client: mockClient,
@@ -409,13 +419,6 @@ void main() {
expect(find.byKey(streamChatCoreKey), 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>(
find.byKey(streamChatCoreKey),
);
@@ -1,3 +1,8 @@
## 2.0.0-nullsafety.5
* Update llc dependency
* Minor fixes and improvements
## 2.0.0-nullsafety.2
* Update llc dependency
@@ -1,7 +1,7 @@
name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter.
version: 2.0.0-nullsafety.2
version: 2.0.0-nullsafety.5
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -17,8 +17,8 @@ dependencies:
mutex: ^3.0.0
path: ^1.8.0
path_provider: ^2.0.1
sqlite3_flutter_libs: ^0.4.2
stream_chat: ^2.0.0-nullsafety.2
sqlite3_flutter_libs: ^0.5.0
stream_chat: ^2.0.0-nullsafety.5
dependency_overrides:
stream_chat:
@@ -29,4 +29,4 @@ dev_dependencies:
mocktail: ^0.1.1
moor_generator: ^4.2.1
pedantic: ^1.11.0
test: ^1.16.8
test: ^1.17.7