diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_view.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_view.dart new file mode 100644 index 00000000..920ee09b --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_view.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/channels_bloc.dart'; + +import 'stream_chat.dart'; + +/// Callback called when tapping on a channel +typedef ChannelTapCallback = void Function(Channel, Widget); + +/// Builder used to create a custom [ChannelPreview] from a [Channel] +typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); + +typedef ViewInfoCallback = void Function(Channel); + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view_paint.png) +/// +/// It shows the list of current channels. +/// +/// ```dart +/// class ChannelListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: ChannelListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChat] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelListView extends StatefulWidget { + /// Instantiate a new ChannelListView + ChannelListView({ + Key key, + this.filter, + this.options, + this.sort, + this.pagination, + this.separatorBuilder, + this.errorBuilder, + this.swipeToAction = false, + this.pullToRefresh = true, + @required this.emptyBuilder, + @required this.loadingBuilder, + @required this.listBuilder, + }) : super(key: key); + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + final WidgetBuilder loadingBuilder; + + final Function(BuildContext, List) listBuilder; + + /// If true a default swipe to action behaviour will be added to this widget + final bool swipeToAction; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of channels to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + @override + _ChannelListViewState createState() => _ChannelListViewState(); +} + +class _ChannelListViewState extends State + with WidgetsBindingObserver { + final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + final channelsBloc = ChannelsBloc.of(context); + + if (!widget.pullToRefresh) { + return _buildListView(channelsBloc); + } + + return RefreshIndicator( + onRefresh: () async { + return channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }, + child: _buildListView(channelsBloc), + ); + } + + StreamBuilder> _buildListView( + ChannelsBlocState channelsBlocState, + ) { + return StreamBuilder>( + stream: channelsBlocState.channelsStream, + builder: (context, snapshot) { + var child; + if (snapshot.hasError) { + child = _buildErrorWidget( + snapshot, + context, + channelsBlocState, + ); + } else if (!snapshot.hasData) { + child = _buildLoadingWidget(); + } else { + final channels = snapshot.data; + + child = widget.emptyBuilder(context); + + if (channels.isNotEmpty) { + return widget.listBuilder(context, channels); + } + } + + return AnimatedSwitcher( + child: child, + duration: Duration(milliseconds: 500), + ); + }, + ); + } + + Widget _buildLoadingWidget() { + return widget.loadingBuilder(context); + } + + Widget _buildErrorWidget( + AsyncSnapshot> snapshot, + BuildContext context, + ChannelsBlocState channelsBlocState, + ) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + return widget.errorBuilder(snapshot.error); + } + + void _listenChannelPagination(ChannelsBlocState channelsProvider) { + if (_scrollController.position.maxScrollExtent == + _scrollController.offset && + _scrollController.offset != 0) { + channelsProvider.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination.copyWith( + offset: channelsProvider.channels?.length ?? 0, + ), + options: widget.options, + ); + } + } + + StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + + _scrollController.addListener(() { + channelsBloc.queryChannelsLoading.first.then((loading) { + if (!loading) { + _listenChannelPagination(channelsBloc); + } + }); + }); + + final client = StreamChat.of(context).client; + + _subscription = client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.notificationMessageNew, + EventType.channelVisible, + ) + .listen((event) { + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }); + } + + @override + void didUpdateWidget(ChannelListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + } + } + + @override + void dispose() { + _subscription.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart new file mode 100644 index 00000000..a9ab1682 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_chat.dart'; + +/// Widget dedicated to the management of a channel list with pagination +class ChannelsBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Set this to true to prevent channels to be brought to the top of the list when a new message arrives + final bool lockChannelsOrder; + + /// Comparator used to sort the channels when a message.new event is received + final Comparator channelsComparator; + + /// Function used to evaluate if a channel should be added to the list when a message.new event is received + final bool Function(Event) shouldAddChannel; + + /// Instantiate a new ChannelsBloc + const ChannelsBloc({ + Key key, + this.child, + this.lockChannelsOrder = false, + this.channelsComparator, + this.shouldAddChannel, + }) : super(key: key); + + @override + ChannelsBlocState createState() => ChannelsBlocState(); + + /// Use this method to get the current [ChannelsBlocState] instance + static ChannelsBlocState of(BuildContext context) { + ChannelsBlocState streamChatState; + + streamChatState = context.findAncestorStateOfType(); + + if (streamChatState == null) { + throw Exception('You must have a ChannelsBloc widget as ancestor'); + } + + return streamChatState; + } +} + +/// The current state of the [ChannelsBloc] +class ChannelsBlocState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + /// The current channel list + List get channels => _channelsController.value; + + /// The current channel list as a stream + Stream> get channelsStream => _channelsController.stream; + + final BehaviorSubject _queryChannelsLoadingController = + BehaviorSubject.seeded(false); + + final BehaviorSubject> _channelsController = BehaviorSubject(); + + /// The stream notifying the state of queryChannel call + Stream get queryChannelsLoading => + _queryChannelsLoadingController.stream; + + final List _hiddenChannels = []; + + /// Calls [client.queryChannels] updating [queryChannelsLoading] stream + Future queryChannels({ + Map filter, + List sortOptions, + PaginationParams paginationParams, + Map options, + bool onlyOffline = false, + }) async { + final client = StreamChat.of(context).client; + + if (client.state?.user == null || + _queryChannelsLoadingController.value == true) { + return; + } + _queryChannelsLoadingController.sink.add(true); + + try { + final clear = paginationParams == null || + paginationParams.offset == null || + paginationParams.offset == 0; + final oldChannels = List.from(channels ?? []); + final _channels = await client.queryChannels( + filter: filter, + sort: sortOptions, + options: options, + paginationParams: paginationParams, + onlyOffline: onlyOffline, + ); + + if (clear) { + _channelsController.add(_channels); + } else { + final l = oldChannels + _channels; + _channelsController.add(l); + } + _queryChannelsLoadingController.sink.add(false); + } catch (err, stackTrace) { + print(err); + print(stackTrace); + _queryChannelsLoadingController.addError(err, stackTrace); + } + } + + final List _subscriptions = []; + + @override + void initState() { + super.initState(); + + final client = StreamChat.of(context).client; + + if (!widget.lockChannelsOrder) { + _subscriptions.add(client.on(EventType.messageNew).listen((e) { + final newChannels = List.from(channels ?? []); + final index = newChannels.indexWhere((c) => c.cid == e.cid); + if (index > -1) { + if (index > 0) { + final channel = newChannels.removeAt(index); + newChannels.insert(0, channel); + } + } else if (widget.shouldAddChannel != null && + widget.shouldAddChannel(e)) { + final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid); + if (hiddenIndex > -1) { + newChannels.insert(0, _hiddenChannels[hiddenIndex]); + _hiddenChannels.removeAt(hiddenIndex); + } else { + if (client.state?.channels != null && + client.state?.channels[e.cid] != null) { + newChannels.insert(0, client.state.channels[e.cid]); + } + } + } + + if (widget.channelsComparator != null) { + newChannels.sort(widget.channelsComparator); + } + _channelsController.add(newChannels); + })); + } + + _subscriptions.add(client.on(EventType.channelHidden).listen((event) async { + final newChannels = List.from(channels ?? []); + final channelIndex = newChannels.indexWhere((c) => c.cid == event.cid); + if (channelIndex > -1) { + final channel = newChannels.removeAt(channelIndex); + _hiddenChannels.add(channel); + _channelsController.add(newChannels); + } + })); + + _subscriptions.add(client + .on(EventType.channelDeleted, EventType.notificationRemovedFromChannel) + .listen((e) { + final channel = e.channel; + _channelsController + .add(List.from(channels..removeWhere((c) => c.cid == channel.cid))); + })); + } + + @override + void dispose() { + _channelsController.close(); + _queryChannelsLoadingController.close(); + _subscriptions.forEach((s) => s.cancel()); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart new file mode 100644 index 00000000..67b6455d --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat.dart'; + +/// Widget dedicated to the management of a message list with pagination +class MessageSearchBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Instantiate a new MessageSearchBloc + const MessageSearchBloc({ + Key key, + @required this.child, + }) : super(key: key); + + @override + MessageSearchBlocState createState() => MessageSearchBlocState(); + + /// Use this method to get the current [MessageSearchBlocState] instance + static MessageSearchBlocState of(BuildContext context) { + MessageSearchBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a MessageSearchBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [MessageSearchBloc] +class MessageSearchBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current messages list + List get messageResponses => _messageResponses.value; + + /// The current messages list as a stream + Stream> get messagesStream => + _messageResponses.stream; + + final BehaviorSubject> _messageResponses = + BehaviorSubject(); + + final BehaviorSubject _queryMessagesLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryMessagesLoading => + _queryMessagesLoadingController.stream; + + /// Calls [Client.search] updating [messageResponses] stream + Future search({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) async { + _messageResponses.add(null); + try { + final messages = await _search( + filter: filter, + messageFilter: messageFilter, + sort: sort, + query: query, + pagination: pagination, + ); + _messageResponses.add(messages.results); + } catch (err, stk) { + _messageResponses.addError(err, stk); + } + } + + /// Calls [Client.search] updating [queryMessagesLoading] stream + Future loadMore({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) async { + if (_queryMessagesLoadingController.value == true) { + return; + } + _queryMessagesLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldMessages = List.from(messageResponses ?? []); + + final messages = await _search( + filter: filter, + messageFilter: messageFilter, + sort: sort, + query: query, + pagination: pagination, + ); + + if (clear) { + _messageResponses.add(messages.results); + } else { + final temp = oldMessages + messages.results; + _messageResponses.add(temp); + } + + _queryMessagesLoadingController.add(false); + } catch (err, stackTrace) { + _queryMessagesLoadingController.addError(err, stackTrace); + } + } + + Future _search({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) { + final client = StreamChat.of(context).client; + return client.search( + filter, + sort, + query, + pagination, + messageFilters: messageFilter, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _messageResponses.close(); + _queryMessagesLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart new file mode 100644 index 00000000..8b4b97ef --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -0,0 +1,356 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +enum QueryDirection { top, bottom } + +/// Widget used to provide information about the channel to the widget tree +/// +/// Use [StreamChannel.of] to get the current [StreamChannelState] instance. +class StreamChannel extends StatefulWidget { + const StreamChannel({ + Key key, + @required this.child, + @required this.channel, + this.showLoading = true, + this.initialMessageId, + }) : assert(child != null), + assert(channel != null), + super(key: key); + + final Widget child; + final Channel channel; + final bool showLoading; + + /// If passed the channel will load from this particular message. + final String initialMessageId; + + /// Use this method to get the current [StreamChannelState] instance + static StreamChannelState of(BuildContext context) { + StreamChannelState streamChannelState; + + streamChannelState = context.findAncestorStateOfType(); + + if (streamChannelState == null) { + throw Exception( + 'You must have a StreamChannel widget at the top of your widget tree'); + } + + return streamChannelState; + } + + @override + StreamChannelState createState() => StreamChannelState(); +} + +class StreamChannelState extends State { + /// Current channel + Channel get channel => widget.channel; + + /// InitialMessageId + String get initialMessageId => widget.initialMessageId; + + /// Current channel state stream + Stream get channelStateStream => + widget.channel.state.channelStateStream; + + final _queryTopMessagesController = BehaviorSubject.seeded(false); + final _queryBottomMessagesController = BehaviorSubject.seeded(false); + + /// The stream notifying the state of [_queryTopMessages] call + Stream get queryTopMessages => _queryTopMessagesController.stream; + + /// The stream notifying the state of [_queryBottomMessages] call + Stream get queryBottomMessages => _queryBottomMessagesController.stream; + + bool _topPaginationEnded = false; + bool _bottomPaginationEnded = false; + + Future _queryTopMessages({ + int limit = 20, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController?.value == true) { + return; + } + _queryTopMessagesController.add(true); + + if (channel.state.messages.isEmpty) { + return _queryTopMessagesController.add(false); + } + + final oldestMessage = channel.state.messages.first; + + try { + final state = await queryBeforeMessage( + oldestMessage.id, + limit: limit, + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + _topPaginationEnded = true; + } + _queryTopMessagesController.add(false); + } catch (e, stk) { + _queryTopMessagesController.addError(e, stk); + } + } + + Future _queryBottomMessages({ + int limit = 20, + bool preferOffline = false, + }) async { + if (_bottomPaginationEnded || + _queryBottomMessagesController?.value == true || + channel?.state?.isUpToDate == true) return; + _queryBottomMessagesController.add(true); + + if (channel.state.messages.isEmpty) { + return _queryBottomMessagesController.add(false); + } + + final recentMessage = channel.state.messages.last; + + try { + final state = await queryAfterMessage( + recentMessage.id, + limit: limit, + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + _bottomPaginationEnded = true; + } + _queryBottomMessagesController.add(false); + } catch (e, stk) { + _queryBottomMessagesController.addError(e, stk); + } + } + + /// Calls [channel.query] updating [queryMessage] stream + Future queryMessages({QueryDirection direction = QueryDirection.top}) { + if (direction == QueryDirection.top) return _queryTopMessages(); + return _queryBottomMessages(); + } + + /// Calls [channel.getReplies] updating [queryMessage] stream + Future getReplies( + String parentId, { + int limit = 50, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController.value) return; + _queryTopMessagesController.add(true); + + Message message; + if (channel.state.threads.containsKey(parentId)) { + final thread = channel.state.threads[parentId]; + if (thread.isNotEmpty) { + message = thread.first; + } + } + + try { + final response = await channel.getReplies( + parentId, + PaginationParams( + lessThan: message?.id, + limit: limit, + ), + preferOffline: preferOffline, + ); + if (response.messages.isEmpty || response.messages.length < limit) { + _topPaginationEnded = true; + } + _queryTopMessagesController.add(false); + } catch (e, stk) { + _queryTopMessagesController.addError(e, stk); + } + } + + /// Query the channel members and watchers + Future queryMembersAndWatchers() async { + await widget.channel.query( + membersPagination: PaginationParams( + offset: channel.state.members?.length, + limit: 100, + ), + watchersPagination: PaginationParams( + offset: channel.state.watchers?.length, + limit: 100, + ), + ); + } + + /// Loads channel at specific message + Future loadChannelAtMessage( + String messageId, { + int before = 20, + int after = 20, + bool preferOffline = false, + }) { + return queryAtMessage( + messageId: messageId, + before: before, + after: after, + preferOffline: preferOffline, + ); + } + + /// + Future queryAtMessage({ + String messageId, + int before = 20, + int after = 20, + bool preferOffline = false, + }) async { + if (channel.state == null) return; + channel.state.isUpToDate = false; + channel.state.truncate(); + + if (messageId == null) { + await channel.query( + messagesPagination: PaginationParams( + limit: before, + ), + preferOffline: preferOffline, + ); + channel.state.isUpToDate = true; + return; + } + + return Future.wait([ + queryBeforeMessage( + messageId, + limit: before, + preferOffline: preferOffline, + ), + queryAfterMessage( + messageId, + limit: after, + preferOffline: preferOffline, + ), + ]); + } + + /// + Future queryBeforeMessage( + String messageId, { + int limit = 20, + bool preferOffline = false, + }) { + return channel.query( + messagesPagination: PaginationParams( + lessThan: messageId, + limit: limit, + ), + preferOffline: preferOffline, + ); + } + + /// + Future queryAfterMessage( + String messageId, { + int limit = 20, + bool preferOffline = false, + }) async { + final state = await channel.query( + messagesPagination: PaginationParams( + greaterThanOrEqual: messageId, + limit: limit, + ), + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + channel.state.isUpToDate = true; + } + return state; + } + + /// + Future getMessage(String messageId) async { + var message = channel.state.messages.firstWhere( + (it) => it.id == messageId, + orElse: () => null, + ); + if (message == null) { + final response = await channel.getMessagesById([messageId]); + message = response.messages.first; + } + return message; + } + + /// Reloads the channel with latest message + Future reloadChannel() => queryAtMessage(before: 30); + + List> _futures; + + Future get _loadChannelAtMessage async { + try { + await loadChannelAtMessage(initialMessageId); + return true; + } catch (e, stk) { + print('Error: $e\nStack: $stk'); + rethrow; + } + } + + @override + void initState() { + super.initState(); + _futures = [widget.channel.initialized]; + if (initialMessageId != null) { + _futures.add(_loadChannelAtMessage); + } + } + + @override + void dispose() { + _queryTopMessagesController.close(); + _queryBottomMessagesController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = FutureBuilder>( + future: Future.wait(_futures), + initialData: [ + channel.state != null, + if (initialMessageId != null) false, + ], + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + var message = snapshot.error.toString(); + if (snapshot.error is DioError) { + final dioError = snapshot.error as DioError; + if (dioError.type == DioErrorType.RESPONSE) { + message = dioError.message; + } else { + message = 'Check your connection and retry'; + } + } + return Center( + child: Text(message), + ); + } + final initialized = snapshot.data[0]; + final dataLoaded = initialMessageId == null ? true : snapshot.data[1]; + if (widget.showLoading && (!initialized || !dataLoaded)) { + return Center( + child: CircularProgressIndicator(), + ); + } + return widget.child; + }, + ); + if (initialMessageId != null) { + child = Material(child: child); + } + return child; + } +} diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 10e0688e..e318c67d 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: stream_chat: ^0.2.23+3 + rxdart: ^0.24.1 flutter: sdk: flutter