From c416d20cb63923fcf632f62929adde7a35774c6f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 28 Dec 2020 22:14:43 +0530 Subject: [PATCH 01/11] Add mention screen Signed-off-by: Sahil Kumar --- example/lib/main.dart | 98 +++++++++++++++++++++++++++++++++- lib/src/message_list_view.dart | 13 +++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 803df8e6..f43f5f8f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -250,8 +250,71 @@ class _HomePageState extends State { class UserMentionPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Text('On Pause Right Now!'), + final user = StreamChat.of(context).user; + return MessageSearchBloc( + child: MessageSearchListView( + messageQuery: '@${user.name}', + filters: { + 'members': { + r'$in': [user.id] + } + }, + sortOptions: [ + SortOption( + 'created_at', + direction: SortOption.ASC, + ), + ], + paginationParams: PaginationParams(limit: 20), + showResultCount: false, + emptyBuilder: (_, __) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: StreamSvgIcon.mentions( + size: 96, + color: Colors.grey, + ), + ), + Text('No mentions exist yet...'), + ], + ), + ), + ), + ); + }, + ); + }, + 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, + ), + ); + }, + ), ); } } @@ -336,6 +399,37 @@ class _ChannelListPageState extends State { ), ], paginationParams: PaginationParams(limit: 20), + emptyBuilder: (_, query) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: + viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: StreamSvgIcon.search( + size: 96, + color: Colors.grey, + ), + ), + Text( + 'No results for \"$query\"...', + ), + ], + ), + ), + ), + ); + }, + ); + }, onItemTap: (messageResponse) async { final client = StreamChat.of(context).client; final message = messageResponse.message; diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index a856db2b..cd1d09e0 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -116,6 +116,7 @@ class MessageListView extends StatefulWidget { this.scrollController, this.itemPositionListener, this.highlightInitialMessage = false, + this.highlightColor = const Color(0XFFFBF4DD), }) : super(key: key); /// Function used to build a custom message widget @@ -162,6 +163,11 @@ class MessageListView extends StatefulWidget { /// Also See [StreamChannel] final bool highlightInitialMessage; + /// The color used for highlighting the initialMessage if there is any. + /// + /// Also See [highlightInitialMessage] + final Color highlightColor; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -796,13 +802,12 @@ class _MessageListViewState extends State { 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, + begin: widget.highlightColor, + end: Colors.white10, ), - duration: const Duration(seconds: 2), + duration: const Duration(seconds: 3), child: child, onEnd: () { initialMessageHighlightComplete = true; From 2829866c4eb7149dfeb111b39628a6d16800abb3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 7 Jan 2021 17:02:36 +0530 Subject: [PATCH 02/11] [MessageSearchListView] Add support for `messageFilters` Signed-off-by: Sahil Kumar --- example/lib/main.dart | 10 +++-- lib/src/message_search_bloc.dart | 62 +++++++++++++++++++++------ lib/src/message_search_list_view.dart | 30 ++++++++----- 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 39ff4a61..0bc75cea 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -276,11 +276,15 @@ class UserMentionPage extends StatelessWidget { final user = StreamChat.of(context).user; return MessageSearchBloc( child: MessageSearchListView( - messageQuery: '@${user.name}', filters: { 'members': { - r'$in': [user.id] - } + r'$in': [user.id], + }, + }, + messageFilters: { + 'mentioned_users.id': { + r'$contains': user.id, + }, }, sortOptions: [ SortOption( diff --git a/lib/src/message_search_bloc.dart b/lib/src/message_search_bloc.dart index ab277c4c..735c1439 100644 --- a/lib/src/message_search_bloc.dart +++ b/lib/src/message_search_bloc.dart @@ -52,7 +52,7 @@ class MessageSearchBlocState extends State Stream get queryMessagesLoading => _queryMessagesLoadingController.stream; - /// Calls [Client.search] updating [queryMessagesLoading] stream + /// Calls [Client.search] updating [messageResponses] stream Future search({ Map filter, Map messageFilter, @@ -60,10 +60,31 @@ class MessageSearchBlocState extends State String query, PaginationParams pagination, }) async { - final client = StreamChat.of(context).client; + if (messageResponses?.isNotEmpty == true) return; + _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); + } + } - if (client.state?.user == null || - _queryMessagesLoadingController.value == true) { + /// 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); @@ -74,18 +95,18 @@ class MessageSearchBlocState extends State final oldMessages = List.from(messageResponses ?? []); - final messageResponse = await client.search( - filter, - sort, - query, - pagination, - messageFilters: messageFilter, + final messages = await _search( + filter: filter, + messageFilter: messageFilter, + sort: sort, + query: query, + pagination: pagination, ); if (clear) { - _messageResponses.add(messageResponse.results); + _messageResponses.add(messages.results); } else { - final temp = oldMessages + messageResponse.results; + final temp = oldMessages + messages.results; _messageResponses.add(temp); } @@ -95,6 +116,23 @@ class MessageSearchBlocState extends State } } + 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); diff --git a/lib/src/message_search_list_view.dart b/lib/src/message_search_list_view.dart index 3ff9f151..1adb45a9 100644 --- a/lib/src/message_search_list_view.dart +++ b/lib/src/message_search_list_view.dart @@ -51,10 +51,11 @@ class MessageSearchListView extends StatefulWidget { /// Instantiate a new MessageSearchListView const MessageSearchListView({ Key key, - @required this.messageQuery, - @required this.filters, + this.messageQuery, + this.filters, this.sortOptions, this.paginationParams, + this.messageFilters, this.emptyBuilder, this.errorBuilder, this.separatorBuilder, @@ -83,6 +84,11 @@ class MessageSearchListView extends StatefulWidget { /// message_limit: how many messages should be included to each channel final PaginationParams paginationParams; + /// The message 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 messageFilters; + /// Builder used to create a custom item preview final MessageSearchItemBuilder itemBuilder; @@ -115,6 +121,7 @@ class _MessageSearchListViewState extends State { sort: widget.sortOptions, query: widget.messageQuery, pagination: widget.paginationParams, + messageFilter: widget.messageFilters, ); } @@ -205,9 +212,7 @@ class _MessageSearchListViewState extends State { children: [ WidgetSpan( child: Padding( - padding: const EdgeInsets.only( - right: 2.0, - ), + padding: const EdgeInsets.only(right: 2.0), child: Icon(Icons.error_outline), ), ), @@ -217,18 +222,17 @@ class _MessageSearchListViewState extends State { style: Theme.of(context).textTheme.headline6, ), Padding( - padding: const EdgeInsets.only( - top: 16.0, - ), + padding: const EdgeInsets.only(top: 16.0), child: Text(message), ), - FlatButton( + RaisedButton( onPressed: () { messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, query: widget.messageQuery, pagination: widget.paginationParams, + messageFilter: widget.messageFilters, ); }, child: Text('Retry'), @@ -281,13 +285,14 @@ class _MessageSearchListViewState extends State { Widget child; child = LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.search( + onEndOfPage: () => messageSearchBloc.loadMore( filter: widget.filters, sort: widget.sortOptions, pagination: widget.paginationParams.copyWith( offset: messageSearchBloc.messageResponses?.length ?? 0, ), query: widget.messageQuery, + messageFilter: widget.messageFilters, ), child: ListView.separated( physics: AlwaysScrollableScrollPhysics(), @@ -344,13 +349,16 @@ class _MessageSearchListViewState extends State { jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || widget.paginationParams?.toJson()?.toString() != oldWidget.paginationParams?.toJson()?.toString() || - widget.messageQuery?.toString() != oldWidget.messageQuery?.toString()) { + widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || + widget.messageFilters?.toString() != + oldWidget.messageFilters?.toString()) { final messageSearchBloc = MessageSearchBloc.of(context); messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, query: widget.messageQuery, pagination: widget.paginationParams, + messageFilter: widget.messageFilters, ); } } From ec499ce4c85f35f3d0757f134bfdc0107b50012c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 7 Jan 2021 17:13:28 +0530 Subject: [PATCH 03/11] [MessageSearchListView] Fix separator color Signed-off-by: Sahil Kumar --- lib/src/message_search_list_view.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/src/message_search_list_view.dart b/lib/src/message_search_list_view.dart index 1adb45a9..33d6eb68 100644 --- a/lib/src/message_search_list_view.dart +++ b/lib/src/message_search_list_view.dart @@ -134,9 +134,7 @@ class _MessageSearchListViewState extends State { Widget _separatorBuilder(BuildContext context, int index) { return Container( height: 1, - color: Theme.of(context).brightness == Brightness.dark - ? StreamChatTheme.of(context).colorTheme.white.withOpacity(0.1) - : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.1), + color: StreamChatTheme.of(context).colorTheme.greyWhisper, ); } From cf32ddf850e05174e635c173aac1cdd14beb8654 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 7 Jan 2021 18:24:44 +0530 Subject: [PATCH 04/11] [MessageSearchListView] Add pullToRefresh Signed-off-by: Sahil Kumar --- example/lib/main.dart | 1 + lib/src/message_search_bloc.dart | 1 - lib/src/message_search_list_view.dart | 51 ++++++++++++++++++--------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 0bc75cea..69099c73 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -425,6 +425,7 @@ class _ChannelListPageState extends State { direction: SortOption.ASC, ), ], + pullToRefresh: false, paginationParams: PaginationParams(limit: 20), emptyBuilder: (_, query) { return LayoutBuilder( diff --git a/lib/src/message_search_bloc.dart b/lib/src/message_search_bloc.dart index 735c1439..67b6455d 100644 --- a/lib/src/message_search_bloc.dart +++ b/lib/src/message_search_bloc.dart @@ -60,7 +60,6 @@ class MessageSearchBlocState extends State String query, PaginationParams pagination, }) async { - if (messageResponses?.isNotEmpty == true) return; _messageResponses.add(null); try { final messages = await _search( diff --git a/lib/src/message_search_list_view.dart b/lib/src/message_search_list_view.dart index 33d6eb68..73a98a2d 100644 --- a/lib/src/message_search_list_view.dart +++ b/lib/src/message_search_list_view.dart @@ -62,6 +62,7 @@ class MessageSearchListView extends StatefulWidget { this.itemBuilder, this.onItemTap, this.showResultCount = true, + this.pullToRefresh = true, }) : super(key: key); /// Message String to search on @@ -107,6 +108,9 @@ class MessageSearchListView extends StatefulWidget { /// Set it to false to hide total results text final bool showResultCount; + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + @override _MessageSearchListViewState createState() => _MessageSearchListViewState(); } @@ -281,7 +285,35 @@ class _MessageSearchListViewState extends State { ); } - Widget child; + Widget 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 (widget.pullToRefresh) { + child = RefreshIndicator( + onRefresh: () async => messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + messageFilter: widget.messageFilters, + ), + child: child, + ); + } + child = LazyLoadScrollView( onEndOfPage: () => messageSearchBloc.loadMore( filter: widget.filters, @@ -292,22 +324,7 @@ class _MessageSearchListViewState extends State { query: widget.messageQuery, messageFilter: widget.messageFilters, ), - 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); - }, - ), + child: child, ); if (widget.showResultCount) { From 68f9ec8d5ed72031ff0826337be0acb0d80ef55a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 7 Jan 2021 18:46:37 +0530 Subject: [PATCH 05/11] fix test Signed-off-by: Sahil Kumar --- test/src/channel_preview_test.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/src/channel_preview_test.dart b/test/src/channel_preview_test.dart index 578d4b28..34b9bf92 100644 --- a/test/src/channel_preview_test.dart +++ b/test/src/channel_preview_test.dart @@ -42,10 +42,18 @@ void main() { user: User(id: 'user-id'), ), ]); - when(channelState.lastMessage).thenReturn(Message( - text: 'hello', - user: User(id: 'other-user'), - )); + when(channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); await tester.pumpWidget(MaterialApp( home: StreamChat( From eaa3ef0959515bd27e2445637839d320adb7582c Mon Sep 17 00:00:00 2001 From: Deven Joshi Date: Fri, 8 Jan 2021 14:21:38 +0530 Subject: [PATCH 06/11] feat: Added typing indicator design and styling fixes --- example/lib/main.dart | 6 ++++++ lib/src/typing_indicator.dart | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 6df1dafb..2bf460f8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -578,6 +578,12 @@ class _ChannelPageState extends State { horizontal: 8, vertical: 4, ), + style: StreamChatTheme.of(context) + .textTheme + .footnote + .copyWith( + color: + StreamChatTheme.of(context).colorTheme.grey), ), ), ), diff --git a/lib/src/typing_indicator.dart b/lib/src/typing_indicator.dart index b13eade6..177e9e21 100644 --- a/lib/src/typing_indicator.dart +++ b/lib/src/typing_indicator.dart @@ -54,7 +54,7 @@ class TypingIndicator extends StatelessWidget { height: 4, ), Text( - ' ${snapshot.data.map((u) => u.name).join(',')} ${snapshot.data.length == 1 ? 'is' : 'are'} typing', + ' ${snapshot.data[0].name}${snapshot.data.length == 1 ? '' : ' and ${snapshot.data.length - 1} more'} ${snapshot.data.length == 1 ? 'is' : 'are'} typing', maxLines: 1, style: style, ), From 1662c14dd35f5f7a32a5b6285b917b232189aa7b Mon Sep 17 00:00:00 2001 From: Deven Joshi Date: Fri, 8 Jan 2021 15:26:16 +0530 Subject: [PATCH 07/11] fix: Fixed no permission modal --- lib/src/message_input.dart | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 0a959ffa..ee8763ba 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -974,9 +974,22 @@ class MessageInputState extends State { 'svgs/icon_picture_empty_state.svg', package: 'stream_chat_flutter', height: 140, - color: - StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, ), + Text( + 'Please enable access to your photos \nand videos so you can share them with friends.', + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .grey), + textAlign: TextAlign.center, + ), + SizedBox(height: 6.0), Center( child: Text( 'Allow access to your gallery', From 68db556c0ff8ee955eb317d55b86a9a656f1880c Mon Sep 17 00:00:00 2001 From: Deven Joshi Date: Fri, 8 Jan 2021 16:32:53 +0530 Subject: [PATCH 08/11] fix: Fixed mention screen styling --- example/lib/main.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 2f85d126..3f1112ba 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -74,7 +74,7 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: ThemeData.light(), darkTheme: ThemeData.dark(), - themeMode: ThemeMode.dark, + themeMode: ThemeMode.system, onGenerateRoute: AppRoutes.generateRoute, initialRoute: client.state.user == null ? Routes.CHOOSE_USER : Routes.HOME, @@ -323,10 +323,21 @@ class UserMentionPage extends StatelessWidget { padding: const EdgeInsets.all(24), child: StreamSvgIcon.mentions( size: 96, - color: Colors.grey, + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, ), ), - Text('No mentions exist yet...'), + Text( + 'No mentions exist yet...', + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith( + color: + StreamChatTheme.of(context).colorTheme.grey, + ), + ), ], ), ), From d7aaa9614b9a3253873548a8bdced89dc7d3e987 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Fri, 8 Jan 2021 14:47:11 +0100 Subject: [PATCH 09/11] fix link image size --- lib/src/message_widget.dart | 2 ++ lib/src/url_attachment.dart | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 1200f38d..2a4ab364 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -842,6 +842,8 @@ class _MessageWidgetState extends State { } void onLongPress(BuildContext context) { + print( + 'widget.message.attachment[0].toJson(): ${widget.message.attachments[0].imageUrl}'); if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.SENDING) { return; diff --git a/lib/src/url_attachment.dart b/lib/src/url_attachment.dart index 1909fc33..130b1109 100644 --- a/lib/src/url_attachment.dart +++ b/lib/src/url_attachment.dart @@ -34,10 +34,10 @@ class UrlAttachment extends StatelessWidget { margin: EdgeInsets.symmetric(horizontal: 8.0), child: Stack( children: [ - Center( - child: CachedNetworkImage( - imageUrl: urlAttachment.imageUrl, - ), + CachedNetworkImage( + width: double.infinity, + imageUrl: urlAttachment.imageUrl, + fit: BoxFit.cover, ), Positioned( left: 0.0, From 26f30f06d09628aebf2e2a4a8adcc85b5593912d Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Fri, 8 Jan 2021 14:47:39 +0100 Subject: [PATCH 10/11] remove log --- lib/src/message_widget.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 2a4ab364..1200f38d 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -842,8 +842,6 @@ class _MessageWidgetState extends State { } void onLongPress(BuildContext context) { - print( - 'widget.message.attachment[0].toJson(): ${widget.message.attachments[0].imageUrl}'); if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.SENDING) { return; From 81edc934f434f83ff5cb2189deee7c2f3e825f04 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Fri, 8 Jan 2021 17:45:41 +0100 Subject: [PATCH 11/11] update profiles --- example/ios/fastlane/report.xml | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/example/ios/fastlane/report.xml b/example/ios/fastlane/report.xml index d6fdf2d7..3a11e14a 100644 --- a/example/ios/fastlane/report.xml +++ b/example/ios/fastlane/report.xml @@ -5,39 +5,27 @@ - + - + - + - + - - - - - - - - - - - - - +