diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id index 20c7c514..e72273c3 100644 --- a/example/ios/Flutter/.last_build_id +++ b/example/ios/Flutter/.last_build_id @@ -1 +1 @@ -c3e639ccf9b069e37a7d1345194e6b99 \ No newline at end of file +13a41b8138c4868054e44b5158f3bdd6 \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index bf8c0e1a..f266b656 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -40,7 +40,7 @@ PODS: - Firebase/Messaging (6.33.0): - Firebase/CoreOnly - FirebaseMessaging (~> 4.7.0) - - firebase_core (0.5.2-1): + - firebase_core (0.5.3): - Firebase/CoreOnly (~> 6.33.0) - Flutter - firebase_messaging (7.0.3): @@ -258,7 +258,7 @@ SPEC CHECKSUMS: esys_flutter_share: 403498dab005b36ce1f8d7aff377e81f0621b0b4 file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 Firebase: 8db6f2d1b2c5e2984efba4949a145875a8f65fe5 - firebase_core: 7423d688a1c6f2f2d859d64ae26991be39989781 + firebase_core: 5d6a02f3d85acd5f8321c2d6d62877626a670659 firebase_messaging: 0aea2cd5885b65e19ede58ee3507f485c992cc75 FirebaseCore: d889d9e12535b7f36ac8bfbf1713a0836a3012cd FirebaseCoreDiagnostics: 770ac5958e1372ce67959ae4b4f31d8e127c3ac1 diff --git a/example/ios/fastlane/report.xml b/example/ios/fastlane/report.xml index d6fdf2d7..17688944 100644 --- a/example/ios/fastlane/report.xml +++ b/example/ios/fastlane/report.xml @@ -5,39 +5,27 @@ - + - + - + - + - - - - - - - - - - - - - + diff --git a/example/lib/chat_info_screen.dart b/example/lib/chat_info_screen.dart index 07e36301..eb12bfee 100644 --- a/example/lib/chat_info_screen.dart +++ b/example/lib/chat_info_screen.dart @@ -363,8 +363,8 @@ class __SharedGroupsScreenState extends State<_SharedGroupsScreen> { ), backgroundColor: StreamChatTheme.of(context).primaryColor, ), - body: StreamBuilder>( - stream: chat.client.queryChannels( + body: FutureBuilder>( + future: chat.client.queryChannels( filter: { r'$and': [ { diff --git a/example/lib/group_chat_details_screen.dart b/example/lib/group_chat_details_screen.dart index d759a82e..c6d4331d 100644 --- a/example/lib/group_chat_details_screen.dart +++ b/example/lib/group_chat_details_screen.dart @@ -133,7 +133,7 @@ class _GroupChatDetailsScreenState extends State { context, Routes.CHANNEL_PAGE, ModalRoute.withName(Routes.HOME), - arguments: channel, + arguments: ChannelPageArgs(channel: channel), ); }, ), diff --git a/example/lib/main.dart b/example/lib/main.dart index 8a67a7da..07716868 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -59,7 +59,7 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: ThemeData.light(), darkTheme: ThemeData.dark(), - //TODO change to system once dark theme is implemented + //TODO change to system once dark theme is implemented themeMode: ThemeMode.light, onGenerateRoute: AppRoutes.generateRoute, initialRoute: @@ -215,12 +215,16 @@ class _HomePageState extends State { alignment: Alignment.bottomCenter, child: ListTile( onTap: () async { - await StreamChat.of(context).client.disconnect(); + Navigator.pop(context); final secureStorage = FlutterSecureStorage(); await secureStorage.deleteAll(); - Navigator.pop(context); - Navigator.pushReplacementNamed( + + StreamChat.of(context).client.disconnect( + clearUser: true, + ); + + await Navigator.pushReplacementNamed( context, Routes.CHOOSE_USER, ); @@ -296,130 +300,113 @@ class _ChannelListPageState extends State { @override Widget build(BuildContext context) { final user = StreamChat.of(context).user; - return ChannelsBloc( - child: MessageSearchBloc( - child: Column( - children: [ - SearchTextField( - controller: _controller, - showCloseButton: _isSearchActive, - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) => FocusScope.of(context).unfocus(), - child: _isSearchActive - ? MessageSearchListView( - messageQuery: _channelQuery, - filters: { - 'members': { - r'$in': [user.id] - } - }, - sortOptions: [ - SortOption( - 'created_at', - direction: SortOption.ASC, - ), - ], - paginationParams: PaginationParams(limit: 20), - onItemTap: (message) {}, - ) - : ChannelListView( - onStartChatPressed: () { - Navigator.pushNamed(context, Routes.NEW_CHAT); - }, - swipeToAction: true, - filter: { - 'members': { - r'$in': [user.id], + return WillPopScope( + onWillPop: () async { + if (_isSearchActive) { + _controller.clear(); + setState(() => _isSearchActive = false); + return false; + } + return true; + }, + child: ChannelsBloc( + child: MessageSearchBloc( + child: Column( + children: [ + SearchTextField( + controller: _controller, + showCloseButton: _isSearchActive, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: _isSearchActive + ? MessageSearchListView( + messageQuery: _channelQuery, + filters: { + 'members': { + r'$in': [user.id] + } }, - }, - options: { - 'presence': true, - }, - pagination: PaginationParams( - limit: 20, + sortOptions: [ + SortOption( + 'created_at', + direction: SortOption.ASC, + ), + ], + paginationParams: PaginationParams(limit: 20), + onItemTap: (messageResponse) async { + final client = StreamChat.of(context).client; + final message = messageResponse.message; + final channel = client.channel( + messageResponse.channel.type, + id: messageResponse.channel.id, + ); + if (channel.state == null) { + await channel.watch(); + } + Navigator.pushNamed( + context, + Routes.CHANNEL_PAGE, + arguments: ChannelPageArgs( + channel: channel, + initialMessage: message, + ), + ); + }, + ) + : ChannelListView( + onStartChatPressed: () { + Navigator.pushNamed(context, Routes.NEW_CHAT); + }, + swipeToAction: true, + filter: { + 'members': { + r'$in': [user.id], + }, + }, + options: { + 'presence': true, + }, + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), - ), + ), ), ), - ), - ], + ], + ), ), ), ); } } -class ChannelQuerySearchResultPage extends StatelessWidget { - final Stream> searchResultStream; +class ChannelPageArgs { + final Channel channel; + final Message initialMessage; - const ChannelQuerySearchResultPage({ - Key key, - @required this.searchResultStream, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return StreamBuilder>( - initialData: const [], - stream: searchResultStream, - builder: (context, snapshot) { - final result = snapshot.data; - return Column( - children: [ - if (result.isNotEmpty) - Container( - width: double.maxFinite, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.black.withOpacity(0.02), - Colors.white.withOpacity(0.05), - ], - stops: [0, 1], - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 8, - ), - child: Text( - '${result.length} results', - style: TextStyle( - color: Colors.black.withOpacity(0.5), - ), - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: result.length, - itemBuilder: (context, index) { - return ListTile( - leading: UserAvatar(), - title: Text(result[index].toJson().toString()), - ); - }, - ), - ), - ], - ); - }, - ); - } + const ChannelPageArgs({ + this.channel, + this.initialMessage, + }); } class ChannelPage extends StatelessWidget { + final int initialScrollIndex; + final double initialAlignment; + final bool highlightInitialMessage; + const ChannelPage({ Key key, + this.initialScrollIndex, + this.initialAlignment, + this.highlightInitialMessage = false, }) : super(key: key); @override @@ -473,6 +460,9 @@ class ChannelPage extends StatelessWidget { child: Stack( children: [ MessageListView( + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + highlightInitialMessage: highlightInitialMessage, threadBuilder: (_, parentMessage) { return ThreadPage( parent: parentMessage, @@ -507,10 +497,14 @@ class ChannelPage extends StatelessWidget { class ThreadPage extends StatelessWidget { final Message parent; + final int initialScrollIndex; + final double initialAlignment; ThreadPage({ Key key, this.parent, + this.initialScrollIndex, + this.initialAlignment, }) : super(key: key); @override @@ -524,6 +518,8 @@ class ThreadPage extends StatelessWidget { Expanded( child: MessageListView( parentMessage: parent, + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, ), ), if (parent.type != 'deleted') diff --git a/example/lib/new_chat_screen.dart b/example/lib/new_chat_screen.dart index 5fbfbf07..f29b1483 100644 --- a/example/lib/new_chat_screen.dart +++ b/example/lib/new_chat_screen.dart @@ -37,6 +37,8 @@ class _NewChatScreenState extends State { bool _showUserList = true; + bool _channelExisted = false; + void _userNameListener() { if (_debounce?.isActive ?? false) _debounce.cancel(); _debounce = Timer(const Duration(milliseconds: 350), () { @@ -56,11 +58,6 @@ class _NewChatScreenState extends State { _searchFocusNode.addListener(() async { if (_searchFocusNode.hasFocus && !_showUserList) { - if (channel.extraData['draft'] == true) { - await channel.stopWatching(); - channel.dispose(); - channel.client.state.channels.remove(channel.cid); - } setState(() { _showUserList = true; }); @@ -71,19 +68,38 @@ class _NewChatScreenState extends State { if (_messageInputFocusNode.hasFocus && _selectedUsers.isNotEmpty) { final chatState = StreamChat.of(context); - channel = chatState.client.channel( - 'messaging', - extraData: { + final res = await chatState.client.queryChannels( + options: { + 'state': false, + 'watch': false, + }, + filter: { 'members': [ ..._selectedUsers.map((e) => e.id), chatState.user.id, ], - 'draft': true, + 'distinct': true, }, + messageLimit: 0, + paginationParams: PaginationParams( + limit: 1, + ), ); - if (!chatState.client.state.channels.containsKey(channel.cid)) { + final _channelExisted = res.length == 1; + if (_channelExisted) { + channel = res.first; await channel.watch(); + } else { + channel = chatState.client.channel( + 'messaging', + extraData: { + 'members': [ + ..._selectedUsers.map((e) => e.id), + chatState.user.id, + ], + }, + ); } setState(() { @@ -134,6 +150,7 @@ class _NewChatScreenState extends State { return GestureDetector( onTap: () { _chipInputTextFieldState.removeItem(user); + _searchFocusNode.requestFocus(); }, child: Stack( alignment: AlignmentDirectional.centerStart, @@ -311,18 +328,38 @@ class _NewChatScreenState extends State { ), ), ) - : MessageListView(), + : FutureBuilder( + future: channel.initialized, + builder: (context, snapshot) { + if (snapshot.data == true) { + return MessageListView(); + } + + return Center( + child: Text( + 'No chats here yet...', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(.5), + ), + ), + ); + }, + ), ), MessageInput( focusNode: _messageInputFocusNode, + preMessageSending: (message) async { + await channel.watch(); + return message; + }, onMessageSent: (m) { - if (!m.isEphemeral) { - _updateChannelAndNavigate(context); - } else { - channel.on('message.new').first.then((_) { - _updateChannelAndNavigate(context); - }); - } + Navigator.pushNamedAndRemoveUntil( + context, + Routes.CHANNEL_PAGE, + ModalRoute.withName(Routes.HOME), + arguments: ChannelPageArgs(channel: channel), + ); }, ), ], @@ -330,16 +367,4 @@ class _NewChatScreenState extends State { ), ); } - - void _updateChannelAndNavigate(BuildContext context) { - channel.update({ - 'draft': false, - }); - Navigator.pushNamedAndRemoveUntil( - context, - Routes.CHANNEL_PAGE, - ModalRoute.withName(Routes.HOME), - arguments: channel, - ); - } } diff --git a/example/lib/routes/app_routes.dart b/example/lib/routes/app_routes.dart index f3261f72..20725b26 100644 --- a/example/lib/routes/app_routes.dart +++ b/example/lib/routes/app_routes.dart @@ -36,9 +36,13 @@ class AppRoutes { return MaterialPageRoute( settings: const RouteSettings(name: Routes.CHANNEL_PAGE), builder: (_) { + final arg = args as ChannelPageArgs; return StreamChannel( - channel: args as Channel, - child: ChannelPage(), + channel: arg.channel, + initialMessageId: arg.initialMessage?.id, + child: ChannelPage( + highlightInitialMessage: arg.initialMessage != null, + ), ); }); case Routes.NEW_CHAT: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c5bfa14f..175cdaeb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.88+90 +version: 1.0.94+97 environment: sdk: ">=2.2.2 <3.0.0" diff --git a/lib/src/channels_bloc.dart b/lib/src/channels_bloc.dart index 474d9599..9b6e55bb 100644 --- a/lib/src/channels_bloc.dart +++ b/lib/src/channels_bloc.dart @@ -85,7 +85,7 @@ class ChannelsBlocState extends State paginationParams.offset == null || paginationParams.offset == 0; final oldChannels = List.from(channels ?? []); - client + await client .queryChannels( filter: filter, sort: sortOptions, @@ -93,21 +93,18 @@ class ChannelsBlocState extends State paginationParams: paginationParams, onlyOffline: onlyOffline, ) - .listen((channels) { + .then((channels) { if (clear) { _channelsController.add(channels); } else { final l = oldChannels + channels; _channelsController.add(l); } - }, onDone: () { _queryChannelsLoadingController.sink.add(false); - }, onError: (err, stackTrace) { - print(err); - print(stackTrace); - _queryChannelsLoadingController.addError(err, stackTrace); }); } catch (err, stackTrace) { + print(err); + print(stackTrace); _queryChannelsLoadingController.addError(err, stackTrace); } } diff --git a/lib/src/lazy_load_scroll_view.dart b/lib/src/lazy_load_scroll_view.dart index e83fc777..2bc82a55 100644 --- a/lib/src/lazy_load_scroll_view.dart +++ b/lib/src/lazy_load_scroll_view.dart @@ -1,33 +1,47 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -enum LoadingStatus { LOADING, STABLE } +enum _LoadingStatus { LOADING, STABLE } -/// Signature for EndOfPageListeners -typedef EndOfPageListenerCallback = void Function(); - -/// A widget that wraps a [Widget] and will trigger [onEndOfPage] when it -/// reaches the bottom of the list +/// A widget that wraps a [Widget] and will trigger [onEndOfPage]/[onStartOfPage] when it +/// reaches the bottom/start of the list class LazyLoadScrollView extends StatefulWidget { /// The [Widget] that this widget watches for changes on final Widget child; - /// Called when the [child] reaches the end of the list - final EndOfPageListenerCallback onEndOfPage; + /// Called when the [child] reaches the start of the list + final AsyncCallback onStartOfPage; - /// The offset to take into account when triggering [onEndOfPage] in pixels - final int scrollOffset; + /// Called when the [child] reaches the end of the list + final AsyncCallback onEndOfPage; + + /// Called when the list scrolling starts + final VoidCallback onPageScrollStart; + + /// Called when the list scrolling ends + final VoidCallback onPageScrollEnd; + + /// Called every time the [child] is in-between the list + final VoidCallback onInBetweenOfPage; + + /// The offset to take into account when triggering [onEndOfPage]/[onStartOfPage] in pixels + final double scrollOffset; /// Used to determine if loading of new data has finished. You should use set this if you aren't using a FutureBuilder or StreamBuilder final bool isLoading; + /// Initiates a LazyLoadScrollView widget LazyLoadScrollView({ Key key, @required this.child, - @required this.onEndOfPage, + this.onStartOfPage, + this.onEndOfPage, + this.onPageScrollStart, + this.onPageScrollEnd, + this.onInBetweenOfPage, this.isLoading = false, this.scrollOffset = 100, - }) : assert(onEndOfPage != null), - assert(child != null), + }) : assert(child != null), super(key: key); @override @@ -35,15 +49,8 @@ class LazyLoadScrollView extends StatefulWidget { } class _LazyLoadScrollViewState extends State { - LoadingStatus _loadMoreStatus = LoadingStatus.STABLE; - - @override - void didUpdateWidget(LazyLoadScrollView oldWidget) { - super.didUpdateWidget(oldWidget); - if (!widget.isLoading) { - _loadMoreStatus = LoadingStatus.STABLE; - } - } + _LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE; + double _scrollPosition = 0.0; @override Widget build(BuildContext context) { @@ -54,28 +61,88 @@ class _LazyLoadScrollViewState extends State { } bool _onNotification(Notification notification) { + if (notification is ScrollStartNotification) { + if (widget.onPageScrollStart != null) { + widget.onPageScrollStart(); + return true; + } + } + if (notification is ScrollEndNotification) { + if (widget.onPageScrollEnd != null) { + widget.onPageScrollEnd(); + return true; + } + } if (notification is ScrollUpdateNotification) { - if (notification.metrics.maxScrollExtent > notification.metrics.pixels && - notification.metrics.maxScrollExtent - notification.metrics.pixels <= - widget.scrollOffset) { - if (_loadMoreStatus != null && - _loadMoreStatus == LoadingStatus.STABLE) { - _loadMoreStatus = LoadingStatus.LOADING; - widget.onEndOfPage(); + final pixels = notification.metrics.pixels; + final maxScrollExtent = notification.metrics.maxScrollExtent; + final minScrollExtent = notification.metrics.minScrollExtent; + final scrollOffset = widget.scrollOffset; + + if (pixels > (minScrollExtent + scrollOffset) && + pixels < (maxScrollExtent - scrollOffset)) { + if (widget.onInBetweenOfPage != null) { + widget.onInBetweenOfPage(); + return true; } } + + final extentBefore = notification.metrics.extentBefore; + final extentAfter = notification.metrics.extentAfter; + final scrollingDown = _scrollPosition < pixels; + + if (scrollOffset == null || scrollOffset == 0) { + if (extentAfter == 0) { + _onEndOfPage(); + } + if (extentBefore == 0) { + _onStartOfPage(); + } + } else { + if (scrollingDown) { + if (extentAfter <= scrollOffset) { + _onEndOfPage(); + } + } else { + if (extentBefore <= scrollOffset) { + _onStartOfPage(); + } + } + } + _scrollPosition = pixels; return true; } if (notification is OverscrollNotification) { if (notification.overscroll > 0) { - if (_loadMoreStatus != null && - _loadMoreStatus == LoadingStatus.STABLE) { - _loadMoreStatus = LoadingStatus.LOADING; - widget.onEndOfPage(); - } + _onEndOfPage(); + } + if (notification.overscroll < 0) { + _onStartOfPage(); } return true; } return false; } + + void _onEndOfPage() { + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { + _loadMoreStatus = _LoadingStatus.LOADING; + if (widget.onEndOfPage != null) { + widget.onEndOfPage().whenComplete(() { + _loadMoreStatus = _LoadingStatus.STABLE; + }); + } + } + } + + void _onStartOfPage() { + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { + _loadMoreStatus = _LoadingStatus.LOADING; + if (widget.onStartOfPage != null) { + widget.onStartOfPage().whenComplete(() { + _loadMoreStatus = _LoadingStatus.STABLE; + }); + } + } + } } diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 542f63e7..27c6b98f 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -21,7 +21,6 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:substring_highlight/substring_highlight.dart'; import 'package:video_compress/video_compress.dart'; -import 'package:photo_manager/photo_manager.dart'; import '../stream_chat_flutter.dart'; import 'stream_channel.dart'; @@ -343,7 +342,10 @@ class MessageInputState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (!widget.disableAttachments) _buildAttachmentButton(), - if (widget.editMessage == null) _buildCommandButton(), + if (widget.editMessage == null && + StreamChannel.of(context).channel?.config?.commands?.isNotEmpty == + true) + _buildCommandButton(), ], ), duration: Duration(milliseconds: 300), @@ -459,6 +461,7 @@ class MessageInputState extends State { } Timer _debounce; + void _onChanged(BuildContext context, String s) { if (_debounce?.isActive == true) _debounce.cancel(); _debounce = Timer( @@ -543,11 +546,12 @@ class MessageInputState extends State { void _checkCommands(String s, BuildContext context) { if (s.startsWith('/')) { var matchedCommandsList = StreamChannel.of(context) - .channel - .config - .commands - .where((element) => element.name == s.substring(1)) - .toList(); + .channel + .config + ?.commands + ?.where((element) => element.name == s.substring(1)) + ?.toList() ?? + []; if (matchedCommandsList.length == 1) { _chosenCommand = matchedCommandsList[0]; @@ -568,11 +572,12 @@ class MessageInputState extends State { OverlayEntry _buildCommandsOverlayEntry() { final text = textEditingController.text.trimLeft(); final commands = StreamChannel.of(context) - .channel - .config - .commands - .where((c) => c.name.contains(text.replaceFirst('/', ''))) - .toList(); + .channel + .config + ?.commands + ?.where((c) => c.name.contains(text.replaceFirst('/', ''))) + ?.toList() ?? + []; RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; @@ -1282,10 +1287,11 @@ class MessageInputState extends State { children: [ if (_attachments.any((e) => e.attachment?.type == 'file')) LimitedBox( - maxHeight: 73.0, + maxHeight: 136.0, child: ListView( - scrollDirection: Axis.horizontal, - children: _attachments + reverse: true, + shrinkWrap: true, + children: _attachments.reversed .where((e) => e.attachment?.type == 'file') .map( (e) => Padding( @@ -1299,8 +1305,8 @@ class MessageInputState extends State { attachmentType: FileAttachmentType.local, file: e.file, size: Size( - MediaQuery.of(context).size.width * 0.55, - MediaQuery.of(context).size.height * 0.3, + MediaQuery.of(context).size.width * 0.65, + 56.0, ), trailing: Padding( padding: const EdgeInsets.all(8.0), @@ -1927,8 +1933,6 @@ class MessageInputState extends State { _mentionsOverlay?.remove(); _mentionsOverlay = null; - final channel = StreamChannel.of(context).channel; - Future sendingFuture; Message message; if (widget.editMessage != null) { @@ -1953,6 +1957,12 @@ class MessageInputState extends State { message = await widget.preMessageSending(message); } + final streamChannel = StreamChannel.of(context); + final channel = streamChannel.channel; + if (!channel.state.isUpToDate) { + await streamChannel.reloadChannel(); + } + if (widget.editMessage == null || widget.editMessage.status == MessageSendingStatus.FAILED) { sendingFuture = channel.sendMessage(message); @@ -2038,6 +2048,7 @@ class MessageInputState extends State { } bool _initialized = false; + @override void didChangeDependencies() { if (widget.editMessage != null && !_initialized) { diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 430e0b85..5a850ad3 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -1,10 +1,13 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/lazy_load_scroll_view.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; @@ -108,10 +111,11 @@ class MessageListView extends StatefulWidget { this.onThreadTap, this.dateDividerBuilder, this.scrollPhysics = const AlwaysScrollableScrollPhysics(), - this.initialScrollIndex = 0, - this.initialAlignment = 0, + this.initialScrollIndex, + this.initialAlignment, this.scrollController, this.itemPositionListener, + this.highlightInitialMessage = false, }) : super(key: key); /// Function used to build a custom message widget @@ -153,6 +157,11 @@ class MessageListView extends StatefulWidget { /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; + /// If true the list will highlight the initialMessage if there is any. + /// + /// Also See [StreamChannel] + final bool highlightInitialMessage; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -160,10 +169,50 @@ class MessageListView extends StatefulWidget { class _MessageListViewState extends State { ItemScrollController _scrollController; bool _bottomWasVisible = false; - bool _topWasVisible = false; Function _onThreadTap; bool _showScrollToBottom = false; ItemPositionsListener _itemPositionListener; + int _messageListLength; + + int get _initialIndex { + if (widget.initialScrollIndex != null) return widget.initialScrollIndex; + final streamChannel = StreamChannel.of(context); + if (streamChannel.initialMessageId != null) { + final messages = streamChannel.channel.state.messages; + final totalMessages = messages.length; + final messageIndex = messages.indexWhere((e) { + return e.id == streamChannel.initialMessageId; + }); + final index = totalMessages - messageIndex; + if (index != 0) return index - 1; + return index; + } + return 0; + } + + double get _initialAlignment { + if (widget.initialAlignment != null) return widget.initialAlignment; + return 0; + } + + bool _isInitialMessage(String id) { + final streamChannel = StreamChannel.of(context); + return streamChannel.initialMessageId == id; + } + + bool get _upToDate => StreamChannel.of(context).channel.state.isUpToDate; + + bool _topPaginationActive = false; + bool _bottomPaginationActive = false; + + int initialIndex; + double initialAlignment; + + List messages = []; + + bool initialMessageHighlightComplete = false; + + bool _inBetweenList = false; @override Widget build(BuildContext context) { @@ -173,179 +222,257 @@ class _MessageListViewState extends State { ? streamChannel.channel.state.threadsStream .where((threads) => threads.containsKey(widget.parentMessage.id)) .map((threads) => threads[widget.parentMessage.id]) - : streamChannel.channel.state.messagesStream; + : streamChannel.channel.state?.messagesStream; - return StreamBuilder>( - stream: messagesStream.map((messages) => messages - .where((e) => - !e.isDeleted || - (e.isDeleted && - e.user.id == streamChannel.channel.client.state.user.id)) - .toList()), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Center( - child: CircularProgressIndicator(), - ); - } + return WillPopScope( + onWillPop: () async { + if (!_upToDate) { + await streamChannel.reloadChannel(); + } + return true; + }, + child: StreamBuilder>( + stream: messagesStream?.map((messages) => messages + ?.where((e) => + !e.isDeleted || + (e.isDeleted && + e.user.id == streamChannel.channel.client.state.user.id)) + ?.toList()), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } - final messages = snapshot.data?.reversed?.toList() ?? []; + final messageList = snapshot.data?.reversed?.toList() ?? []; - if (messages.isEmpty) { - return Center( - child: Text( - 'No chats here yet...', - style: TextStyle( - fontSize: 12, - color: Colors.black.withOpacity(.5), - ), - ), - ); - } + if (messageList.isEmpty) { + if (_upToDate) { + return Center( + child: Text( + 'No chats here yet...', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(.5), + ), + ), + ); + } + } else { + messages = messageList; + } - return Stack( - alignment: Alignment.center, - children: [ - ScrollablePositionedList.builder( - itemPositionsListener: _itemPositionListener, - addAutomaticKeepAlives: true, - key: Key('messageListView'), - initialScrollIndex: widget.initialScrollIndex, - initialAlignment: widget.initialAlignment, - physics: widget.scrollPhysics, - itemScrollController: _scrollController, - reverse: true, - itemCount: messages.length + - 1 + - (widget.parentMessage != null ? 1 : 0), - itemBuilder: (context, i) { - if (i == messages.length + 1) { - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder( - context, - widget.parentMessage, - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildParentMessage(widget.parentMessage), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Container( - padding: const EdgeInsets.all(8), - child: Text( - 'Start of thread', - textAlign: TextAlign.center, - ), - color: - Theme.of(context).accentColor.withAlpha(50), - ), - ), - ], + final newMessagesListLength = messages.length; + + if (_messageListLength != null) { + if (_bottomPaginationActive || (_inBetweenList && _upToDate)) { + if (_itemPositionListener.itemPositions.value?.isNotEmpty == + true) { + final first = _itemPositionListener.itemPositions.value.first; + final diff = newMessagesListLength - _messageListLength; + if (diff > 0) { + initialIndex = first.index + diff; + initialAlignment = first.itemLeadingEdge; + } + } + } else if (!_topPaginationActive && _upToDate) { + // Reset the index in-case we send any new message + initialIndex = 0; + initialAlignment = 0; + } + } + + _messageListLength = newMessagesListLength; + + return Stack( + alignment: Alignment.center, + children: [ + LazyLoadScrollView( + onStartOfPage: () async { + _inBetweenList = false; + if (!_upToDate) { + _topPaginationActive = false; + _bottomPaginationActive = true; + return _paginateData( + streamChannel, + QueryDirection.bottom, ); } - } - - if (i == messages.length) { - return _buildLoadingIndicator(streamChannel); - } - final message = messages[i]; - final nextMessage = i > 0 ? messages[i - 1] : null; - - Widget messageWidget; - - if (i == 0) { - messageWidget = _buildBottomMessage( - context, - message, - messages, - streamChannel, - ); - } else if (i == messages.length - 1) { - messageWidget = _buildTopMessage( - context, - message, - messages, - streamChannel, - ); - } else { - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder( - context, - MessageDetails( - context, - message, - messages, - i, - ), - messages), - ); - } else { - messageWidget = buildMessage(message, messages, i); - } - } - - if (nextMessage != null && - !Jiffy(message.createdAt.toLocal()) - .isSame(nextMessage.createdAt.toLocal(), Units.DAY)) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - messageWidget, - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: widget.dateDividerBuilder != null - ? widget.dateDividerBuilder( - nextMessage.createdAt.toLocal()) - : DateDivider( - dateTime: nextMessage.createdAt.toLocal(), - ), - ), - ], - ); - } - - return messageWidget; - }, - ), - if (widget.showScrollToBottom && _showScrollToBottom) - _buildScrollToBottom(), - Positioned( - top: 20.0, - child: ValueListenableBuilder>( - valueListenable: _itemPositionListener.itemPositions, - builder: (context, values, _) { - final items = _itemPositionListener.itemPositions?.value; - if (items.isEmpty || messages.isEmpty) { - return SizedBox(); - } - - var index = _getTopElement(values).index; - - if (index > messages.length) { - return 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(), - ); }, + onEndOfPage: () async { + _inBetweenList = false; + _topPaginationActive = true; + _bottomPaginationActive = false; + return _paginateData( + streamChannel, + QueryDirection.top, + ); + }, + onInBetweenOfPage: () { + _inBetweenList = true; + }, + child: ScrollablePositionedList.builder( + key: ValueKey(initialIndex + initialAlignment), + itemPositionsListener: _itemPositionListener, + addAutomaticKeepAlives: true, + initialScrollIndex: initialIndex ?? 0, + initialAlignment: initialAlignment ?? 0, + physics: widget.scrollPhysics, + itemScrollController: _scrollController, + reverse: true, + itemCount: messages.length + + 2 + + (widget.parentMessage != null ? 1 : 0), + itemBuilder: (context, i) { + if (i == messages.length + 2) { + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder( + context, + widget.parentMessage, + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildParentMessage(widget.parentMessage), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 32), + child: Container( + padding: const EdgeInsets.all(8), + child: Text( + 'Start of thread', + textAlign: TextAlign.center, + ), + color: Theme.of(context) + .accentColor + .withAlpha(50), + ), + ), + ], + ); + } + } + if (i == messages.length + 1) { + return _buildLoadingIndicator( + streamChannel, + QueryDirection.top, + ); + } + if (i == 0) { + return _buildLoadingIndicator( + streamChannel, + QueryDirection.bottom, + ); + } + final message = messages[i - 1]; + final nextMessage = (i - 1) > 0 ? messages[i - 2] : null; + + Widget messageWidget; + + if (i == 1) { + messageWidget = _buildBottomMessage( + context, + message, + messages, + streamChannel, + ); + } else if (i == messages.length - 1) { + messageWidget = _buildTopMessage( + context, + message, + messages, + streamChannel, + ); + } else { + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('MESSAGE-${message.id}'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + messages, + i, + ), + messages), + ); + } else { + messageWidget = buildMessage(message, messages, i); + } + } + + if (nextMessage != null && + !Jiffy(message.createdAt.toLocal()).isSame( + nextMessage.createdAt.toLocal(), Units.DAY)) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + messageWidget, + Padding( + padding: + const EdgeInsets.symmetric(vertical: 12.0), + child: widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + nextMessage.createdAt.toLocal()) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ), + ), + ], + ); + } + + return messageWidget; + }, + ), ), - ), - ], - ); - }); + if (widget.showScrollToBottom) _buildScrollToBottom(), + Positioned( + top: 20.0, + child: ValueListenableBuilder>( + valueListenable: _itemPositionListener.itemPositions, + builder: (context, values, _) { + final items = _itemPositionListener.itemPositions?.value; + if (items.isEmpty || messages.isEmpty) { + return SizedBox(); + } + + var index = _getTopElement(values).index; + + if (index > messages.length) { + return 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) { + if (widget.parentMessage == null) { + return channel.queryMessages(direction: direction); + } else { + return channel.getReplies(widget.parentMessage.id); + } } ItemPosition _getTopElement(Iterable values) { @@ -357,91 +484,120 @@ class _MessageListViewState extends State { Widget _buildScrollToBottom() { final streamChannel = StreamChannel.of(context); - return Positioned( - bottom: 8, - right: 8, - width: 40, - height: 40, - child: Stack( - clipBehavior: Clip.none, - children: [ - FloatingActionButton( - backgroundColor: Colors.white, - child: StreamSvgIcon.down( - color: Colors.black, - ), - onPressed: () { - setState(() { - _showScrollToBottom = false; - }); - _scrollController.scrollTo( - index: 0, - duration: Duration(seconds: 1), - curve: Curves.easeInOut, - ); - }, - ), - if (streamChannel.channel.state.members.any((Member e) => - e.userId == streamChannel.channel.client.state.user.id)) - StreamBuilder( - stream: streamChannel.channel.state.unreadCountStream, - initialData: streamChannel.channel.state.unreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data <= 0) { - return Offstage(); + return StreamBuilder>( + stream: Rx.combineLatest2( + streamChannel.channel.state.isUpToDateStream, + streamChannel.channel.state.unreadCountStream, + (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), + ), + builder: (_, snapshot) { + if (snapshot.hasError) { + return Offstage(); + } else if (!snapshot.hasData) { + return Offstage(); + } + final isUpToDate = snapshot.data.item1; + final showScrollToBottom = !isUpToDate || _showScrollToBottom; + if (!showScrollToBottom) { + return Offstage(); + } + final unreadCount = snapshot.data.item2; + final showUnreadCount = unreadCount > 0 && + streamChannel.channel.state.members.any( + (e) => e.userId == streamChannel.channel.client.state.user.id); + return Positioned( + bottom: 8, + right: 8, + width: 40, + height: 40, + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton( + backgroundColor: Colors.white, + child: StreamSvgIcon.down( + color: Colors.black, + ), + onPressed: () { + if (unreadCount > 0) { + streamChannel.channel.markRead(); } - return Positioned( - width: 20, - height: 20, - left: 10, - top: -10, - child: CircleAvatar( - child: Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - snapshot.data.toString(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - ), + if (!_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + streamChannel.reloadChannel(); + } else { + setState(() => _showScrollToBottom = false); + _scrollController.scrollTo( + index: 0, + duration: Duration(seconds: 1), + curve: Curves.easeInOut, + ); + } + }, + ), + if (showUnreadCount) + Positioned( + width: 20, + height: 20, + left: 10, + top: -10, + child: CircleAvatar( + child: Padding( + padding: const EdgeInsets.all(3.0), + child: Text( + '$unreadCount', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, ), ), ), - ); - }), - ], - ), + ), + ), + ], + ), + ); + }, ); } - Container _buildLoadingIndicator(StreamChannelState streamChannel) { - return Container( - key: Key('LOADING-INDICATOR'), - height: 50, - width: double.infinity, - child: StreamBuilder( - stream: streamChannel.queryMessage, - initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( - color: Color(0xffd0021B).withAlpha(26), - child: Center( - child: Text('Error loading messages'), - ), - ); - } - if (!snapshot.data) { - return SizedBox(); - } - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: CircularProgressIndicator(), + Widget _buildLoadingIndicator( + StreamChannelState streamChannel, + QueryDirection direction, + ) { + final stream = direction == QueryDirection.top + ? streamChannel.queryTopMessages + : streamChannel.queryBottomMessages; + return StreamBuilder( + key: Key('LOADING-INDICATOR'), + stream: stream, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Center( + child: Text('Error loading messages'), ), ); - }), - ); + } + if (!snapshot.data) { + if (direction == QueryDirection.top) { + return Container( + height: 50, + width: double.infinity, + ); + } + return Offstage(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: const CircularProgressIndicator(), + ), + ); + }); } Widget _buildTopMessage( @@ -468,22 +624,7 @@ class _MessageListViewState extends State { } else { messageWidget = buildMessage(message, messages, messages.length - 1); } - - return VisibilityDetector( - key: ValueKey('TOP-MESSAGE'), - child: messageWidget, - onVisibilityChanged: (visibility) { - final topIsVisible = visibility.visibleBounds != Rect.zero; - if (topIsVisible && !_topWasVisible) { - if (widget.parentMessage == null) { - streamChannel.queryMessages(); - } else { - streamChannel.getReplies(widget.parentMessage.id); - } - } - _topWasVisible = topIsVisible; - }, - ); + return messageWidget; } Widget _buildBottomMessage( @@ -515,18 +656,17 @@ class _MessageListViewState extends State { key: ValueKey('BOTTOM-MESSAGE'), onVisibilityChanged: (visibility) { final isVisible = visibility.visibleBounds != Rect.zero; - if (isVisible && - !_bottomWasVisible && - streamChannel.channel.config?.readEvents == true) { - if (streamChannel.channel.state.unreadCount > 0) { + if (isVisible && !_bottomWasVisible) { + final channel = streamChannel.channel; + if (_upToDate && + channel.config?.readEvents == true && + channel.state.unreadCount > 0) { streamChannel.channel.markRead(); } + _bottomWasVisible = !isVisible; } - _bottomWasVisible = isVisible; if (mounted) { - setState(() { - _showScrollToBottom = !isVisible; - }); + setState(() => _showScrollToBottom = !isVisible); } }, child: messageWidget, @@ -582,7 +722,7 @@ class _MessageListViewState extends State { final userId = StreamChat.of(context).user.id; final isMyMessage = message.user.id == userId; final isNextUser = - index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; + index - 2 >= 0 && message.user.id == messages[index - 2]?.user?.id; final channel = StreamChannel.of(context).channel; final readList = channel.state?.read @@ -597,7 +737,7 @@ class _MessageListViewState extends State { final allRead = readList.length >= (channel.memberCount ?? 0) - 1; - return MessageWidget( + Widget child = MessageWidget( key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, @@ -605,7 +745,8 @@ class _MessageListViewState extends State { padding: EdgeInsets.only( left: 8.0, right: 8.0, - bottom: index == 0 ? 30 : (isNextUser ? 5 : 10), + bottom: index == 0 ? 30 : (isNextUser ? 2 : 7), + top: 3, ), showUsername: !isMyMessage && !isNextUser, showSendingIndicator: isMyMessage && @@ -639,6 +780,30 @@ class _MessageListViewState extends State { readList: readList, allRead: allRead, ); + + if (!initialMessageHighlightComplete && + widget.highlightInitialMessage && + _isInitialMessage(message.id)) { + final accentColor = Theme.of(context).accentColor; + child = TweenAnimationBuilder( + tween: ColorTween( + begin: accentColor.withOpacity(0.7), + end: Colors.transparent, + ), + duration: const Duration(seconds: 2), + child: child, + onEnd: () { + initialMessageHighlightComplete = true; + }, + builder: (_, color, child) { + return Container( + color: color, + child: child, + ); + }, + ); + } + return child; } StreamSubscription _messageNewListener; @@ -651,26 +816,21 @@ class _MessageListViewState extends State { final streamChannel = StreamChannel.of(context); + initialIndex = _initialIndex; + initialAlignment = _initialAlignment; + _messageNewListener = streamChannel.channel.on(EventType.messageNew).listen((event) { - final firstElementInViewport = - _itemPositionListener.itemPositions.value.first; + if (_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + } if (event.message.user.id == streamChannel.channel.client.state.user.id) { WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.jumpTo( index: 0, ); }); - } else { - if (firstElementInViewport.index != 0) { - _scrollController.jumpTo( - index: firstElementInViewport.index + 1, - alignment: firstElementInViewport.itemLeadingEdge, - ); - } - } - if (firstElementInViewport.index == 0) { - streamChannel.channel.markRead(); } }); diff --git a/lib/src/message_search_item.dart b/lib/src/message_search_item.dart index 3e786b9a..35b31aa8 100644 --- a/lib/src/message_search_item.dart +++ b/lib/src/message_search_item.dart @@ -77,16 +77,16 @@ class MessageSearchItem extends StatelessWidget { } Widget _buildDate(BuildContext context, Message message) { - final lastUpdatedAt = message.updatedAt; + final createdAt = message.createdAt; String stringDate; final now = DateTime.now(); - if (now.year != lastUpdatedAt.year || - now.month != lastUpdatedAt.month || - now.day != lastUpdatedAt.day) { - stringDate = Jiffy(lastUpdatedAt.toLocal()).format('dd/MM/yyyy'); + if (now.year != createdAt.year || + now.month != createdAt.month || + now.day != createdAt.day) { + stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy'); } else { - stringDate = Jiffy(lastUpdatedAt.toLocal()).format('HH:mm'); + stringDate = Jiffy(createdAt.toLocal()).format('HH:mm'); } return Text( diff --git a/lib/src/message_search_list_view.dart b/lib/src/message_search_list_view.dart index 8b346570..087ae839 100644 --- a/lib/src/message_search_list_view.dart +++ b/lib/src/message_search_list_view.dart @@ -14,6 +14,10 @@ typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); typedef MessageSearchItemBuilder = Widget Function( BuildContext, GetMessageResponse); +/// Builder used when [MessageSearchListView] is empty +typedef EmptyMessageSearchBuilder = Widget Function( + BuildContext context, String searchQuery); + /// /// It shows the list of searched messages. /// @@ -85,7 +89,7 @@ class MessageSearchListView extends StatefulWidget { final MessageSearchItemTapCallback onItemTap; /// The builder used when the channel list is empty. - final WidgetBuilder emptyBuilder; + final EmptyMessageSearchBuilder emptyBuilder; /// The builder that will be used in case of error final Widget Function(Error error) errorBuilder; @@ -250,11 +254,10 @@ class _MessageSearchListViewState extends State { final items = snapshot.data; - if (items.isEmpty && widget.emptyBuilder != null) { - return widget.emptyBuilder(context); - } - - if (items.isEmpty && widget.emptyBuilder == null) { + if (items.isEmpty) { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context, widget.messageQuery); + } return LayoutBuilder( builder: (context, viewportConstraints) { return SingleChildScrollView( diff --git a/lib/src/stream_channel.dart b/lib/src/stream_channel.dart index 6fafa070..001d98ff 100644 --- a/lib/src/stream_channel.dart +++ b/lib/src/stream_channel.dart @@ -4,23 +4,29 @@ 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 { - StreamChannel({ + const StreamChannel({ Key key, @required this.child, @required this.channel, this.showLoading = true, - }) : super( - key: key, - ); + 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; @@ -43,87 +49,123 @@ 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 BehaviorSubject _queryMessageController = BehaviorSubject(); + final _queryTopMessagesController = BehaviorSubject.seeded(false); + final _queryBottomMessagesController = BehaviorSubject.seeded(false); - /// The stream notifying the state of queryMessage call - Stream get queryMessage => _queryMessageController.stream; + /// The stream notifying the state of [_queryTopMessages] call + Stream get queryTopMessages => _queryTopMessagesController.stream; - bool _paginationEnded = false; + /// The stream notifying the state of [_queryBottomMessages] call + Stream get queryBottomMessages => _queryBottomMessagesController.stream; - /// Calls [channel.query] updating [queryMessage] stream - void queryMessages() { - if (_queryMessageController.value == true || _paginationEnded) { + bool _topPaginationEnded = false; + bool _bottomPaginationEnded = false; + + Future _queryTopMessages({ + int limit = 20, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController?.value == true) { return; } + _queryTopMessagesController.add(true); - _queryMessageController.add(true); - - String firstId; - if (channel.state.messages.isNotEmpty) { - firstId = channel.state.messages.first.id; + if (channel.state.messages.isEmpty) { + return _queryTopMessagesController.add(false); } - final messageLimit = 50; + final oldestMessage = channel.state.messages.first; - widget.channel - .query( - messagesPagination: PaginationParams( - lessThan: firstId, - limit: messageLimit, - ), - preferOffline: true, - ) - .then((res) { - if (res.messages.isEmpty || res.messages.length < messageLimit) { - _paginationEnded = true; + try { + final state = await queryBeforeMessage( + oldestMessage.id, + limit: limit, + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + _topPaginationEnded = true; } - _queryMessageController.add(false); - }).catchError((e, stack) { - if (!_queryMessageController.isClosed) { - _queryMessageController.addError(e, stack); + _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) async { - if (_queryMessageController.value == true || _paginationEnded) { - return; + Future getReplies( + String parentId, { + int limit = 50, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController.value) return; + _queryTopMessagesController.add(true); + + if (!channel.state.threads.containsKey(parentId)) { + return _queryTopMessagesController.add(false); } - _queryMessageController.add(true); + final thread = channel.state.threads[parentId]; - String firstId; - if (widget.channel.state.threads.containsKey(parentId)) { - final thread = widget.channel.state.threads[parentId]; + if (thread.isEmpty) return _queryTopMessagesController.add(false); - if (thread != null && thread.isNotEmpty) { - firstId = thread?.first?.id; + final message = thread.first; + + try { + final state = await queryBeforeMessage( + message.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); } - - final messageLimit = 50; - return widget.channel - .getReplies( - parentId, - PaginationParams( - lessThan: firstId, - limit: messageLimit, - ), - preferOffline: true, - ) - .then((res) { - if (res.messages.isEmpty || res.messages.length < messageLimit) { - _paginationEnded = true; - } - _queryMessageController.add(false); - }).catchError((e, stack) { - _queryMessageController.addError(e, stack); - }); } /// Query the channel members and watchers @@ -140,41 +182,161 @@ class StreamChannelState extends State { ); } + /// 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; + } + + /// 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() { - _queryMessageController.close(); + _queryTopMessagesController.close(); + _queryBottomMessagesController.close(); super.dispose(); } @override Widget build(BuildContext context) { - if (widget.channel == null) { - return Center( - child: CircularProgressIndicator(), - ); - } - return FutureBuilder( - future: widget.channel.initialized, - initialData: widget.channel.state != null, + Widget child = FutureBuilder>( + future: Future.wait(_futures), + initialData: [ + channel.state != null, + if (initialMessageId != null) false, + ], builder: (context, snapshot) { - if (widget.showLoading && (!snapshot.hasData || !snapshot.data)) { - return Container( - height: 30, - child: Center( - child: CircularProgressIndicator(), - ), + 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), ); - } else if (snapshot.hasError) { - return Container( - height: 30, - child: Center( - child: Text(snapshot.error), - ), - ); - } else { - return widget.child; } + 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/lib/src/user_item.dart b/lib/src/user_item.dart index f12e3e61..4c1af658 100644 --- a/lib/src/user_item.dart +++ b/lib/src/user_item.dart @@ -90,6 +90,8 @@ class UserItem extends StatelessWidget { } Widget _buildLastActive(context) { - return Text('Last online ${Jiffy(user.lastActive).fromNow()}'); + return user.online == true + ? Text('Online') + : Text('Last online ${Jiffy(user.lastActive).fromNow()}'); } } diff --git a/lib/src/user_list_view.dart b/lib/src/user_list_view.dart index b57e2d55..59ba7f67 100644 --- a/lib/src/user_list_view.dart +++ b/lib/src/user_list_view.dart @@ -190,7 +190,7 @@ class _UserListViewState extends State } final groupedUsers = >{}; for (var e in temp) { - final alphabet = e.name[0]; + final alphabet = e.name[0]?.toUpperCase(); groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; } final items = []; @@ -339,7 +339,9 @@ class _UserListViewState extends State ); return LazyLoadScrollView( - onEndOfPage: () => _listenUserPagination(usersBlocState), + onEndOfPage: () async { + return _listenUserPagination(usersBlocState); + }, child: child, ); }, diff --git a/pubspec.yaml b/pubspec.yaml index d5112694..12374291 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: file_picker: ^2.0.12 image_picker: ^0.6.7+2 flutter_keyboard_visibility: ^3.3.0 - stream_chat: ^0.2.19 + stream_chat: ^0.2.20 mime: ^0.9.6+3 video_compress: ^2.1.1 visibility_detector: ^0.1.5