diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 75fecc34..b6c5cecf 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -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 diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index fc37cd5c..fe56aed4 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -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? get isMutedStream => _client.state.userStream.map((event) => - event!.channelMutes.any((element) => element.channel.cid == cid) == true); + Stream? 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 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 sendMessage(Message message) async { + Future 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 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(); _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 partialUpdateMessage( + Message message, { + Map? set, + List? 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 unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); + partialUpdateMessage( + message, + set: { + 'pinned': false, + }, + ); /// Send a file to this channel Future sendFile( @@ -747,11 +802,12 @@ class Channel { } /// Edit the channel custom data - Future updatePartial( - Map channelData, - ) async { + Future updatePartial({ + Map? set, + List? 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 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?> get messagesStream => channelStateStream .map((cs) => cs.messages) - .distinct((prev, next) => const ListEquality().equals(prev, next)); + .distinct(const ListEquality().equals); /// Channel pinned message list List? 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 _unreadCountController = BehaviorSubject.seeded(0); + set unreadCount(int value) => _unreadCountController.add(value); + /// Unread count getter as a stream - Stream get unreadCountStream => _unreadCountController.stream; + Stream 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 get typingEvents => _typingEventsController.value; /// Channel related typing users stream - Stream> get typingEventsStream => _typingEventsController.stream; + Stream> get typingEventsStream => + _typingEventsController.stream.distinct(const ListEquality().equals); + final BehaviorSubject> _typingEventsController = BehaviorSubject.seeded([]); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 7a3734da..7a22ac50 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -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 updateChannelPartial( String channelId, - String channelType, - Map data, - ) => + String channelType, { + Map? set, + List? unset, + }) => _chatApi.channel.updateChannelPartial( channelId, channelType, - data, + set: set, + unset: unset, ); /// Add a device for Push Notifications. @@ -1125,12 +1127,14 @@ class StreamChatClient { Future 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 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 partialUpdateMessage( + String messageId, { + Map? set, + List? unset, + }) => + _chatApi.message.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + /// Deletes the given message Future 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 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 unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); + Future 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() .listen(_unreadChannelsController.add), _client .on() - .where((event) => event.totalUnreadCount != null) - .map((e) => e.totalUnreadCount) + .map((event) => event.totalUnreadCount) + .whereType() .listen(_totalUnreadCountController.add), ]); @@ -1305,8 +1331,8 @@ class ClientState { final _subscriptions = []; /// 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> 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 get unreadChannelsStream => _unreadChannelsController.stream; + Stream 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 get totalUnreadCountStream => _totalUnreadCountController.stream; + Stream get totalUnreadCountStream => _totalUnreadCountController.stream; /// The current list of channels in memory as a stream Stream> get channelsStream => _channelsController.stream; @@ -1399,8 +1425,8 @@ class ClientState { final _channelsController = BehaviorSubject>.seeded({}); final _userController = BehaviorSubject(); final _usersController = BehaviorSubject>.seeded({}); - final _unreadChannelsController = BehaviorSubject(); - final _totalUnreadCountController = BehaviorSubject(); + final _unreadChannelsController = BehaviorSubject.seeded(0); + final _totalUnreadCountController = BehaviorSubject.seeded(0); /// Call this method to dispose this object void dispose() { diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index bdf2a401..9da05df0 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -109,12 +109,16 @@ class ChannelApi { /// Updates the [channelId] of type [ChannelType] data with [data] Future updateChannelPartial( String channelId, - String channelType, - Map data, - ) async { + String channelType, { + Map? set, + List? 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); } diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index eccd2f44..6d80f35b 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -14,11 +14,15 @@ class MessageApi { Future 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 partialUpdateMessage( + String messageId, { + Map? set, + List? 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 deleteMessage( String messageId, diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 87c82473..5c9bc95c 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -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 toJson() => _$MemberToJson(this); + + @override + List get props => [ + user, + inviteAcceptedAt, + inviteRejectedAt, + invited, + role, + userId, + isModerator, + banned, + shadowBanned, + createdAt, + updatedAt, + ]; } diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 426b863a..d394dbdd 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -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? 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, ]; } diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index 7d268d0e..c49e2a6e 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -67,7 +67,6 @@ Message _$MessageFromJson(Map 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 _$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)); diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index b1840ba2..de58055d 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -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 get props => [ + id, + role, + teams, + createdAt, + updatedAt, + lastActive, + online, + banned, + extraData, + ]; } diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 6bf106a2..d3e4bfe3 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -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'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index e6259b42..4aaa33bd 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -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 \ No newline at end of file + test: ^1.17.7 \ No newline at end of file diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 85f83db5..41b50360 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -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 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 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); diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 8d5f5e76..2152cee0 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -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); }); diff --git a/packages/stream_chat/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart index 59319eca..041c18f5 100644 --- a/packages/stream_chat/test/src/core/api/message_api_test.dart +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -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'; diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart index 8f8bdf45..ad5245a2 100644 --- a/packages/stream_chat/test/src/core/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -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 } diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index b3f28ff0..f7b8f003 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -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", diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index f1468271..74280a0b 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -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 {} diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index c958ff5f..967f3c21 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -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; } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e0b60fad..39247b13 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -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 diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index a676e576..7ec5fb04 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -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( diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 1bbbd54f..1572061e 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -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), diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 5c431d6e..c677435a 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart index 7773b36f..16c1f1b7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/channel_image.dart b/packages/stream_chat_flutter/lib/src/channel_image.dart index 19495809..d3e028ee 100644 --- a/packages/stream_chat_flutter/lib/src/channel_image.dart +++ b/packages/stream_chat_flutter/lib/src/channel_image.dart @@ -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>( + return BetterStreamBuilder>( 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( - stream: streamChat.client.state.usersStream.map( - (users) => users[otherMember?.userId] ?? otherMember!.user!), + return BetterStreamBuilder( + 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, diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index 05622812..813df431 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -25,14 +25,14 @@ class ChannelInfo extends StatelessWidget { @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; - return StreamBuilder>( - stream: channel.state?.membersStream, - initialData: channel.state?.members, - builder: (context, snapshot) => ConnectionStatusBuilder( + return BetterStreamBuilder>( + 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: diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index c52c3797..f6acf35e 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -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 { } } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - child: child, - ); + return child; } Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder( @@ -477,104 +473,105 @@ class _ChannelListViewState extends State { Widget _listItemBuilder(BuildContext context, int i, List 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('CHANNEL-${channel.id}'), + key: ValueKey('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() ?? - [ + 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() ?? + [ + 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 { context, ChannelsBlocState channelsProvider, ) => - StreamBuilder( + BetterStreamBuilder( 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 { 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; diff --git a/packages/stream_chat_flutter/lib/src/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel_name.dart index 5db3341a..4819ecc7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel_name.dart @@ -26,11 +26,11 @@ class ChannelName extends StatelessWidget { final client = StreamChat.of(context); final channel = StreamChannel.of(context).channel; - return StreamBuilder>( + return BetterStreamBuilder>( stream: channel.extraDataStream, initialData: channel.extraData, - builder: (context, snapshot) => _buildName( - snapshot.data!, + builder: (context, data) => _buildName( + data, channel.state?.members, client, ), diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 3189b0cb..d9170eb8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -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( + return BetterStreamBuilder( 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>( + BetterStreamBuilder?>( 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( + Widget _buildDate(BuildContext context) => BetterStreamBuilder( 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?>( + BetterStreamBuilder?>( 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(); } diff --git a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart index 7e4e7f8e..fbe7ffae 100644 --- a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart +++ b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart @@ -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? connectionStatusStream; @@ -38,22 +34,18 @@ class ConnectionStatusBuilder extends StatelessWidget { Widget build(BuildContext context) { final stream = connectionStatusStream ?? StreamChat.of(context).client.wsConnectionStatusStream; - return StreamBuilder( - initialData: initialStatus, + final client = StreamChat.of(context).client; + return BetterStreamBuilder( + 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, ); } } diff --git a/packages/stream_chat_flutter/lib/src/emoji/emoji.dart b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart index 617e372a..38af85af 100644 --- a/packages/stream_chat_flutter/lib/src/emoji/emoji.dart +++ b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart @@ -114325,6 +114325,9 @@ class Emoji { /// Get all Emojis static List all() => List.unmodifiable(_emojis); + static Iterable chars() => + _emojis.map((e) => e.char).whereType(); + /// Returns Emoji by [char] and character static Emoji? byChar(String char) { return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == char); diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 3b6ba3e2..5161c8e8 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -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); } } diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index e40cfc21..5b4102a1 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -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 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 { 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: [ + 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 { 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: [ - 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 { } } + 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 { ); } + 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; diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 0d2070e7..7e62f1c3 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -268,6 +268,8 @@ class MessageInputState extends State { /// The editing controller passed to the input TextField late final TextEditingController textEditingController; + late StreamChatThemeData _streamChatTheme; + bool get _hasQuotedMessage => widget.quotedMessage != null; @override @@ -305,9 +307,10 @@ class MessageInputState extends State { @override Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - Widget child = Container( - color: streamChatThemeData.messageInputTheme.inputBackground, + Widget child = DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.messageInputTheme.inputBackground, + ), child: SafeArea( child: GestureDetector( onPanUpdate: (details) { @@ -332,7 +335,7 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.all(8), child: StreamSvgIcon.reply( - color: streamChatThemeData.colorTheme.greyGainsboro, + color: _streamChatTheme.colorTheme.greyGainsboro, ), ), const Text( @@ -390,65 +393,62 @@ class MessageInputState extends State { ], ); - Widget _buildDmCheckbox() { - final streamChatThemeData = StreamChatTheme.of(context); - return Row( - children: [ - Container( - height: 16, - width: 16, - foregroundDecoration: BoxDecoration( - border: _sendAsDm - ? null - : Border.all( - color: streamChatThemeData.colorTheme.black.withOpacity(.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - child: Center( - child: Material( + Widget _buildDmCheckbox() => Row( + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: _streamChatTheme.colorTheme.black.withOpacity(.5), + width: 2, + ), borderRadius: BorderRadius.circular(3), - color: _sendAsDm - ? streamChatThemeData.colorTheme.accentBlue - : streamChatThemeData.colorTheme.white, - child: InkWell( - onTap: () { - setState(() { - _sendAsDm = !_sendAsDm; - }); - }, - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), - crossFadeState: _sendAsDm - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: StreamSvgIcon.check( - size: 16, - color: streamChatThemeData.colorTheme.white, - ), - secondChild: const SizedBox( - height: 16, - width: 16, + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? _streamChatTheme.colorTheme.accentBlue + : _streamChatTheme.colorTheme.white, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: StreamSvgIcon.check( + size: 16, + color: _streamChatTheme.colorTheme.white, + ), + secondChild: const SizedBox( + height: 16, + width: 16, + ), ), ), ), ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Also send as direct message', - style: streamChatThemeData.textTheme.footnote.copyWith( - color: streamChatThemeData.colorTheme.black.withOpacity(0.5), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Also send as direct message', + style: _streamChatTheme.textTheme.footnote.copyWith( + color: _streamChatTheme.colorTheme.black.withOpacity(0.5), + ), ), ), - ), - ], - ); - } + ], + ); Widget _animateSendButton(BuildContext context) { final sendButton = widget.activeSendButton != null @@ -463,8 +463,7 @@ class MessageInputState extends State { : CrossFadeState.showSecond, firstChild: sendButton, secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), - duration: - StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration!, + duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, alignment: Alignment.center, ); } @@ -478,16 +477,18 @@ class MessageInputState extends State { ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: IconButton( - onPressed: () => setState(() => _actionsShrunk = false), + onPressed: () { + if (_actionsShrunk) { + setState(() => _actionsShrunk = false); + } + }, icon: Transform.rotate( angle: (widget.actionsLocation == ActionsLocation.right || widget.actionsLocation == ActionsLocation.rightInside) ? pi : 0, child: StreamSvgIcon.emptyCircleLeft( - color: StreamChatTheme.of(context) - .messageInputTheme - .expandButtonColor, + color: _streamChatTheme.messageInputTheme.expandButtonColor, ), ), padding: const EdgeInsets.all(0), @@ -522,7 +523,6 @@ class MessageInputState extends State { } Expanded _buildTextInput(BuildContext context) { - final theme = StreamChatTheme.of(context); final margin = (widget.sendButtonLocation == SendButtonLocation.inside ? const EdgeInsets.only(right: 8) : EdgeInsets.zero) + @@ -530,49 +530,46 @@ class MessageInputState extends State { ? const EdgeInsets.only(left: 8) : EdgeInsets.zero); return Expanded( - child: Center( - child: Container( - clipBehavior: Clip.antiAlias, - margin: margin, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - gradient: _focusNode.hasFocus - ? theme.messageInputTheme.activeBorderGradient - : theme.messageInputTheme.idleBorderGradient, - ), - child: Padding( - padding: const EdgeInsets.all(1.5), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - color: theme.messageInputTheme.inputBackground, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: TextField( - key: const Key('messageInputText'), - enabled: _inputEnabled, - maxLines: null, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: textEditingController, - focusNode: _focusNode, - style: theme.messageInputTheme.inputTextStyle, - autofocus: widget.autofocus, - textAlignVertical: TextAlignVertical.center, - decoration: _getInputDecoration(), - textCapitalization: TextCapitalization.sentences, - ), - ) - ], - ), + child: Container( + clipBehavior: Clip.hardEdge, + margin: margin, + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + gradient: _focusNode.hasFocus + ? _streamChatTheme.messageInputTheme.activeBorderGradient + : _streamChatTheme.messageInputTheme.idleBorderGradient, + ), + child: Padding( + padding: const EdgeInsets.all(1.5), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + color: _streamChatTheme.messageInputTheme.inputBackground, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: const Key('messageInputText'), + enabled: _inputEnabled, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: _streamChatTheme.messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(), + textCapitalization: TextCapitalization.sentences, + ), + ) + ], ), ), ), @@ -581,13 +578,12 @@ class MessageInputState extends State { } InputDecoration _getInputDecoration() { - final theme = StreamChatTheme.of(context); - final passedDecoration = theme.messageInputTheme.inputDecoration; + final passedDecoration = _streamChatTheme.messageInputTheme.inputDecoration; return InputDecoration( isDense: true, hintText: _getHint(), - hintStyle: theme.messageInputTheme.inputTextStyle!.copyWith( - color: theme.colorTheme.grey, + hintStyle: _streamChatTheme.messageInputTheme.inputTextStyle!.copyWith( + color: _streamChatTheme.colorTheme.grey, ), border: const OutlineInputBorder( borderSide: BorderSide( @@ -625,7 +621,7 @@ class MessageInputState extends State { constraints: BoxConstraints.tight(const Size(64, 24)), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.accentBlue, + color: _streamChatTheme.colorTheme.accentBlue, ), alignment: Alignment.center, child: Row( @@ -637,7 +633,8 @@ class MessageInputState extends State { ), Text( _chosenCommand?.name.toUpperCase() ?? '', - style: theme.textTheme.footnoteBold.copyWith( + style: + _streamChatTheme.textTheme.footnoteBold.copyWith( color: Colors.white, ), ), @@ -828,129 +825,126 @@ class MessageInputState extends State { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; + final child = Padding( + padding: const EdgeInsets.all(8), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + color: _streamChatTheme.colorTheme.white, + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(400)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.white, + borderRadius: BorderRadius.circular(8)), + child: ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + if (commands.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: StreamSvgIcon.lightning( + color: _streamChatTheme.colorTheme.accentBlue, + ), + ), + Text( + 'Instant Commands', + style: TextStyle( + color: + _streamChatTheme.colorTheme.black.withOpacity(.5), + ), + ) + ], + ), + ), + const SizedBox( + height: 10, + ), + ...commands + .map( + (c) => InkWell( + onTap: () { + _setCommand(c); + }, + child: SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox( + width: 16, + ), + _buildCommandIcon(c.name), + const SizedBox( + width: 8, + ), + Text.rich( + TextSpan( + text: c.name.capitalize(), + style: const TextStyle( + fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' /${c.name} ${c.args}', + style: _streamChatTheme.textTheme.body + .copyWith( + // ignore: lines_longer_than_80_chars + color: _streamChatTheme + // ignore: lines_longer_than_80_chars + .colorTheme + .grey, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ) + .toList(), + ], + ), + ), + ), + ); return OverlayEntry( builder: (context) => Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, left: 0, right: 0, child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - final streamChatThemeData = StreamChatTheme.of(context); - return Transform.scale( - scale: val, - child: Padding( - padding: const EdgeInsets.all(8), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - color: streamChatThemeData.colorTheme.white, - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose( - const Size.fromHeight(400)), - decoration: BoxDecoration( - color: streamChatThemeData.colorTheme.white, - borderRadius: BorderRadius.circular(8)), - child: ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - if (commands.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - child: StreamSvgIcon.lightning( - color: streamChatThemeData - .colorTheme.accentBlue, - ), - ), - Text( - 'Instant Commands', - style: TextStyle( - color: streamChatThemeData - .colorTheme.black - .withOpacity(.5), - ), - ) - ], - ), - ), - const SizedBox( - height: 10, - ), - ...commands - .map( - (c) => InkWell( - onTap: () { - _setCommand(c); - }, - child: SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox( - width: 16, - ), - _buildCommandIcon(c.name), - const SizedBox( - width: 8, - ), - Text.rich( - TextSpan( - text: c.name.capitalize(), - style: const TextStyle( - fontWeight: - FontWeight.bold), - children: [ - TextSpan( - text: - ' /${c.name} ${c.args}', - style: streamChatThemeData - .textTheme.body - .copyWith( - // ignore: lines_longer_than_80_chars - color: streamChatThemeData - // ignore: lines_longer_than_80_chars - .colorTheme - .grey, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ) - .toList(), - ], - ), - ), - ), - ), - ); - }), + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, + ), )); } Widget _buildFilePickerSection() { + if (!_openFilePickerSection) { + return const Offstage(); + } + final _attachmentContainsFile = _attachments.values.any((it) => it.type == 'file'); - final chatThemeData = StreamChatTheme.of(context); Color _getIconColor(int index) { - final streamChatThemeData = chatThemeData; + final streamChatThemeData = _streamChatTheme; switch (index) { case 0: return _attachments.isEmpty @@ -982,7 +976,7 @@ class MessageInputState extends State { _animateContainer ? const Duration(milliseconds: 300) : Duration.zero, height: _openFilePickerSection ? _filePickerSize : 0, child: Material( - color: chatThemeData.colorTheme.whiteSmoke, + color: _streamChatTheme.colorTheme.whiteSmoke, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1044,9 +1038,9 @@ class MessageInputState extends State { ); }); }, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: _streamChatTheme.colorTheme.white, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), @@ -1057,12 +1051,14 @@ class MessageInputState extends State { child: Center( child: Padding( padding: const EdgeInsets.all(8), - child: Container( + child: SizedBox( width: 40, height: 4, - decoration: BoxDecoration( - color: chatThemeData.colorTheme.whiteSmoke, - borderRadius: BorderRadius.circular(4), + child: DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.whiteSmoke, + borderRadius: BorderRadius.circular(4), + ), ), ), ), @@ -1072,13 +1068,14 @@ class MessageInputState extends State { ), if (_openFilePickerSection) Expanded( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: _streamChatTheme.colorTheme.white, borderRadius: BorderRadius.circular(8), ), child: _PickerWidget( filePickerIndex: _filePickerIndex, + streamChatTheme: _streamChatTheme, containsFile: _attachmentContainsFile, selectedMedias: _attachments.keys.toList(), onAddMoreFilesClick: pickFile, @@ -1150,7 +1147,6 @@ class MessageInputState extends State { } Widget _buildCommandIcon(String iconType) { - final chatThemeData = StreamChatTheme.of(context); switch (iconType) { case 'giphy': return CircleAvatar( @@ -1161,7 +1157,7 @@ class MessageInputState extends State { ); case 'ban': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.iconUserDelete( size: 16, @@ -1170,7 +1166,7 @@ class MessageInputState extends State { ); case 'flag': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.flag( size: 14, @@ -1179,7 +1175,7 @@ class MessageInputState extends State { ); case 'imgur': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: ClipOval( child: StreamSvgIcon.imgur( @@ -1189,7 +1185,7 @@ class MessageInputState extends State { ); case 'mute': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.mute( size: 16, @@ -1198,7 +1194,7 @@ class MessageInputState extends State { ); case 'unban': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.userAdd( size: 16, @@ -1207,7 +1203,7 @@ class MessageInputState extends State { ); case 'unmute': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.volumeUp( size: 16, @@ -1216,7 +1212,7 @@ class MessageInputState extends State { ); default: return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.lightning( size: 16, @@ -1253,7 +1249,70 @@ class MessageInputState extends State { // ignore: cast_nullable_to_non_nullable final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; + final child = Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(240)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.white, + ), + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) => ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + const SizedBox( + height: 8, + ), + ...snapshot.data! + .where((it) => it.user != null) + .map( + (m) => Material( + color: _streamChatTheme.colorTheme.white, + child: InkWell( + onTap: () { + if (m.user != null) { + _mentionedUsers.add(m.user!); + } + splits[splits.length - 1] = m.user!.name; + final rejoin = splits.join('@'); + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text.substring( + textEditingController.selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + _debounce!.cancel(); + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + child: widget.mentionsTileBuilder != null + ? widget.mentionsTileBuilder!(context, m) + : MentionTile(m), + ), + ), + ) + .toList(), + const SizedBox( + height: 8, + ), + ], + ), + ), + ), + ); return OverlayEntry( builder: (context) => Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, @@ -1263,78 +1322,11 @@ class MessageInputState extends State { tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 300), curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - final chatThemeData = StreamChatTheme.of(context); - return Transform.scale( - scale: val, - child: Card( - margin: const EdgeInsets.all(8), - elevation: 2, - color: chatThemeData.colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(const Size.fromHeight(240)), - decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, - ), - child: FutureBuilder>( - future: queryMembers ?? Future.value(members), - initialData: members, - builder: (context, snapshot) => ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - const SizedBox( - height: 8, - ), - ...snapshot.data! - .where((it) => it.user != null) - .map( - (m) => Material( - color: chatThemeData.colorTheme.white, - child: InkWell( - onTap: () { - if (m.user != null) { - _mentionedUsers.add(m.user!); - } - - splits[splits.length - 1] = m.user!.name; - final rejoin = splits.join('@'); - - textEditingController.value = - TextEditingValue( - text: rejoin + - textEditingController.text.substring( - textEditingController - .selection.start), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); - _debounce!.cancel(); - _mentionsOverlay?.remove(); - _mentionsOverlay = null; - }, - child: widget.mentionsTileBuilder != null - ? widget.mentionsTileBuilder!(context, m) - : MentionTile(m), - ), - ), - ) - .toList(), - const SizedBox( - height: 8, - ), - ], - ), - ), - ), - ), - ); - }, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, ), ), ); @@ -1363,88 +1355,88 @@ class MessageInputState extends State { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; - return OverlayEntry(builder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return Positioned( - bottom: size.height + MediaQuery.of(context).viewInsets.bottom, - left: 0, - right: 0, - child: Card( - margin: const EdgeInsets.all(8), - elevation: 2, - color: chatThemeData.colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(const Size.fromHeight(200)), - decoration: BoxDecoration( - boxShadow: const [ - BoxShadow( - spreadRadius: -8, - blurRadius: 5, - offset: Offset(0, -4), + return OverlayEntry( + builder: (context) => Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ], - color: chatThemeData.colorTheme.white, - ), - child: ListView.builder( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - itemCount: emojis.length + 1, - itemBuilder: (context, i) { - if (i == 0) { - return Padding( - padding: const EdgeInsets.only(left: 8, top: 8), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: StreamSvgIcon.smile( - color: chatThemeData.colorTheme.accentBlue, + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(200)), + decoration: BoxDecoration( + boxShadow: const [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5, + offset: Offset(0, -4), + ), + ], + color: _streamChatTheme.colorTheme.white, + ), + child: ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: emojis.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8), + child: StreamSvgIcon.smile( + color: + _streamChatTheme.colorTheme.accentBlue, + ), + ), + Flexible( + child: Text( + 'Emoji matching "$query"', + style: TextStyle( + color: _streamChatTheme.colorTheme.black + .withOpacity(.5), + ), + ), + ) + ], + ), + ); + } + + final emoji = emojis.elementAt(i - 1)!; + final themeData = Theme.of(context); + return ListTile( + title: SubstringHighlight( + text: + // ignore: lines_longer_than_80_chars + "${emoji.char} ${emoji.name!.replaceAll('_', ' ')}", + term: query, + textStyleHighlight: + themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, + fontWeight: FontWeight.bold, + ), + textStyle: themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, ), ), - Flexible( - child: Text( - 'Emoji matching "$query"', - style: TextStyle( - color: chatThemeData.colorTheme.black - .withOpacity(.5), - ), - ), - ) - ], - ), - ); - } - - final emoji = emojis.elementAt(i - 1)!; - final themeData = Theme.of(context); - return ListTile( - title: SubstringHighlight( - text: - // ignore: lines_longer_than_80_chars - "${emoji.char} ${emoji.name!.replaceAll('_', ' ')}", - term: query, - textStyleHighlight: - themeData.textTheme.headline6!.copyWith( - fontSize: 14.5, - fontWeight: FontWeight.bold, - ), - textStyle: themeData.textTheme.headline6!.copyWith( - fontSize: 14.5, - ), - ), - onTap: () { - _chooseEmoji(splits, emoji); - }, - ); - }), - ), - ), - ); - }); + onTap: () { + _chooseEmoji(splits, emoji); + }, + ); + }), + ), + ), + )); } void _chooseEmoji(List splits, Emoji emoji) { @@ -1483,7 +1475,7 @@ class MessageInputState extends State { reverse: true, showBorder: !containsUrl, message: widget.quotedMessage!, - messageTheme: StreamChatTheme.of(context).otherMessageTheme, + messageTheme: _streamChatTheme.otherMessageTheme, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), ); } @@ -1568,32 +1560,29 @@ class MessageInputState extends State { ); } - Widget _buildRemoveButton(Attachment attachment) { - final chatThemeData = StreamChatTheme.of(context); - return SizedBox( - height: 24, - width: 24, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: () { - setState(() => _attachments.remove(attachment.id)); - }, - fillColor: chatThemeData.colorTheme.black.withOpacity(.5), - child: Center( - child: StreamSvgIcon.close( - size: 24, - color: chatThemeData.colorTheme.white, + Widget _buildRemoveButton(Attachment attachment) => SizedBox( + height: 24, + width: 24, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: () { + setState(() => _attachments.remove(attachment.id)); + }, + fillColor: _streamChatTheme.colorTheme.black.withOpacity(.5), + child: Center( + child: StreamSvgIcon.close( + size: 24, + color: _streamChatTheme.colorTheme.white, + ), ), ), - ), - ); - } + ); Widget _buildAttachment(Attachment attachment) { if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == @@ -1623,18 +1612,15 @@ class MessageInputState extends State { fit: BoxFit.cover, errorWidget: (_, obj, trace) => getFileTypeImage(attachment.extraData['other'] as String?), - progressIndicatorBuilder: (context, _, progress) { - final chatThemeData = StreamChatTheme.of(context); - return Shimmer.fromColors( - baseColor: chatThemeData.colorTheme.greyGainsboro, - highlightColor: chatThemeData.colorTheme.whiteSmoke, - child: Image.asset( - 'images/placeholder.png', - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ), - ); - }, + placeholder: (context, _) => Shimmer.fromColors( + baseColor: _streamChatTheme.colorTheme.greyGainsboro, + highlightColor: _streamChatTheme.colorTheme.whiteSmoke, + child: Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', + ), + ), ); case 'video': return Stack( @@ -1666,14 +1652,13 @@ class MessageInputState extends State { Widget _buildCommandButton() { final s = textEditingController.text.trim(); - final chatThemeData = StreamChatTheme.of(context); return IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty - ? chatThemeData.colorTheme.greyGainsboro + ? _streamChatTheme.colorTheme.greyGainsboro : (_commandsOverlay != null - ? chatThemeData.messageInputTheme.actionButtonColor - : chatThemeData.messageInputTheme.actionButtonIdleColor), + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.messageInputTheme.actionButtonIdleColor), ), padding: const EdgeInsets.all(0), constraints: const BoxConstraints.tightFor( @@ -1708,40 +1693,37 @@ class MessageInputState extends State { ); } - Widget _buildAttachmentButton() { - final chatThemeData = StreamChatTheme.of(context); - return IconButton( - icon: StreamSvgIcon.attach( - color: _openFilePickerSection - ? chatThemeData.messageInputTheme.actionButtonColor - : chatThemeData.messageInputTheme.actionButtonIdleColor, - ), - padding: const EdgeInsets.all(0), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - onPressed: () async { - _emojiOverlay?.remove(); - _emojiOverlay = null; - _commandsOverlay?.remove(); - _commandsOverlay = null; - _mentionsOverlay?.remove(); - _mentionsOverlay = null; + Widget _buildAttachmentButton() => IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.messageInputTheme.actionButtonIdleColor, + ), + padding: const EdgeInsets.all(0), + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; - if (_openFilePickerSection) { - setState(() { - _animateContainer = true; - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - } else { - showAttachmentModal(); - } - }, - ); - } + if (_openFilePickerSection) { + setState(() { + _animateContainer = true; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ); /// Show the attachment modal, making the user choose where to /// pick a media from @@ -1948,8 +1930,7 @@ class MessageInputState extends State { padding: const EdgeInsets.all(8), child: StreamSvgIcon( assetName: _getIdleSendIcon(), - color: - StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor, + color: _streamChatTheme.messageInputTheme.sendButtonIdleColor, ), ); @@ -1965,8 +1946,7 @@ class MessageInputState extends State { ), icon: StreamSvgIcon( assetName: _getSendIcon(), - color: - StreamChatTheme.of(context).messageInputTheme.sendButtonColor, + color: _streamChatTheme.messageInputTheme.sendButtonColor, ), ), ); @@ -2083,9 +2063,8 @@ class MessageInputState extends State { StreamSubscription? _keyboardListener; void _showErrorAlert(String description) { - final chatThemeData = StreamChatTheme.of(context); showModalBottomSheet( - backgroundColor: chatThemeData.colorTheme.white, + backgroundColor: _streamChatTheme.colorTheme.white, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -2099,7 +2078,7 @@ class MessageInputState extends State { height: 26, ), StreamSvgIcon.error( - color: chatThemeData.colorTheme.accentRed, + color: _streamChatTheme.colorTheme.accentRed, size: 24, ), const SizedBox( @@ -2107,7 +2086,7 @@ class MessageInputState extends State { ), Text( 'Something went wrong', - style: chatThemeData.textTheme.headlineBold, + style: _streamChatTheme.textTheme.headlineBold, ), const SizedBox( height: 7, @@ -2123,7 +2102,7 @@ class MessageInputState extends State { height: 36, ), Container( - color: chatThemeData.colorTheme.black.withOpacity(.08), + color: _streamChatTheme.colorTheme.black.withOpacity(.08), height: 1, ), Row( @@ -2135,8 +2114,8 @@ class MessageInputState extends State { }, child: Text( 'OK', - style: chatThemeData.textTheme.bodyBold - .copyWith(color: chatThemeData.colorTheme.accentBlue), + style: _streamChatTheme.textTheme.bodyBold + .copyWith(color: _streamChatTheme.colorTheme.accentBlue), ), ), ], @@ -2169,6 +2148,7 @@ class MessageInputState extends State { @override void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); if (widget.editMessage != null && !_initialized) { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; @@ -2233,6 +2213,7 @@ class _PickerWidget extends StatefulWidget { required this.selectedMedias, required this.onAddMoreFilesClick, required this.onMediaSelected, + required this.streamChatTheme, }) : super(key: key); final int filePickerIndex; @@ -2240,6 +2221,7 @@ class _PickerWidget extends StatefulWidget { final List selectedMedias; final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; final void Function(AssetEntity) onMediaSelected; + final StreamChatThemeData streamChatTheme; @override __PickerWidgetState createState() => __PickerWidgetState(); @@ -2266,7 +2248,6 @@ class __PickerWidgetState extends State<_PickerWidget> { return const Center(child: CircularProgressIndicator()); } - final chatThemeData = StreamChatTheme.of(context); if (snapshot.data!) { if (widget.containsFile) { return GestureDetector( @@ -2275,12 +2256,12 @@ class __PickerWidgetState extends State<_PickerWidget> { }, child: Container( constraints: const BoxConstraints.expand(), - color: chatThemeData.colorTheme.whiteSmoke, + color: widget.streamChatTheme.colorTheme.whiteSmoke, alignment: Alignment.center, child: Text( 'Add more files', style: TextStyle( - color: chatThemeData.colorTheme.accentBlue, + color: widget.streamChatTheme.colorTheme.accentBlue, fontWeight: FontWeight.bold, ), ), @@ -2298,7 +2279,7 @@ class __PickerWidgetState extends State<_PickerWidget> { PhotoManager.openSetting(); }, child: Container( - color: chatThemeData.colorTheme.whiteSmoke, + color: widget.streamChatTheme.colorTheme.whiteSmoke, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2307,21 +2288,21 @@ class __PickerWidgetState extends State<_PickerWidget> { 'svgs/icon_picture_empty_state.svg', package: 'stream_chat_flutter', height: 140, - color: chatThemeData.colorTheme.greyGainsboro, + color: widget.streamChatTheme.colorTheme.greyGainsboro, ), Text( // ignore: lines_longer_than_80_chars 'Please enable access to your photos \nand videos so you can share them with friends.', - style: chatThemeData.textTheme.body - .copyWith(color: chatThemeData.colorTheme.grey), + style: widget.streamChatTheme.textTheme.body.copyWith( + color: widget.streamChatTheme.colorTheme.grey), textAlign: TextAlign.center, ), const SizedBox(height: 6), Center( child: Text( 'Allow access to your gallery', - style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentBlue, + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + color: widget.streamChatTheme.colorTheme.accentBlue, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index db14924b..aae9d193 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -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)? 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 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 { void Function(Message)? _onThreadTap; bool _showScrollToBottom = false; late final ItemPositionsListener _itemPositionListener; + late final Stream> _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 { 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 data) { messages = data; @@ -392,8 +408,7 @@ class _MessageListViewState extends State { } 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 { 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 { }, ), if (widget.showScrollToBottom) _buildScrollToBottom(), - Positioned( - top: 20, - child: ValueListenableBuilder>( - 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>( + 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 _paginateData( StreamChannelState? channel, QueryDirection direction) => _messageListController.paginateData!(direction: direction); @@ -612,8 +645,8 @@ class _MessageListViewState extends State { Widget _buildScrollToBottom() => StreamBuilder>( 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 { 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 { 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 { } }, child: StreamSvgIcon.down( - color: chatThemeData.colorTheme.black, + color: _streamTheme.colorTheme.black, ), ), if (showUnreadCount) @@ -691,44 +723,13 @@ class _MessageListViewState extends State { Widget _buildLoadingIndicator( StreamChannelState streamChannel, QueryDirection direction, - ) { - final stream = direction == QueryDirection.top - ? streamChannel.queryTopMessages - : streamChannel.queryBottomMessages; - return StreamBuilder( - 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 { } } if (mounted) { - setState(() => _showScrollToBottom = !isVisible); + if (_showScrollToBottom == isVisible) { + setState(() => _showScrollToBottom = !isVisible); + } } }, child: messageWidget, @@ -807,8 +810,11 @@ class _MessageListViewState extends State { ) { 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 { 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 { } 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 { ? 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('MESSAGE-${message.id}'), message: message, @@ -1033,8 +1044,8 @@ class _MessageListViewState extends State { 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 { 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 { 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 { if (!initialMessageHighlightComplete && widget.highlightInitialMessage && _isInitialMessage(message.id)) { - final colorTheme = chatThemeData.colorTheme; + final colorTheme = _streamTheme.colorTheme; final highlightColor = widget.messageHighlightColor ?? colorTheme.highlight; child = TweenAnimationBuilder( @@ -1114,6 +1126,8 @@ class _MessageListViewState extends State { _scrollController = widget.scrollController ?? ItemScrollController(); _itemPositionListener = widget.itemPositionListener ?? ItemPositionsListener.create(); + _itemPositionStream = + _valueListenableToStreamAdapter(_itemPositionListener.itemPositions); _getOnThreadTap(); super.initState(); @@ -1122,6 +1136,7 @@ class _MessageListViewState extends State { @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 { Navigator.push( context, MaterialPageRoute( - builder: (_) => StreamBuilder( + builder: (_) => BetterStreamBuilder( 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 { 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( + 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 _valueListenableToStreamAdapter(ValueListenable listenable) { + // ignore: close_sinks + late StreamController _controller; + + void listener() { + _controller.add(listenable.value); + } + + void start() { + listenable.addListener(listener); + } + + void end() { + listenable.removeListener(listener); + } + + _controller = StreamController( + onListen: start, + onPause: end, + onResume: start, + onCancel: end, + ); + + return _controller.stream; +} diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index 5f3bc5a4..842fe79d 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -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( - 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: [ + 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: [ - 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( + 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(), diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 97e061ae..7fb092d6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -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 attachmentBuilders; @@ -401,37 +413,39 @@ class _MessageWidgetState extends State 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 @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 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 ? 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: [ - 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: [ + 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: [ - 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: [ + 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 ); } + @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 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 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 ); } + 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 ); } - Widget _buildThreadParticipantsIndicator(Iterable 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 threadParticipants) => + _ThreadParticipants( + streamChatTheme: _streamChatTheme, + threadParticipants: threadParticipants, + ); Widget _buildReactionIndicator( BuildContext context, ) { - final ownId = StreamChat.of(context).user!.id; + final ownId = _streamChat.user!.id; final reactionsMap = {}; widget.message.latestReactions?.forEach((element) { if (!reactionsMap.containsKey(element.type) || @@ -878,10 +909,11 @@ class _MessageWidgetState extends State 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 !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, + showPinButton: widget.showPinButton, customActions: widget.customActions, ), )); @@ -927,10 +960,11 @@ class _MessageWidgetState extends State 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 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 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 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 ); } - 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 } if (hasUrlAttachments) { - return StreamChatTheme.of(context).colorTheme.blueAlice; + return _streamChatTheme.colorTheme.blueAlice; } if (isOnlyEmoji) { @@ -1153,6 +1215,45 @@ class _MessageWidgetState extends State } } +class _ThreadParticipants extends StatelessWidget { + const _ThreadParticipants({ + Key? key, + required StreamChatThemeData streamChatTheme, + required this.threadParticipants, + }) : _streamChatTheme = streamChatTheme, + super(key: key); + + final StreamChatThemeData _streamChatTheme; + final Iterable 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, diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 07a820b9..43620640 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart index ce75445b..a3b2052f 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/src/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/reaction_icon.dart index 4466e154..e675128b 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_icon.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_icon.dart @@ -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; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reaction_picker.dart index 8a89b8c3..5101cbb7 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_picker.dart @@ -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 triggerAnimations(); } - return TweenAnimationBuilder( - 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((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((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( + 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 { diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index 25c2ca32..91ed2537 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -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, + ); + }, ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index 78dfba2c..fb5017e1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -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; diff --git a/packages/stream_chat_flutter/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart index e9377674..100cdb53 100644 --- a/packages/stream_chat_flutter/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -33,13 +33,22 @@ class TypingIndicator extends StatelessWidget { Widget build(BuildContext context) { final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; - return StreamBuilder>( + + final altWidget = Align( + key: const Key('alternative'), + alignment: alignment, + child: Container( + child: alternativeWidget ?? const Offstage(), + ), + ); + return BetterStreamBuilder>( 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, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index 9c3a5f88..381bad39 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -17,16 +17,16 @@ class UnreadIndicator extends StatelessWidget { Widget build(BuildContext context) { final client = StreamChat.of(context).client; return IgnorePointer( - child: StreamBuilder( + child: BetterStreamBuilder( 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, diff --git a/packages/stream_chat_flutter/lib/src/url_attachment.dart b/packages/stream_chat_flutter/lib/src/url_attachment.dart index 995475a8..4944c73c 100644 --- a/packages/stream_chat_flutter/lib/src/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/url_attachment.dart @@ -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), diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 5a86d4ef..e98112fc 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -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, diff --git a/packages/stream_chat_flutter/lib/svgs/icon_pin.svg b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg new file mode 100644 index 00000000..0f494729 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 6da11fc1..95914b01 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -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 diff --git a/packages/stream_chat_flutter/test/src/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel_header_test.dart index 6da3e20f..9f6e981d 100644 --- a/packages/stream_chat_flutter/test/src/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_header_test.dart @@ -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)); diff --git a/packages/stream_chat_flutter/test/src/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input_test.dart index 63ddb10f..f07f518d 100644 --- a/packages/stream_chat_flutter/test/src/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input_test.dart @@ -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); }, ); diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 083c2ff5..d2b04e11 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -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 {} diff --git a/packages/stream_chat_flutter/test/src/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart index 29bc0d79..96a6c678 100644 --- a/packages/stream_chat_flutter/test/src/unread_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart @@ -83,7 +83,7 @@ void main() { ), )); - expect(find.byType(SizedBox), findsOneWidget); + expect(find.text('0'), findsNothing); }, ); diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 1930e36a..26cfa6c3 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -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 diff --git a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart new file mode 100644 index 00000000..ddf342dc --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart @@ -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 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? 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(); +} + +class _BetterStreamBuilderState extends State> { + 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 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; + } + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index cf11ef5f..2a257fe6 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -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 { .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 { return true; } - return StreamBuilder?>( - 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?>( + 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); }, ); } diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index ef947a0f..8bd41c76 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -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'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index b1a52d5f..f20fe3eb 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -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: diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index e53cd0a8..4e34f8f2 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -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 = []; 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 = []; 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( diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index 6ca24363..73227e7c 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -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(); diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 62306bc6..168454c8 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -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( 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( find.byKey(streamChatCoreKey), ); diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index c35459ca..aa7c7513 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.0.0-nullsafety.5 + +* Update llc dependency +* Minor fixes and improvements + ## 2.0.0-nullsafety.2 * Update llc dependency diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index f3759414..ff1a8579 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -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