From ded26db306532289498d9048473e0e6e74385721 Mon Sep 17 00:00:00 2001 From: xsahil03x Date: Fri, 27 Nov 2020 12:54:57 +0530 Subject: [PATCH] feat : MessageSearchListView Signed-off-by: xsahil03x --- example/lib/main.dart | 167 ++++++++++++-- lib/src/message_search_bloc.dart | 109 +++++++++ lib/src/message_search_item.dart | 98 +++++++++ lib/src/message_search_list_view.dart | 305 ++++++++++++++++++++++++++ lib/src/stream_chat_theme.dart | 1 + lib/stream_chat_flutter.dart | 3 + 6 files changed, 663 insertions(+), 20 deletions(-) create mode 100644 lib/src/message_search_bloc.dart create mode 100644 lib/src/message_search_item.dart create mode 100644 lib/src/message_search_list_view.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 50ff18b0..bab4b1e0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -12,6 +12,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'notifications_service.dart'; import 'routes/app_routes.dart'; import 'routes/routes.dart'; +import 'search_text_field.dart'; +import 'dart:async'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -66,7 +68,45 @@ class MyApp extends StatelessWidget { } } -class ChannelListPage extends StatelessWidget { +class ChannelListPage extends StatefulWidget { + @override + _ChannelListPageState createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State { + TextEditingController _controller; + + String _channelQuery = ''; + + bool _isSearchActive = false; + + Timer _debounce; + + void _channelQueryListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) { + setState(() { + _channelQuery = _controller.text; + _isSearchActive = _channelQuery.isNotEmpty; + }); + } + }); + } + + @override + void initState() { + super.initState(); + _controller = TextEditingController()..addListener(_channelQueryListener); + } + + @override + void dispose() { + _controller?.removeListener(_channelQueryListener); + _controller?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final user = StreamChat.of(context).user; @@ -79,26 +119,50 @@ class ChannelListPage extends StatelessWidget { drawer: _buildDrawer(context, user), drawerEdgeDragWidth: 50, body: ChannelsBloc( - child: ChannelListView( - onStartChatPressed: () { - Navigator.pushNamed(context, Routes.NEW_CHAT); - }, - swipeToAction: true, - filter: { - 'members': { - '\$in': [user.id], - }, - 'draft': { - r'$ne': true, - }, - }, - options: { - 'presence': true, - }, - pagination: PaginationParams( - limit: 20, + child: MessageSearchBloc( + child: Column( + children: [ + SearchTextField( + controller: _controller, + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: _isSearchActive + ? MessageSearchListView( + messageQuery: _channelQuery, + filters: { + 'members': { + r'$in': [user.id] + } + }, + paginationParams: PaginationParams(limit: 20), + ) + : ChannelListView( + onStartChatPressed: () { + Navigator.pushNamed(context, Routes.NEW_CHAT); + }, + swipeToAction: true, + filter: { + 'members': { + r'$in': [user.id], + }, + 'draft': { + r'$ne': true, + }, + }, + options: { + 'presence': true, + }, + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), + ), + ), + ), + ], ), - channelWidget: ChannelPage(), ), ), ); @@ -207,6 +271,68 @@ class ChannelListPage extends StatelessWidget { } } +class ChannelQuerySearchResultPage extends StatelessWidget { + final Stream> searchResultStream; + + 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()), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + class ChannelPage extends StatelessWidget { const ChannelPage({ Key key, @@ -215,6 +341,7 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), appBar: ChannelHeader( showTypingIndicator: false, ), diff --git a/lib/src/message_search_bloc.dart b/lib/src/message_search_bloc.dart new file mode 100644 index 00000000..bff2d68e --- /dev/null +++ b/lib/src/message_search_bloc.dart @@ -0,0 +1,109 @@ +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 messages => _messagesController.value; + + /// The current messages list as a stream + Stream> get messagesStream => _messagesController.stream; + + final BehaviorSubject> _messagesController = BehaviorSubject(); + + final BehaviorSubject _queryMessagesLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryMessagesLoading => + _queryMessagesLoadingController.stream; + + /// Calls [Client.search] updating [queryMessagesLoading] stream + Future search({ + Map filter, + List sort, + String query, + PaginationParams pagination, + }) async { + final client = StreamChat.of(context).client; + + if (client.state?.user == null || + _queryMessagesLoadingController.value == true) { + return; + } + _queryMessagesLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldMessages = List.from(messages ?? []); + + final messageResponse = await client.search( + filter, + sort, + query, + pagination, + ); + + if (clear) { + _messagesController.add(messageResponse.results); + } else { + final temp = oldMessages + messageResponse.results; + _messagesController.add(temp); + } + + _queryMessagesLoadingController.add(false); + } catch (err, stackTrace) { + _queryMessagesLoadingController.addError(err, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _messagesController.close(); + _queryMessagesLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/message_search_item.dart b/lib/src/message_search_item.dart new file mode 100644 index 00000000..d97db708 --- /dev/null +++ b/lib/src/message_search_item.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class MessageSearchItem extends StatelessWidget { + final Message message; + + const MessageSearchItem({ + Key key, + @required this.message, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final data = Message.fromJson(message.extraData['message']); + final user = data.user; + debugPrint(message.toJson().toString()); + return ListTile( + leading: UserAvatar( + user: user, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: Text( + user.name, + style: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + subtitle: Row( + children: [ + Expanded(child: _buildSubtitle(context, data)), + _buildDate(context, data), + ], + ), + ); + } + + Widget _buildDate(BuildContext context, Message message) { + final lastUpdatedAt = message.updatedAt; + 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'); + } else { + stringDate = Jiffy(lastUpdatedAt.toLocal()).format('HH:mm'); + } + + return Text( + stringDate, + style: StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, + ); + } + + Widget _buildSubtitle(BuildContext context, Message message) { + if (message == null) { + return SizedBox(); + } + + var text = message.text; + if (message.isDeleted) { + text = 'This message was deleted.'; + } else if (message.attachments != null) { + final parts = [ + ...message.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return '🎬'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return null; + }).where((e) => e != null), + message.text ?? '', + ]; + + text = parts.join(' '); + } + + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: + StreamChatTheme.of(context).channelPreviewTheme.subtitle.color, + fontStyle: (message.isSystem || message.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + ), + ); + } +} diff --git a/lib/src/message_search_list_view.dart b/lib/src/message_search_list_view.dart new file mode 100644 index 00000000..f317a677 --- /dev/null +++ b/lib/src/message_search_list_view.dart @@ -0,0 +1,305 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/message_search_item.dart'; + +import 'lazy_load_scroll_view.dart'; +import 'message_search_bloc.dart'; + +class MessageSearchListView extends StatefulWidget { + final String messageQuery; + final Map filters; + final List sortOptions; + final PaginationParams paginationParams; + final bool pullToRefresh; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// Builder used to create a custom item separator + final IndexedWidgetBuilder separatorBuilder; + + const MessageSearchListView({ + Key key, + @required this.messageQuery, + @required this.filters, + this.sortOptions, + this.paginationParams, + this.emptyBuilder, + this.errorBuilder, + this.separatorBuilder, + this.pullToRefresh = true, + }) : super(key: key); + + @override + _MessageSearchListViewState createState() => _MessageSearchListViewState(); +} + +class _MessageSearchListViewState extends State { + @override + void initState() { + super.initState(); + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + ); + } + + @override + Widget build(BuildContext context) { + final messageSearchBloc = MessageSearchBloc.of(context); + + if (!widget.pullToRefresh) { + return _buildListView(messageSearchBloc); + } + + return RefreshIndicator( + onRefresh: () async { + return messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + ); + }, + child: _buildListView(messageSearchBloc), + ); + } + + Widget _separatorBuilder(BuildContext context, int index) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ); + } + + Widget _listItemBuilder(BuildContext context, Message message) { + return MessageSearchItem(message: message); + } + + Widget _buildQueryProgressIndicator( + context, MessageSearchBlocState messageSearchBloc) { + return StreamBuilder( + stream: messageSearchBloc.queryMessagesLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading messages'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _buildListView(MessageSearchBlocState messageSearchBloc) { + return StreamBuilder>( + stream: messageSearchBloc.messagesStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + if (widget.errorBuilder != null) { + return widget.errorBuilder(snapshot.error); + } + + 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: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only( + right: 2.0, + ), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading messages'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + Padding( + padding: const EdgeInsets.only( + top: 16.0, + ), + child: Text(message), + ), + FlatButton( + onPressed: () { + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + ); + }, + child: Text('Retry'), + ), + ], + ), + ); + } + + if (!snapshot.hasData) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ); + } + + final items = snapshot.data; + + if (items.isEmpty && widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + + if (items.isEmpty && widget.emptyBuilder == null) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Text('There are no messages currently'), + ), + ), + ); + }, + ); + } + + Widget child; + child = LazyLoadScrollView( + onEndOfPage: () => messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + pagination: widget.paginationParams.copyWith( + offset: messageSearchBloc.messages?.length ?? 0, + ), + query: widget.messageQuery, + ), + child: ListView.separated( + physics: AlwaysScrollableScrollPhysics(), + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) { + if (index < items.length) { + return _listItemBuilder(context, items[index]); + } + return _buildQueryProgressIndicator(context, messageSearchBloc); + }, + ), + ); + + if (true) { + child = Column( + children: [ + 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( + '${items.length} results', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } + return child; + }, + ); + } + + @override + void didUpdateWidget(MessageSearchListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.filters?.toString() != oldWidget.filters?.toString() || + jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || + widget.paginationParams?.toJson()?.toString() != + oldWidget.paginationParams?.toJson()?.toString() || + widget.messageQuery?.toString() != oldWidget.messageQuery?.toString()) { + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + ); + } + } +} diff --git a/lib/src/stream_chat_theme.dart b/lib/src/stream_chat_theme.dart index d5b2c1c2..9c24525c 100644 --- a/lib/src/stream_chat_theme.dart +++ b/lib/src/stream_chat_theme.dart @@ -245,6 +245,7 @@ class StreamChatThemeData { title: TextStyle( fontSize: 14, color: isDark ? Colors.white : Colors.black, + fontWeight: FontWeight.bold ), subtitle: TextStyle( fontSize: 12.5, diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index f8ad25ea..35239cbd 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -38,3 +38,6 @@ export 'src/users_bloc.dart'; export 'src/users_bloc.dart'; export 'src/utils.dart'; export 'src/video_attachment.dart'; +export 'src/message_search_bloc.dart'; +export 'src/message_search_item.dart'; +export 'src/message_search_list_view.dart';