diff --git a/lib/src/channel_image.dart b/lib/src/channel_image.dart index 3e436ee8..efd8700e 100644 --- a/lib/src/channel_image.dart +++ b/lib/src/channel_image.dart @@ -52,6 +52,9 @@ class ChannelImage extends StatelessWidget { this.onTap, this.showOnlineStatus = true, this.borderRadius, + this.selected = false, + this.selectionColor = const Color(0xFF006CFF), + this.selectionThickness = 4, }) : super(key: key); final BorderRadius borderRadius; @@ -67,6 +70,12 @@ class ChannelImage extends StatelessWidget { final bool showOnlineStatus; + final bool selected; + + final Color selectionColor; + + final double selectionThickness; + @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); @@ -94,11 +103,10 @@ class ChannelImage extends StatelessWidget { .channelPreviewTheme .avatarTheme .constraints, - onTap: onTap != null - ? (_) { - onTap(); - } - : null, + onTap: onTap != null ? (_) => onTap() : null, + selected: selected, + selectionColor: selectionColor, + selectionThickness: selectionThickness, ); }); } else { @@ -111,16 +119,20 @@ class ChannelImage extends StatelessWidget { .toList(); return GroupImage( images: images, + borderRadius: borderRadius, constraints: constraints ?? StreamChatTheme.of(context) .channelPreviewTheme .avatarTheme .constraints, onTap: onTap, + selected: selected, + selectionColor: selectionColor, + selectionThickness: selectionThickness, ); } - return ClipRRect( + Widget child = ClipRRect( borderRadius: borderRadius ?? StreamChatTheme.of(context) .channelPreviewTheme @@ -171,6 +183,29 @@ class ChannelImage extends StatelessWidget { ), ), ); + if (selected) { + child = ClipRRect( + borderRadius: (borderRadius ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + constraints: constraints ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .constraints, + color: selectionColor, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: child, + ), + ), + ); + } + return child; }); } } diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index fcbe4452..95d60f9c 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -74,6 +74,8 @@ class ChannelListView extends StatefulWidget { this.onStartChatPressed, this.swipeToAction = false, this.pullToRefresh = true, + this.crossAxisCount = 1, + this.selectedChannels = const [], }) : super(key: key); /// The builder that will be used in case of error @@ -134,6 +136,11 @@ class ChannelListView extends StatefulWidget { /// Callback used in the default empty list widget final VoidCallback onStartChatPressed; + /// The number of children in the cross axis. + final int crossAxisCount; + + final List selectedChannels; + @override _ChannelListViewState createState() => _ChannelListViewState(); } @@ -268,22 +275,33 @@ class _ChannelListViewState extends State } if (channels.isNotEmpty) { - child = ListView.custom( - physics: AlwaysScrollableScrollPhysics(), - controller: _scrollController, - childrenDelegate: SliverChildBuilderDelegate( - (context, i) { - return _itemBuilder(context, i, channels); + if (widget.crossAxisCount > 1) { + child = GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount), + itemCount: channels.length, + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + itemBuilder: (context, index) { + return _gridItemBuilder(context, index, channels); }, - childCount: (channels.length * 2) + 1, - findChildIndexCallback: (key) { - final ValueKey valueKey = key; - final index = channels.indexWhere( - (channel) => 'CHANNEL-${channel.id}' == valueKey.value); - return index != -1 ? (index * 2) : null; + ); + } else { + child = ListView.separated( + physics: AlwaysScrollableScrollPhysics(), + itemCount: + channels.isNotEmpty ? channels.length + 1 : channels.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); }, - ), - ); + itemBuilder: (context, index) { + return _listItemBuilder(context, index, channels); + }, + ); + } } } @@ -301,11 +319,13 @@ class _ChannelListViewState extends State children: List.generate( 25, (i) { - if (i % 2 != 0) { - if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, i); + if (widget.crossAxisCount == 1) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); } - return _separatorBuilder(context, i); } return _buildLoadingItem(); }, @@ -314,63 +334,96 @@ class _ChannelListViewState extends State } Shimmer _buildLoadingItem() { - return Shimmer.fromColors( - baseColor: Color(0xffE5E5E5), - highlightColor: StreamChatTheme.of(context).colorTheme.white, - child: ListTile( - leading: Container( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - shape: BoxShape.circle, - ), - constraints: BoxConstraints.tightFor( - height: 40, - width: 40, - ), + if (widget.crossAxisCount > 1) { + return Shimmer.fromColors( + baseColor: Color(0xffE5E5E5), + highlightColor: Color(0xffffffff), + child: Column( + children: [ + SizedBox( + height: 4.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (int i = 0; i < widget.crossAxisCount; i++) + Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + constraints: BoxConstraints.tightFor( + height: 70, + width: 70, + ), + ), + ], + ), + SizedBox( + height: 16.0, + ), + ], ), - title: Align( - alignment: Alignment.centerLeft, - child: Container( + ); + } else { + return Shimmer.fromColors( + baseColor: Color(0xffE5E5E5), + highlightColor: StreamChatTheme.of(context).colorTheme.white, + child: ListTile( + leading: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(11), + shape: BoxShape.circle, ), constraints: BoxConstraints.tightFor( - height: 16, - width: 82, + height: 40, + width: 40, ), ), - ), - subtitle: Row( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(11), - ), - constraints: BoxConstraints.tightFor( - height: 16, - width: 238, - ), - ), - ), - Container( - margin: const EdgeInsets.only(left: 16), + title: Align( + alignment: Alignment.centerLeft, + child: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, borderRadius: BorderRadius.circular(11), ), constraints: BoxConstraints.tightFor( height: 16, - width: 42, + width: 82, ), ), - ], + ), + subtitle: Row( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 238, + ), + ), + ), + Container( + margin: const EdgeInsets.only(left: 16), + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 42, + ), + ), + ], + ), ), - ), - ); + ); + } } Widget _buildErrorWidget( @@ -437,20 +490,10 @@ class _ChannelListViewState extends State ); } - Widget _itemBuilder(context, int i, List channels) { - if (i % 2 != 0) { - if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, i); - } - return _separatorBuilder(context, i); - } - - i = i ~/ 2; - + Widget _listItemBuilder(BuildContext context, int i, List channels) { final channelsProvider = ChannelsBloc.of(context); if (i < channels.length) { final channel = channels[i]; - ChannelTapCallback onTap; if (widget.onChannelTap != null) { onTap = widget.onChannelTap; @@ -590,6 +633,47 @@ class _ChannelListViewState extends State } } + Widget _gridItemBuilder(BuildContext context, int i, List channels) { + var channel = channels[i]; + + var selected = widget.selectedChannels.contains(channel); + + return Container( + key: ValueKey('CHANNEL-${channel.id}'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ChannelImage( + channel: channel, + borderRadius: BorderRadius.circular(32), + selected: selected, + constraints: BoxConstraints.tightFor( + width: 64, + height: 64, + ), + onTap: () { + widget.onChannelTap(channel, null); + }, + ), + SizedBox(height: 7), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: StreamChannel( + child: ChannelName( + textStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + channel: channel, + ), + ), + ], + ), + ); + } + Widget _buildQueryProgressIndicator( context, ChannelsBlocState channelsProvider, diff --git a/lib/src/group_image.dart b/lib/src/group_image.dart index f4f21b90..fc45aa59 100644 --- a/lib/src/group_image.dart +++ b/lib/src/group_image.dart @@ -9,21 +9,33 @@ class GroupImage extends StatelessWidget { @required this.images, this.constraints, this.onTap, + this.borderRadius, + this.selected = false, + this.selectionColor = const Color(0xFF006CFF), + this.selectionThickness = 4, }) : super(key: key); final List images; final BoxConstraints constraints; final VoidCallback onTap; + final bool selected; + final BorderRadius borderRadius; + final Color selectionColor; + final double selectionThickness; @override Widget build(BuildContext context) { - return GestureDetector( + var avatar; + final streamChatTheme = StreamChatTheme.of(context); + + avatar = GestureDetector( onTap: onTap, child: ClipRRect( - borderRadius: StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .borderRadius, + borderRadius: borderRadius ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .borderRadius, child: Container( constraints: constraints ?? StreamChatTheme.of(context) @@ -91,5 +103,24 @@ class GroupImage extends StatelessWidget { ), ), ); + + if (selected) { + avatar = ClipRRect( + borderRadius: (borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + color: selectionColor, + height: 64.0, + width: 64.0, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } + + return avatar; } } diff --git a/lib/src/image_footer.dart b/lib/src/image_footer.dart index 36b3d042..fa74ff9d 100644 --- a/lib/src/image_footer.dart +++ b/lib/src/image_footer.dart @@ -16,8 +16,6 @@ import 'package:stream_chat_flutter/src/stream_chat.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'stream_channel.dart'; - class ImageFooter extends StatefulWidget { /// Callback to call when pressing the back button. /// By default it calls [Navigator.pop] @@ -64,11 +62,11 @@ class _ImageFooterState extends State { bool _userSearchMode = false; TextEditingController _searchController; TextEditingController _messageController = TextEditingController(); + FocusNode _messageFocusNode = FocusNode(); - String _userNameQuery; - bool _isSearchActive = false; + String _channelNameQuery; - Set _selectedUsers = {}; + List _selectedChannels = []; bool _loading = false; Timer _debounce; @@ -80,8 +78,7 @@ class _ImageFooterState extends State { _debounce = Timer(const Duration(milliseconds: 350), () { if (mounted && modalSetStateCallback != null) { modalSetStateCallback(() { - _userNameQuery = _searchController.text; - _isSearchActive = _userNameQuery.isNotEmpty; + _channelNameQuery = _searchController.text; }); } }); @@ -91,6 +88,9 @@ class _ImageFooterState extends State { void initState() { super.initState(); _searchController = TextEditingController()..addListener(_userNameListener); + _messageFocusNode.addListener(() { + setState(() {}); + }); } @override @@ -251,7 +251,7 @@ class _ImageFooterState extends State { ); } - Widget _buildShareModal(context) { + void _buildShareModal(context) { showDialog( context: context, builder: (context) { @@ -259,7 +259,7 @@ class _ImageFooterState extends State { modalSetStateCallback = modalSetState; return Padding( padding: EdgeInsets.only( - top: _userSearchMode + top: _userSearchMode || _messageFocusNode.hasFocus ? 16.0 : MediaQuery.of(context).size.height / 2, left: 8.0, @@ -275,74 +275,81 @@ class _ImageFooterState extends State { child: Column( children: [ _buildTextInputSection(modalSetState), + if (_userSearchMode) + SizedBox( + height: 22.0, + ), Expanded( - child: UserListView( - selectedUsers: _selectedUsers, - onUserTap: (user, _) { - _searchController.clear(); - if (!_selectedUsers.contains(user)) { - modalSetState(() { - _selectedUsers.add(user); - }); - } else { - modalSetState(() { - _selectedUsers.remove(user); - }); - } - }, - crossAxisCount: 4, - pagination: PaginationParams( - limit: 25, - ), - filter: { - if (_searchController.text.isNotEmpty) - 'name': { - r'$autocomplete': _userNameQuery, - }, - 'id': { - r'$ne': StreamChat.of(context).user.id, + child: ChannelsBloc( + child: ChannelListView( + selectedChannels: _selectedChannels, + onChannelTap: (channel, _) { + _searchController.clear(); + if (!_selectedChannels.contains(channel)) { + modalSetState(() { + _selectedChannels.add(channel); + }); + } else { + modalSetState(() { + _selectedChannels.remove(channel); + }); + } }, - }, - sort: [ - SortOption( - 'name', - direction: 1, + crossAxisCount: 4, + pagination: PaginationParams( + limit: 25, ), - ], - 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.search( - size: 96, - color: Colors.grey, + filter: { + if (_searchController.text.isNotEmpty) + 'name': { + r'$autocomplete': _channelNameQuery, + }, + 'id': { + r'$ne': StreamChat.of(context).user.id, + }, + }, + sort: [ + SortOption( + 'name', + direction: 1, + ), + ], + 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.search( + size: 96, + color: Colors.grey, + ), ), - ), - Text( - 'No user matches these keywords...'), - ], + Text( + 'No chat matches these keywords...'), + ], + ), ), ), - ), - ); - }, - ); - }, + ); + }, + ); + }, + ), ), ), - if (_selectedUsers.isNotEmpty) + if (_selectedChannels.isNotEmpty) _buildShareTextInputSection(modalSetState), - if (!_userSearchMode && _selectedUsers.isEmpty) + if (!_userSearchMode && _selectedChannels.isEmpty) Align( alignment: Alignment.bottomCenter, child: Container( @@ -543,6 +550,7 @@ class _ImageFooterState extends State { padding: const EdgeInsets.only(left: 8.0), child: TextField( controller: _messageController, + focusNode: _messageFocusNode, onChanged: (val) { modalSetState(() {}); }, @@ -619,27 +627,16 @@ class _ImageFooterState extends State { _messageController.clear(); - final client = StreamChat.of(context).client; - - for (var user in _selectedUsers) { - var c = client.channel('messaging', extraData: { - 'members': [ - user.id, - StreamChat.of(context).user.id, - ], - }); - - await c.watch(); - + for (var channel in _selectedChannels) { final message = Message( text: text, attachments: [attachments[widget.currentPage]], ); - await c.sendMessage(message); + await channel.sendMessage(message); } - _selectedUsers.clear(); + _selectedChannels.clear(); Navigator.pop(context); } diff --git a/lib/src/user_avatar.dart b/lib/src/user_avatar.dart index 7411c994..fb9a832a 100644 --- a/lib/src/user_avatar.dart +++ b/lib/src/user_avatar.dart @@ -61,12 +61,15 @@ class UserAvatar extends StatelessWidget { : streamChatTheme.defaultUserImage(context, user), ), ); + if (selected) { avatar = ClipRRect( borderRadius: (borderRadius ?? streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + BorderRadius.circular(selectionThickness), child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, color: selectionColor, child: Padding( padding: EdgeInsets.all(selectionThickness),