diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 223bab2b..bf8c0e1a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -119,8 +119,6 @@ PODS: - nanopb/encode (1.30906.0) - path_provider (0.0.1): - Flutter - - "permission_handler (5.0.1+1)": - - Flutter - photo_manager (0.0.1): - Flutter - PromisesObjC (1.2.11) @@ -178,7 +176,6 @@ DEPENDENCIES: - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) - - permission_handler (from `.symlinks/plugins/permission_handler/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) @@ -238,8 +235,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" - permission_handler: - :path: ".symlinks/plugins/permission_handler/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" shared_preferences: @@ -283,7 +278,6 @@ SPEC CHECKSUMS: image_picker: 9c3312491f862b28d21ecd8fdf0ee14e601b3f09 nanopb: 59317e09cf1f1a0af72f12af412d54edf52603fc path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c - permission_handler: eac8e15b4a1a3fba55b761d19f3f4e6b005d15b6 photo_manager: f7c619c2cc8c2adb8d85c63363babac477de9c67 PromisesObjC: 8c196f5a328c2cba3e74624585467a557dcb482f Protobuf: 3dac39b34a08151c6d949560efe3f86134a3f748 diff --git a/example/lib/choose_user_page.dart b/example/lib/choose_user_page.dart index 844a8c77..c728a193 100644 --- a/example/lib/choose_user_page.dart +++ b/example/lib/choose_user_page.dart @@ -157,9 +157,8 @@ class ChooseUserPage extends StatelessWidget { } Navigator.pushNamedAndRemoveUntil( context, - Routes.CHANNEL_LIST, - ModalRoute.withName(Routes.CHANNEL_LIST), - arguments: client, + Routes.HOME, + ModalRoute.withName(Routes.HOME), ); }, leading: UserAvatar( diff --git a/example/lib/group_chat_details_screen.dart b/example/lib/group_chat_details_screen.dart index 88de33a9..d759a82e 100644 --- a/example/lib/group_chat_details_screen.dart +++ b/example/lib/group_chat_details_screen.dart @@ -132,7 +132,7 @@ class _GroupChatDetailsScreenState extends State { Navigator.pushNamedAndRemoveUntil( context, Routes.CHANNEL_PAGE, - ModalRoute.withName(Routes.CHANNEL_LIST), + ModalRoute.withName(Routes.HOME), arguments: channel, ); }, diff --git a/example/lib/main.dart b/example/lib/main.dart index 50ff18b0..4b529e23 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(); @@ -58,15 +60,55 @@ class MyApp extends StatelessWidget { //TODO change to system once dark theme is implemented themeMode: ThemeMode.light, onGenerateRoute: AppRoutes.generateRoute, - initialRoute: client.state.user == null - ? Routes.CHOOSE_USER - : Routes.CHANNEL_LIST, + initialRoute: + client.state.user == null ? Routes.CHOOSE_USER : Routes.HOME, ), ); } } -class ChannelListPage extends StatelessWidget { +class HomePage extends StatefulWidget { + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _currentIndex = 0; + + bool _isSelected(int index) => _currentIndex == index; + + List get _navBarItems { + return [ + BottomNavigationBarItem( + icon: Stack( + overflow: Overflow.visible, + children: [ + StreamSvgIcon.message( + color: _isSelected(0) ? Colors.black : Colors.grey, + ), + Positioned( + top: -3, + right: -16, + child: UnreadIndicator(), + ), + ], + ), + label: 'Chats', + ), + BottomNavigationBarItem( + icon: Stack( + overflow: Overflow.visible, + children: [ + StreamSvgIcon.mentions( + color: _isSelected(1) ? Colors.black : Colors.grey, + ), + ], + ), + label: 'Mentions', + ), + ]; + } + @override Widget build(BuildContext context) { final user = StreamChat.of(context).user; @@ -78,28 +120,22 @@ 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, - ), - channelWidget: ChannelPage(), - ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + items: _navBarItems, + type: BottomNavigationBarType.fixed, + selectedItemColor: Colors.black, + unselectedItemColor: Colors.grey, + onTap: (index) { + setState(() => _currentIndex = index); + }, + ), + body: IndexedStack( + index: _currentIndex, + children: [ + ChannelListPage(), + UserMentionPage(), + ], ), ); } @@ -207,6 +243,178 @@ class ChannelListPage extends StatelessWidget { } } +class UserMentionPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Text('On Pause Right Now!'), + ); + } +} + +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; + 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], + }, + }, + options: { + 'presence': true, + }, + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +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 +423,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/example/lib/new_chat_screen.dart b/example/lib/new_chat_screen.dart index 364c8b7e..5fbfbf07 100644 --- a/example/lib/new_chat_screen.dart +++ b/example/lib/new_chat_screen.dart @@ -338,7 +338,7 @@ class _NewChatScreenState extends State { Navigator.pushNamedAndRemoveUntil( context, Routes.CHANNEL_PAGE, - ModalRoute.withName(Routes.CHANNEL_LIST), + ModalRoute.withName(Routes.HOME), arguments: channel, ); } diff --git a/example/lib/notifications_service.dart b/example/lib/notifications_service.dart index c5b77c0e..83f5047a 100644 --- a/example/lib/notifications_service.dart +++ b/example/lib/notifications_service.dart @@ -33,7 +33,8 @@ void showLocalNotification(Message message, ChannelModel channel) async { } Future backgroundHandler(Map notification) async { - final messageId = notification['data']['message_id']; + print('new notification ${notification}'); + final messageId = notification['data']['id']; final notificationData = await NotificationService.getAndStoreMessage(messageId); diff --git a/example/lib/routes/app_routes.dart b/example/lib/routes/app_routes.dart index 19352319..6b0529e1 100644 --- a/example/lib/routes/app_routes.dart +++ b/example/lib/routes/app_routes.dart @@ -13,11 +13,11 @@ class AppRoutes { static Route generateRoute(RouteSettings settings) { final args = settings.arguments; switch (settings.name) { - case Routes.CHANNEL_LIST: + case Routes.HOME: return MaterialPageRoute( - settings: const RouteSettings(name: Routes.CHANNEL_LIST), + settings: const RouteSettings(name: Routes.HOME), builder: (_) { - return ChannelListPage(); + return HomePage(); }); case Routes.CHOOSE_USER: return MaterialPageRoute( diff --git a/example/lib/routes/routes.dart b/example/lib/routes/routes.dart index f4ace9a4..0e0b1e57 100644 --- a/example/lib/routes/routes.dart +++ b/example/lib/routes/routes.dart @@ -1,6 +1,6 @@ /// Define all the route names here class Routes { - static const String CHANNEL_LIST = '/channel_list'; + static const String HOME = '/home'; static const String CHOOSE_USER = '/choose_user'; static const String ADVANCED_OPTIONS = '/advance_options'; static const String CHANNEL_PAGE = '/channel_page'; diff --git a/example/lib/search_text_field.dart b/example/lib/search_text_field.dart index f46a48b0..e32bb88b 100644 --- a/example/lib/search_text_field.dart +++ b/example/lib/search_text_field.dart @@ -78,7 +78,7 @@ class SearchTextField extends StatelessWidget { Future.microtask( () => [ controller.clear(), - onChanged(''), + if (onChanged != null) onChanged(''), ], ); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index f5beccb2..6ae2ab54 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.79+81 +version: 1.0.87+89 environment: sdk: ">=2.2.2 <3.0.0" diff --git a/lib/src/channel_bottom_sheet.dart b/lib/src/channel_bottom_sheet.dart index c0e32509..48ce8887 100644 --- a/lib/src/channel_bottom_sheet.dart +++ b/lib/src/channel_bottom_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'channel_info.dart'; import 'channel_name.dart'; @@ -12,13 +13,11 @@ import 'user_avatar.dart'; class ChannelBottomSheet extends StatelessWidget { const ChannelBottomSheet({ Key key, - @required this.channel, }) : super(key: key); - final Channel channel; - @override Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; return SafeArea( child: Padding( padding: const EdgeInsets.all(8.0), @@ -31,10 +30,13 @@ class ChannelBottomSheet extends StatelessWidget { vertical: 2.0, ), child: Center( - child: ChannelName( + child: StreamChannel( + showLoading: false, channel: channel, - textStyle: - StreamChatTheme.of(context).channelPreviewTheme.title, + child: ChannelName( + textStyle: + StreamChatTheme.of(context).channelPreviewTheme.title, + ), ), ), ), diff --git a/lib/src/channel_info.dart b/lib/src/channel_info.dart index 208fa588..eda58f43 100644 --- a/lib/src/channel_info.dart +++ b/lib/src/channel_info.dart @@ -80,7 +80,7 @@ class ChannelInfo extends StatelessWidget { } if (!showTypingIndicator) { - return alternativeWidget; + return alternativeWidget ?? Offstage(); } return TypingIndicator( diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index 45c53eb6..1a431672 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -511,7 +511,10 @@ class _ChannelListViewState extends State ), context: context, builder: (context) { - return ChannelBottomSheet(channel: channel); + return StreamChannel( + child: ChannelBottomSheet(), + channel: channel, + ); }, ); }, diff --git a/lib/src/channel_name.dart b/lib/src/channel_name.dart index 01abcb81..bf3eff22 100644 --- a/lib/src/channel_name.dart +++ b/lib/src/channel_name.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:stream_chat/stream_chat.dart'; import '../stream_chat_flutter.dart'; @@ -11,37 +12,59 @@ class ChannelName extends StatelessWidget { /// Instantiate a new ChannelName const ChannelName({ Key key, - this.channel, this.textStyle, }) : super(key: key); - /// The channel to show the name of - final Channel channel; - /// The style of the text displayed final TextStyle textStyle; @override Widget build(BuildContext context) { final client = StreamChat.of(context); - final channel = this.channel ?? StreamChannel.of(context).channel; + final channel = StreamChannel.of(context).channel; + return StreamBuilder>( stream: channel.extraDataStream, initialData: channel.extraData, builder: (context, snapshot) { + return _buildName(snapshot.data, channel.state.members, client); + }, + ); + } + + Widget _buildName( + Map extraData, + List members, + StreamChatState client, + ) { + return LayoutBuilder( + builder: (context, constraints) { String title; - if (snapshot.data['name'] == null) { - final otherMembers = channel.state.members - .where((member) => member.userId != client.user.id); + if (extraData['name'] == null) { + final otherMembers = + members.where((member) => member.userId != client.user.id); if (otherMembers.isNotEmpty) { - final exceedingMembers = otherMembers.length - 5; + final maxWidth = constraints.maxWidth; + final maxChars = maxWidth / textStyle.fontSize; + var currentChars = 0; + final currentMembers = []; + otherMembers.forEach((element) { + final newLength = currentChars + element.user.name.length; + if (newLength < maxChars) { + currentChars = newLength; + currentMembers.add(element); + } + }); + + final exceedingMembers = + otherMembers.length - currentMembers.length; title = - '${otherMembers.take(5).map((e) => e.user.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + '${currentMembers.map((e) => e.user.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; } else { - title = channel.id; + title = 'No title'; } } else { - title = snapshot.data['name']; + title = extraData['name']; } return Text( diff --git a/lib/src/deleted_message.dart b/lib/src/deleted_message.dart index 673d79c8..9181d985 100644 --- a/lib/src/deleted_message.dart +++ b/lib/src/deleted_message.dart @@ -1,6 +1,7 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; class DeletedMessage extends StatelessWidget { const DeletedMessage({ @@ -9,6 +10,7 @@ class DeletedMessage extends StatelessWidget { this.borderRadiusGeometry, this.shape, this.borderSide, + this.reverse = false, }) : super(key: key); /// The theme of the message @@ -23,33 +25,44 @@ class DeletedMessage extends StatelessWidget { /// The borderside of the message text final BorderSide borderSide; + /// If true the widget will be mirrored + final bool reverse; + @override Widget build(BuildContext context) { - return Material( - color: messageTheme.messageBackgroundColor, - shape: shape ?? - RoundedRectangleBorder( - borderRadius: borderRadiusGeometry ?? BorderRadius.zero, - side: borderSide ?? - BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withAlpha(24) - : Colors.black.withAlpha(24), - ), + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Material( + color: messageTheme.messageBackgroundColor, + shape: shape ?? + RoundedRectangleBorder( + borderRadius: borderRadiusGeometry ?? BorderRadius.zero, + side: borderSide ?? + BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withAlpha(24) + : Colors.black.withAlpha(24), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16, ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16, - ), - child: Text( - 'Message deleted', - style: messageTheme.messageText.copyWith( - fontStyle: FontStyle.italic, - color: (Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black) - .withOpacity(.5), + child: Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Text( + 'Message deleted', + style: messageTheme.messageText.copyWith( + fontStyle: FontStyle.italic, + color: (Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black) + .withOpacity(.5), + ), + ), ), ), ), diff --git a/lib/src/media_list_view.dart b/lib/src/media_list_view.dart index 80686d62..507238da 100644 --- a/lib/src/media_list_view.dart +++ b/lib/src/media_list_view.dart @@ -6,6 +6,17 @@ import 'package:stream_chat_flutter/src/lazy_load_scroll_view.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'dart:ui' as ui; +extension on Duration { + String format() { + final s = '$this'.split('.')[0].padLeft(8, '0'); + if (s.startsWith('00:')) { + return s.replaceFirst('00:', ''); + } + + return s; + } +} + class MediaListView extends StatefulWidget { final List selectedIds; final void Function(AssetEntity media) onSelect; @@ -37,7 +48,6 @@ class _MediaListViewState extends State { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, ), - cacheExtent: 1000, itemBuilder: ( context, position, @@ -88,7 +98,7 @@ class _MediaListViewState extends State { ), ), ), - if (media.type == AssetType.video) + if (media.type == AssetType.video) ...[ Positioned( left: 8, bottom: 10, @@ -97,6 +107,17 @@ class _MediaListViewState extends State { package: 'stream_chat_flutter', ), ), + Positioned( + right: 4, + bottom: 10, + child: Text( + media.videoDuration.format(), + style: TextStyle( + color: Colors.white, + ), + ), + ), + ] ], ), onTap: () { diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 10fe6595..1dbc45df 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -11,7 +11,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:http_parser/http_parser.dart' as httpParser; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/compress_video_service.dart'; @@ -22,6 +21,7 @@ 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'; @@ -380,6 +380,7 @@ class MessageInputState extends State { autofocus: false, textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( + isDense: true, hintText: _getHint(), prefixText: _commandEnabled ? null : ' ', border: OutlineInputBorder( @@ -392,7 +393,10 @@ class MessageInputState extends State { borderSide: BorderSide(color: Colors.transparent)), disabledBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.transparent)), - contentPadding: EdgeInsets.all(8), + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 13, + ), prefixIcon: _commandEnabled ? Padding( padding: @@ -665,8 +669,8 @@ class MessageInputState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ IconButton( + iconSize: 24, icon: StreamSvgIcon.pictures( - size: 24, color: _filePickerIndex == 0 ? StreamChatTheme.of(context).accentColor : Colors.black.withOpacity(0.5), @@ -678,8 +682,8 @@ class MessageInputState extends State { }, ), IconButton( + iconSize: 32, icon: StreamSvgIcon.files( - size: 24, color: _filePickerIndex == 1 ? StreamChatTheme.of(context).accentColor : Colors.black.withOpacity(0.5), @@ -689,8 +693,8 @@ class MessageInputState extends State { }, ), IconButton( + iconSize: 24, icon: StreamSvgIcon.camera( - size: 24, color: _filePickerIndex == 2 ? StreamChatTheme.of(context).accentColor : Colors.black.withOpacity(0.5), @@ -700,8 +704,9 @@ class MessageInputState extends State { }, ), IconButton( + padding: const EdgeInsets.all(0), + iconSize: 24, icon: StreamSvgIcon.record( - size: 24, color: _filePickerIndex == 3 ? StreamChatTheme.of(context).accentColor : Colors.black.withOpacity(0.5), @@ -767,10 +772,8 @@ class MessageInputState extends State { Widget _buildPickerSection() { switch (_filePickerIndex) { case 0: - return FutureBuilder( - future: Platform.isAndroid - ? Permission.storage.status - : Permission.photos.status, + return FutureBuilder( + future: PhotoManager.requestPermission(), builder: (context, snapshot) { if (!snapshot.hasData) { return Center( @@ -778,10 +781,10 @@ class MessageInputState extends State { ); } - if (snapshot.data.isGranted) { + if (snapshot.data) { return MediaListView( selectedIds: _attachments.map((e) => e.id).toList(), - onSelect: (media) { + onSelect: (media) async { if (!_attachments .any((element) => element.id == media.id)) { _addAttachment(media); @@ -797,22 +800,7 @@ class MessageInputState extends State { return InkWell( onTap: () async { - var status = await (Platform.isAndroid - ? Permission.storage.status - : Permission.photos.status); - if (status.isPermanentlyDenied || status.isDenied) { - if (await openAppSettings()) { - setState(() {}); - } - } else { - status = await (Platform.isAndroid - ? Permission.storage - : Permission.photos) - .request(); - if (status.isGranted) { - setState(() {}); - } - } + PhotoManager.openSetting(); }, child: Container( color: Color(0xFFF2F2F2), @@ -853,7 +841,7 @@ class MessageInputState extends State { setState(() { _attachments.add(attachment); }); - final mediaFile = await medium.file; + final mediaFile = await medium.originFile; var file = PlatformFile( path: mediaFile.path, @@ -881,7 +869,7 @@ class MessageInputState extends State { } file = PlatformFile( name: file.name, - size: mediaInfo.filesize, + size: (mediaInfo.filesize / 1024).ceil(), bytes: await mediaInfo.file.readAsBytes(), path: mediaInfo.path, ); @@ -1306,6 +1294,12 @@ class MessageInputState extends State { ? Image.memory( attachment.file.bytes, fit: BoxFit.cover, + errorBuilder: (context, _, __) { + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); + }, ) : Image.network( attachment.attachment.imageUrl, @@ -1321,7 +1315,10 @@ class MessageInputState extends State { future: VideoCompress.getFileThumbnail(attachment.file.path), builder: (context, snapshot) { if (!snapshot.hasData) { - return Offstage(); + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); } return Image.file( @@ -1400,15 +1397,6 @@ class MessageInputState extends State { _filePickerSize = _kMinMediaPickerSize; }); } else { - final status = await (Platform.isAndroid - ? Permission.storage.status - : Permission.photos.status); - if (status.isUndetermined) { - await (Platform.isAndroid - ? Permission.storage - : Permission.photos) - .request(); - } showAttachmentModal(); } }, @@ -1538,6 +1526,7 @@ class MessageInputState extends State { } final bytes = await pickedFile.readAsBytes(); file = PlatformFile( + size: (bytes.length / 1024).ceil(), path: pickedFile.path, bytes: bytes, ); @@ -1567,24 +1556,10 @@ class MessageInputState extends State { return; } - if (file.size > _kMaxAttachmentSize) { - if (attachmentType == 'video') { - final mediaInfo = await CompressVideoService.compressVideo(file.path); - file = PlatformFile( - name: mediaInfo.title, - size: mediaInfo.filesize, - bytes: await mediaInfo.file.readAsBytes(), - path: mediaInfo.path, - ); - } else { - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text( - 'The file is too large to upload. The file size limit is 20MB', - ), - ), - ); - } + final mimeType = _getMimeType(file.path.split('/').last); + + if (mimeType.type == 'video' || mimeType.type == 'image') { + attachmentType = mimeType.type; } final channel = StreamChannel.of(context).channel; @@ -1600,6 +1575,33 @@ class MessageInputState extends State { _attachments.add(attachment); }); + if (file.size > _kMaxAttachmentSize) { + if (attachmentType == 'video') { + final mediaInfo = await CompressVideoService.compressVideo(file.path); + file = PlatformFile( + name: mediaInfo.title, + size: (mediaInfo.filesize / 1024).ceil(), + bytes: await mediaInfo.file.readAsBytes(), + path: mediaInfo.path, + ); + setState(() { + attachment.file = file; + }); + } else { + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text( + 'The file is too large to upload. The file size limit is 20MB', + ), + ), + ); + setState(() { + _attachments.remove(attachment); + }); + return; + } + } + final url = await _uploadAttachment(file, fileType, channel); if (fileType == DefaultAttachmentTypes.image) { @@ -1640,28 +1642,41 @@ class MessageInputState extends State { } Future _uploadImage(PlatformFile file, Channel channel) async { - final filename = file.name ?? file.path?.split('/')?.last; + final filename = file.path?.split('/')?.last; + final mimeType = _getMimeType(filename); final bytes = file.bytes; final res = await channel.sendImage( MultipartFile.fromBytes( bytes, filename: filename, - contentType: filename != null - ? httpParser.MediaType.parse(lookupMimeType(filename)) - : null, + contentType: mimeType, ), ); return res.file; } + httpParser.MediaType _getMimeType(String filename) { + httpParser.MediaType mimeType; + if (filename != null) { + if (filename.toLowerCase().endsWith('heic')) { + mimeType = httpParser.MediaType.parse('image/heic'); + } else { + mimeType = httpParser.MediaType.parse(lookupMimeType(filename)); + } + } + + return mimeType; + } + Future _uploadFile(PlatformFile file, Channel channel) async { - final filename = file.name ?? file.path?.split('/')?.last; + final filename = file.path?.split('/')?.last; + final mimeType = _getMimeType(filename); final bytes = file.bytes; final res = await channel.sendFile( MultipartFile.fromBytes( bytes, filename: filename, - contentType: httpParser.MediaType.parse(lookupMimeType(filename)), + contentType: mimeType, ), ); return res.file; @@ -1704,17 +1719,17 @@ class MessageInputState extends State { if (_commandEnabled) { return 'Icon_search.svg'; } else { - return 'Icon_circle_up.svg'; + return 'Icon_circle_right.svg'; } } String _getSendIcon() { if (widget.editMessage != null) { - return 'Icon_circle_right.svg'; + return 'Icon_circle_up.svg'; } else if (_commandEnabled) { return 'Icon_search.svg'; } else { - return 'Icon_circle_right.svg'; + return 'Icon_circle_up.svg'; } } diff --git a/lib/src/message_search_bloc.dart b/lib/src/message_search_bloc.dart new file mode 100644 index 00000000..0128bb22 --- /dev/null +++ b/lib/src/message_search_bloc.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat.dart'; + +/// Widget dedicated to the management of a message list with pagination +class MessageSearchBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Instantiate a new MessageSearchBloc + const MessageSearchBloc({ + Key key, + @required this.child, + }) : super(key: key); + + @override + MessageSearchBlocState createState() => MessageSearchBlocState(); + + /// Use this method to get the current [MessageSearchBlocState] instance + static MessageSearchBlocState of(BuildContext context) { + MessageSearchBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a MessageSearchBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [MessageSearchBloc] +class MessageSearchBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current messages list + List get messageResponses => _messageResponses.value; + + /// The current messages list as a stream + Stream> get messagesStream => + _messageResponses.stream; + + final BehaviorSubject> _messageResponses = + BehaviorSubject(); + + final BehaviorSubject _queryMessagesLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryMessagesLoading => + _queryMessagesLoadingController.stream; + + /// Calls [Client.search] updating [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(messageResponses ?? []); + + final messageResponse = await client.search( + filter, + sort, + query, + pagination, + ); + + if (clear) { + _messageResponses.add(messageResponse.results); + } else { + final temp = oldMessages + messageResponse.results; + _messageResponses.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() { + _messageResponses.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..3e786b9a --- /dev/null +++ b/lib/src/message_search_item.dart @@ -0,0 +1,137 @@ +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'; + +/// It shows the current [Message] preview. +/// +/// Usually you don't use this widget as it's the default item used by [MessageSearchListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageSearchItem extends StatelessWidget { + /// Instantiate a new MessageSearchItem + const MessageSearchItem({ + Key key, + @required this.getMessageResponse, + this.onTap, + this.showOnlineStatus = true, + }) : super(key: key); + + /// [Message] displayed + final GetMessageResponse getMessageResponse; + + /// Function called when tapping this widget + final VoidCallback onTap; + + /// If true the [MessageSearchItem] will show the current online Status + final bool showOnlineStatus; + + @override + Widget build(BuildContext context) { + final message = getMessageResponse.message; + final channel = getMessageResponse.channel; + final channelName = channel.extraData['name']; + final user = message.user; + return ListTile( + onTap: onTap, + leading: UserAvatar( + user: user, + showOnlineStatus: showOnlineStatus, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: Row( + children: [ + Text( + user.id == StreamChat.of(context).user.id ? 'You' : user.name, + style: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + if (channelName != null) ...[ + Text( + ' in ', + style: StreamChatTheme.of(context) + .channelPreviewTheme + .title + .copyWith( + fontWeight: FontWeight.normal, + ), + ), + Text( + channelName, + style: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + ], + ], + ), + subtitle: Row( + children: [ + Expanded(child: _buildSubtitle(context, message)), + SizedBox(width: 16), + _buildDate(context, message), + ], + ), + ); + } + + 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..8b346570 --- /dev/null +++ b/lib/src/message_search_list_view.dart @@ -0,0 +1,358 @@ +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'; + +/// Callback called when tapping on a user +typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); + +/// Builder used to create a custom [ListUserItem] from a [User] +typedef MessageSearchItemBuilder = Widget Function( + BuildContext, GetMessageResponse); + +/// +/// It shows the list of searched messages. +/// +/// ```dart +/// class MessageSearchPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: MessageSearchListView( +/// messageQuery: _channelQuery, +/// filters: { +/// 'members': { +/// r'$in': [user.id] +/// } +/// }, +/// paginationParams: PaginationParams(limit: 20), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the information about the messages. +/// The widget uses a [ListView.separated] to render the list of messages. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageSearchListView extends StatefulWidget { + /// Instantiate a new MessageSearchListView + const MessageSearchListView({ + Key key, + @required this.messageQuery, + @required this.filters, + this.sortOptions, + this.paginationParams, + this.emptyBuilder, + this.errorBuilder, + this.separatorBuilder, + this.itemBuilder, + this.onItemTap, + this.showResultCount = true, + }) : super(key: key); + + /// Message String to search on + final String messageQuery; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filters; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sortOptions; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams paginationParams; + + /// Builder used to create a custom item preview + final MessageSearchItemBuilder itemBuilder; + + /// Function called when tapping on a [MessageSearchItem] + final MessageSearchItemTapCallback onItemTap; + + /// 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; + + /// Set it to false to hide total results text + final bool showResultCount; + + @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); + return _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, GetMessageResponse getMessageResponse) { + if (widget.itemBuilder != null) { + return widget.itemBuilder(context, getMessageResponse); + } + return MessageSearchItem( + getMessageResponse: getMessageResponse, + onTap: () => widget.onItemTap(getMessageResponse), + ); + } + + 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.messageResponses?.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 (widget.showResultCount) { + 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/message_widget.dart b/lib/src/message_widget.dart index 17f00d02..64dab4ac 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -287,6 +287,7 @@ class _MessageWidgetState extends State { transform: Matrix4.rotationY( widget.reverse ? pi : 0), child: DeletedMessage( + reverse: widget.reverse, borderRadiusGeometry: widget.borderRadiusGeometry, borderSide: widget.borderSide, diff --git a/lib/src/stream_chat_theme.dart b/lib/src/stream_chat_theme.dart index d5b2c1c2..54b096d1 100644 --- a/lib/src/stream_chat_theme.dart +++ b/lib/src/stream_chat_theme.dart @@ -243,9 +243,9 @@ class StreamChatThemeData { ), ), title: TextStyle( - fontSize: 14, - color: isDark ? Colors.white : Colors.black, - ), + fontSize: 14, + color: isDark ? Colors.white : Colors.black, + fontWeight: FontWeight.bold), subtitle: TextStyle( fontSize: 12.5, color: (isDark ? Colors.white : Colors.black).withOpacity(0.5), diff --git a/lib/src/user_list_view.dart b/lib/src/user_list_view.dart index 569eb38a..b57e2d55 100644 --- a/lib/src/user_list_view.dart +++ b/lib/src/user_list_view.dart @@ -6,7 +6,6 @@ import 'package:stream_chat_flutter/src/lazy_load_scroll_view.dart'; import 'package:stream_chat_flutter/src/users_bloc.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'stream_chat.dart'; import 'user_item.dart'; /// Callback called when tapping on a user @@ -41,8 +40,8 @@ typedef UserItemBuilder = Widget Function(BuildContext, User, bool); /// ``` /// /// -/// Make sure to have a [StreamChat] ancestor in order to provide the information about the channels. -/// The widget uses a [ListView.custom] to render the list of channels. +/// Make sure to have a [UsersBloc] ancestor in order to provide the information about the users. +/// The widget uses a [ListView.separated], [GridView.builder] to render the list, grid of channels. /// /// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. /// Modify it to change the widget appearance. @@ -63,7 +62,6 @@ class UserListView extends StatefulWidget { this.separatorBuilder, this.onImageTap, this.selectedUsers, - this.swipeToAction = false, this.pullToRefresh = true, this.groupAlphabetically = false, this.crossAxisCount = 1, @@ -76,9 +74,6 @@ class UserListView extends StatefulWidget { /// The builder that will be used in case of error final Widget Function(Error error) errorBuilder; - /// If true a default swipe to action behaviour will be added to this widget - final bool swipeToAction; - /// The builder used when the channel list is empty. final WidgetBuilder emptyBuilder; @@ -321,7 +316,6 @@ class _UserListViewState extends State final child = _isListView ? ListView.separated( physics: AlwaysScrollableScrollPhysics(), - // controller: _scrollController, itemCount: items.isNotEmpty ? items.length + 1 : items.length, separatorBuilder: (_, index) { if (widget.separatorBuilder != null) { diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index 5f880d0c..5dd46fa3 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -40,3 +40,7 @@ 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'; +export 'src/unread_indicator.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 9efce978..b41f58c6 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.13+1 + stream_chat: ^0.2.14 mime: ^0.9.6+3 video_compress: ^2.1.1 visibility_detector: ^0.1.5 @@ -41,7 +41,6 @@ dependencies: image_gallery_saver: ^1.6.6 esys_flutter_share: ^1.0.2 photo_manager: ^0.5.8 - permission_handler: ^5.0.1+1 transparent_image: ^1.0.0 ezanimation: ^0.4.1 synchronized: ^2.2.0+2