diff --git a/example/lib/chips_input_text_field.dart b/example/lib/chips_input_text_field.dart new file mode 100644 index 00000000..317a68f7 --- /dev/null +++ b/example/lib/chips_input_text_field.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_icons.dart'; + +typedef ChipBuilder = Widget Function(BuildContext context, T chip); +typedef OnChipAdded = void Function(T chip); +typedef OnChipRemoved = void Function(T chip); + +class ChipsInputTextField extends StatefulWidget { + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onInputChanged; + final ChipBuilder chipBuilder; + final OnChipAdded onChipAdded; + final OnChipRemoved onChipRemoved; + final String hint; + + const ChipsInputTextField({ + Key key, + @required this.chipBuilder, + @required this.controller, + this.onInputChanged, + this.focusNode, + this.onChipAdded, + this.onChipRemoved, + this.hint = 'Type a name', + }) : super(key: key); + + @override + ChipInputTextFieldState createState() => ChipInputTextFieldState(); +} + +class ChipInputTextFieldState extends State> { + final _chips = {}; + bool _pauseItemAddition = false; + + void addItem(T item) { + setState(() => _chips.add(item)); + if (widget.onChipAdded != null) widget.onChipAdded(item); + } + + void removeItem(T item) { + setState(() { + _chips.remove(item); + if (_chips.isEmpty) resumeItemAddition(); + }); + if (widget.focusNode != null) widget.focusNode.requestFocus(); + if (widget.onChipRemoved != null) widget.onChipRemoved(item); + } + + void pauseItemAddition() { + if (!_pauseItemAddition) { + setState(() => _pauseItemAddition = true); + } + } + + void resumeItemAddition() { + if (_pauseItemAddition) { + setState(() => _pauseItemAddition = false); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _pauseItemAddition + ? () { + setState(() { + _pauseItemAddition = false; + widget.focusNode?.requestFocus(); + }); + } + : null, + child: Material( + elevation: 1, + color: Colors.white, + child: Container( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'TO:', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(0.5), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: _chips.map((item) { + return widget.chipBuilder(context, item); + }).toList(), + ), + if (!_pauseItemAddition) + TextField( + controller: widget.controller, + onChanged: widget.onInputChanged, + focusNode: widget.focusNode, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.only(top: 4.0), + hintText: widget.hint, + hintStyle: TextStyle( + color: Colors.black.withOpacity(0.5), + fontSize: 14, + ), + ), + ), + ], + ), + ), + SizedBox(width: 12), + Align( + alignment: Alignment.bottomCenter, + child: IconButton( + icon: Icon( + _chips.isEmpty + ? StreamIcons.user + : StreamIcons.user_add, + color: Colors.black.withOpacity(0.5), + size: 24, + ), + onPressed: + !_pauseItemAddition ? null : resumeItemAddition, + alignment: Alignment.topRight, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/group_chat_details_screen.dart b/example/lib/group_chat_details_screen.dart new file mode 100644 index 00000000..6aff5711 --- /dev/null +++ b/example/lib/group_chat_details_screen.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:uuid/uuid.dart'; + +import 'main.dart'; +import 'neumorphic_button.dart'; + +class GroupChatDetailsScreen extends StatefulWidget { + final List selectedUsers; + + const GroupChatDetailsScreen({ + Key key, + @required this.selectedUsers, + }) : super(key: key); + + @override + _GroupChatDetailsScreenState createState() => _GroupChatDetailsScreenState(); +} + +class _GroupChatDetailsScreenState extends State { + final _selectedUsers = []; + + TextEditingController _groupNameController; + + bool _isGroupNameEmpty = true; + + int get _totalUsers => _selectedUsers.length; + + void _groupNameListener() { + final name = _groupNameController.text; + if (mounted) { + setState(() { + _isGroupNameEmpty = name.isEmpty; + }); + } + } + + @override + void initState() { + super.initState(); + _selectedUsers.addAll(widget.selectedUsers); + _groupNameController = TextEditingController() + ..addListener(_groupNameListener); + } + + @override + void dispose() { + _groupNameController?.removeListener(_groupNameListener); + _groupNameController?.clear(); + _groupNameController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 1, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'Name of Group Chat', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + Text( + 'NAME', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(0.5), + ), + ), + SizedBox(width: 16), + Expanded( + child: TextField( + controller: _groupNameController, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.all(0), + hintText: 'Choose a group chat name', + hintStyle: TextStyle( + fontSize: 14, color: Colors.black.withOpacity(.5)), + ), + ), + ), + ], + ), + ), + ), + actions: [ + NeumorphicButton( + child: IconButton( + padding: const EdgeInsets.all(0), + icon: Icon(StreamIcons.check), + color: Color(0xFF006CFF), + onPressed: _isGroupNameEmpty + ? null + : () async { + final groupName = _groupNameController.text; + final client = StreamChat.of(context).client; + final channel = client + .channel('messaging', id: Uuid().v4(), extraData: { + 'members': [ + client.state.user.id, + ..._selectedUsers.map((e) => e.id), + ], + 'name': groupName, + }); + await channel.watch(); + Navigator.of(context) + ..pop() + ..pushReplacement( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: ChannelPage(), + channel: channel, + ); + }, + ), + ); + }, + ), + ), + ], + ), + body: Column( + children: [ + Container( + width: double.maxFinite, + color: Colors.grey.shade50, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + '$_totalUsers ${_totalUsers > 1 ? 'Members' : 'Member'}', + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ), + ), + Expanded( + child: ListView.separated( + itemCount: _selectedUsers.length + 1, + separatorBuilder: (_, __) => Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ), + itemBuilder: (_, index) { + if (index == _selectedUsers.length) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ); + } + final user = _selectedUsers[index]; + return ListTile( + key: ObjectKey(user), + leading: UserAvatar( + user: user, + constraints: BoxConstraints.tightFor( + width: 40, + height: 40, + ), + ), + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + trailing: IconButton( + icon: Icon( + Icons.clear_rounded, + color: Colors.black, + ), + padding: const EdgeInsets.all(0), + splashRadius: 24, + onPressed: () { + setState(() { + _selectedUsers.remove(user); + }); + if (_selectedUsers.isEmpty) { + Navigator.pop(context); + } + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 500b6059..a7d5e473 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:example/choose_user_page.dart'; +import 'package:example/new_chat_screen.dart'; +import 'package:example/new_group_chat_screen.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -67,34 +69,34 @@ class ChannelListPage extends StatelessWidget { Widget build(BuildContext context) { final user = StreamChat.of(context).user; return Scaffold( - drawerEnableOpenDragGesture: true, - drawerEdgeDragWidth: 50, - drawer: _buildDrawer(context, user), - floatingActionButton: FloatingActionButton( - child: Icon(Icons.add), - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return CreateChannelPage(); - })); - }, + appBar: AppBar( + backgroundColor: Colors.white, + title: Text( + 'Stream Chat', + style: TextStyle(color: Colors.black), + ), ), + drawer: _buildDrawer(context, user), + drawerEdgeDragWidth: 50, body: ChannelsBloc( child: ChannelListView( onStartChatPressed: () { Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return CreateChannelPage(); + return NewChatScreen(); })); }, swipeToAction: true, filter: { 'members': { '\$in': [user.id], - } + }, + 'draft': { + r'$ne': true, + }, }, options: { 'presence': true, }, - sort: [SortOption('last_message_at')], pagination: PaginationParams( limit: 20, ), @@ -140,6 +142,13 @@ class ChannelListPage extends StatelessWidget { ), ListTile( leading: Icon(StreamIcons.edit), + onTap: () { + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (context) { + return NewChatScreen(); + })); + }, title: Text( 'New direct message', style: TextStyle( @@ -149,6 +158,13 @@ class ChannelListPage extends StatelessWidget { ), ListTile( leading: Icon(StreamIcons.group), + onTap: () { + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (context) { + return NewGroupChatScreen(); + })); + }, title: Text( 'New group', style: TextStyle( @@ -263,194 +279,3 @@ class ThreadPage extends StatelessWidget { ); } } - -class CreateChannelPage extends StatefulWidget { - @override - _CreateChannelPageState createState() => _CreateChannelPageState(); -} - -class _CreateChannelPageState extends State { - final ScrollController _scrollController = ScrollController(); - Client client; - List users = []; - List selectedUsers = []; - int offset = 0; - bool loading = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: Colors.transparent, - title: Text( - 'Create a channel', - style: Theme.of(context).textTheme.headline6, - ), - ), - floatingActionButton: - selectedUsers.isNotEmpty ? _buildFAB(context) : SizedBox(), - body: _buildListView(), - ); - } - - ListView _buildListView() { - return ListView.builder( - controller: _scrollController, - itemBuilder: _itemBuilder, - itemCount: users.length, - ); - } - - Widget _itemBuilder(context, i) { - final user = users[i]; - return ListTile( - onLongPress: () { - _selectUser(user); - }, - selected: selectedUsers.contains(user), - onTap: () { - if (selectedUsers.isNotEmpty) { - return _selectUser(user); - } - _createChannel(context, [user]); - }, - leading: UserAvatar( - user: user, - ), - title: Text(user.name), - ); - } - - Widget _buildFAB(BuildContext context) { - return FloatingActionButton( - child: Icon(Icons.done), - onPressed: () async { - String name; - if (selectedUsers.length > 1) { - name = await _showEnterNameDialog(context); - if (name?.isNotEmpty != true) { - return; - } - } - - _createChannel(context, selectedUsers, name); - }, - ); - } - - Future _showEnterNameDialog(BuildContext context) { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (context) => SimpleDialog( - contentPadding: const EdgeInsets.all(16), - title: Text('Enter a name for the channel'), - children: [ - TextField( - controller: controller, - decoration: InputDecoration( - border: OutlineInputBorder(), - ), - ), - ButtonBar( - children: [ - FlatButton( - onPressed: () => Navigator.pop(context), - child: Text('Cancel'), - ), - FlatButton( - onPressed: () => Navigator.pop(context, controller.text), - child: Text('Ok'), - ), - ], - ), - ], - ), - ); - } - - Future _createChannel( - BuildContext context, - List users, [ - String name, - ]) async { - final channel = client.channel('messaging', extraData: { - 'members': [ - client.state.user.id, - ...users.map((e) => e.id), - ], - if (name != null) 'name': name, - }); - await channel.watch(); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - child: ChannelPage(), - channel: channel, - ); - }, - ), - ); - } - - void _selectUser(User user) { - if (!selectedUsers.contains(user)) { - setState(() { - selectedUsers.add(user); - }); - } else { - setState(() { - selectedUsers.remove(user); - }); - } - } - - @override - void initState() { - super.initState(); - - client = StreamChat.of(context).client; - - _scrollController.addListener(() async { - if (!loading && - _scrollController.offset >= - _scrollController.position.maxScrollExtent - 100) { - offset += 25; - await _queryUsers(); - } - }); - - _queryUsers(); - } - - Future _queryUsers() { - loading = true; - return client.queryUsers( - pagination: PaginationParams( - limit: 25, - offset: offset, - ), - filter: { - 'id': { - r'$ne': client.state.user.id, - } - }, - sort: [ - SortOption( - 'name', - direction: SortOption.ASC, - ), - ], - ).then((value) { - setState(() { - users = [ - ...users, - ...value.users, - ]; - }); - }).whenComplete(() => loading = false); - } -} diff --git a/example/lib/neumorphic_button.dart b/example/lib/neumorphic_button.dart new file mode 100644 index 00000000..69454fb2 --- /dev/null +++ b/example/lib/neumorphic_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class NeumorphicButton extends StatelessWidget { + final Widget child; + final Color backgroundColor; + + const NeumorphicButton({ + Key key, + @required this.child, + this.backgroundColor = Colors.white, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: child, + margin: EdgeInsets.all(8.0), + height: 40, + width: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.grey[700], + offset: Offset(0, 1.0), + blurRadius: 0.5, + spreadRadius: 0, + ), + BoxShadow( + color: Colors.white, + offset: Offset.zero, + blurRadius: 0.5, + spreadRadius: 0, + ), + ], + ), + ); + } +} diff --git a/example/lib/new_chat_screen.dart b/example/lib/new_chat_screen.dart new file mode 100644 index 00000000..9ec2ca7e --- /dev/null +++ b/example/lib/new_chat_screen.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'chips_input_text_field.dart'; +import 'main.dart'; +import 'neumorphic_button.dart'; +import 'new_group_chat_screen.dart'; + +class NewChatScreen extends StatefulWidget { + @override + _NewChatScreenState createState() => _NewChatScreenState(); +} + +class _NewChatScreenState extends State { + final _chipInputTextFieldStateKey = + GlobalKey>(); + + TextEditingController _controller; + + ChipInputTextFieldState get _chipInputTextFieldState => + _chipInputTextFieldStateKey.currentState; + + String _userNameQuery = ''; + + final _selectedUsers = {}; + + final _searchFocusNode = FocusNode(); + final _messageInputFocusNode = FocusNode(); + + bool _isSearchActive = false; + + Channel channel; + + Timer _debounce; + + bool _showUserList = true; + + void _userNameListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) + setState(() { + _userNameQuery = _controller.text; + _isSearchActive = _userNameQuery.isNotEmpty; + }); + }); + } + + @override + void initState() { + super.initState(); + channel = StreamChat.of(context).client.channel('messaging'); + _controller = TextEditingController()..addListener(_userNameListener); + + _searchFocusNode.addListener(() async { + if (_searchFocusNode.hasFocus && !_showUserList) { + if (channel.extraData['draft'] == true) { + await channel.stopWatching(); + channel.dispose(); + channel.client.state.channels.remove(channel.cid); + } + setState(() { + _showUserList = true; + }); + } + }); + + _messageInputFocusNode.addListener(() async { + if (_messageInputFocusNode.hasFocus && _selectedUsers.isNotEmpty) { + final chatState = StreamChat.of(context); + + channel = chatState.client.channel( + 'messaging', + extraData: { + 'members': [ + ..._selectedUsers.map((e) => e.id), + chatState.user.id, + ], + 'draft': true, + }, + ); + + if (!chatState.client.state.channels.containsKey(channel.cid)) { + await channel.watch(); + } + + setState(() { + _showUserList = false; + }); + } + }); + } + + @override + void dispose() { + _searchFocusNode.dispose(); + _messageInputFocusNode.dispose(); + _controller?.clear(); + _controller?.removeListener(_userNameListener); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'New Chat', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + ), + body: StreamChannel( + showLoading: false, + channel: channel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChipsInputTextField( + key: _chipInputTextFieldStateKey, + controller: _controller, + focusNode: _searchFocusNode, + chipBuilder: (context, user) { + return GestureDetector( + onTap: () { + _chipInputTextFieldState.removeItem(user); + }, + child: Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.only(left: 24), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 12, 4), + child: Text( + user.name, + style: TextStyle(color: Colors.black), + ), + ), + ), + Opacity( + opacity: .8, + child: UserAvatar( + showOnlineStatus: false, + user: user, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + ), + ), + Positioned( + child: Icon( + StreamIcons.close, + color: Colors.white, + ), + ), + ], + ), + ); + }, + onChipAdded: (user) { + setState(() => _selectedUsers.add(user)); + }, + onChipRemoved: (user) { + setState(() => _selectedUsers.remove(user)); + }, + ), + if (!_isSearchActive && !_selectedUsers.isNotEmpty) + Container( + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => NewGroupChatScreen()), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + NeumorphicButton( + child: Icon( + StreamIcons.group, + color: Color(0xFF006CFF), + ), + ), + SizedBox(width: 8), + Text( + 'Create a Group', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + if (_showUserList) + 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( + _isSearchActive + ? "Matches for \"$_userNameQuery\"" + : 'On the platform', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + Expanded( + child: _showUserList + ? UsersBloc( + child: UserListView( + selectedUsers: _selectedUsers, + groupAlphabetically: _isSearchActive ? false : true, + onUserTap: (user, _) { + _controller.clear(); + if (!_selectedUsers.contains(user)) { + _chipInputTextFieldState + ..addItem(user) + ..pauseItemAddition(); + } else { + _chipInputTextFieldState.removeItem(user); + } + }, + pagination: PaginationParams( + limit: 25, + ), + filter: { + if (_userNameQuery.isNotEmpty) + 'name': { + r'$autocomplete': _userNameQuery, + }, + '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: Icon( + StreamIcons.search, + size: 96, + color: Colors.grey, + ), + ), + Text( + 'No user matches these keywords...'), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ) + : MessageListView(), + ), + MessageInput( + focusNode: _messageInputFocusNode, + onMessageSent: (m) { + if (!m.isEphemeral) { + _updateChannelAndNavigate(context); + } else { + channel.on('message.new').first.then((_) { + _updateChannelAndNavigate(context); + }); + } + }, + ), + ], + ), + ), + ); + } + + void _updateChannelAndNavigate(BuildContext context) { + channel.update({ + 'draft': false, + }); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: ChannelPage(), + channel: channel, + ); + }, + ), + ); + } +} diff --git a/example/lib/new_group_chat_screen.dart b/example/lib/new_group_chat_screen.dart new file mode 100644 index 00000000..b75e7510 --- /dev/null +++ b/example/lib/new_group_chat_screen.dart @@ -0,0 +1,285 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'group_chat_details_screen.dart'; + +class NewGroupChatScreen extends StatefulWidget { + @override + _NewGroupChatScreenState createState() => _NewGroupChatScreenState(); +} + +class _NewGroupChatScreenState extends State { + TextEditingController _controller; + + String _userNameQuery = ''; + + final _selectedUsers = {}; + + bool _isSearchActive = false; + + Timer _debounce; + + void _userNameListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) + setState(() { + _userNameQuery = _controller.text; + _isSearchActive = _userNameQuery.isNotEmpty; + }); + }); + } + + @override + void initState() { + super.initState(); + _controller = TextEditingController()..addListener(_userNameListener); + } + + @override + void dispose() { + _controller?.clear(); + _controller?.removeListener(_userNameListener); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 1, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'Add Group Members', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + actions: [ + if (_selectedUsers.isNotEmpty) + IconButton( + icon: Icon( + StreamIcons.arrow_right, + color: Color(0xFF006CFF), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GroupChatDetailsScreen( + selectedUsers: _selectedUsers.toList(growable: false), + ), + ), + ); + }, + ) + ], + ), + body: UsersBloc( + child: Column( + children: [ + Container( + height: 36, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: Colors.grey.shade300, + ), + borderRadius: BorderRadius.circular(24), + ), + margin: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: TextField( + controller: _controller, + decoration: InputDecoration( + prefixIcon: Icon( + StreamIcons.search, + color: Colors.black, + size: 24, + ), + hintText: 'Search', + hintStyle: TextStyle( + color: Colors.black.withOpacity(0.5), + fontSize: 14, + ), + contentPadding: const EdgeInsets.all(0), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + if (_selectedUsers.isNotEmpty) + Container( + height: 104, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _selectedUsers.length, + padding: const EdgeInsets.all(8), + separatorBuilder: (_, __) => SizedBox(width: 16), + itemBuilder: (_, index) { + final user = _selectedUsers.elementAt(index); + return Column( + children: [ + Stack( + children: [ + UserAvatar( + onlineIndicatorAlignment: Alignment(0.9, 0.9), + user: user, + showOnlineStatus: true, + borderRadius: BorderRadius.circular(32), + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + ), + Positioned( + top: -4, + right: -4, + child: GestureDetector( + onTap: () { + if (_selectedUsers.contains(user)) { + setState(() => _selectedUsers.remove(user)); + } + }, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: Colors.grey.shade100, + ), + ), + child: Padding( + padding: const EdgeInsets.all(0.0), + child: Icon( + StreamIcons.close, + size: 24, + ), + ), + ), + ), + ) + ], + ), + SizedBox(height: 4), + Text( + user.name.split(' ')[0], + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ); + }, + ), + ), + 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( + _isSearchActive + ? 'Matches for \"$_userNameQuery\"' + : 'On the platform', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + Expanded( + child: UserListView( + selectedUsers: _selectedUsers, + groupAlphabetically: _isSearchActive ? false : true, + onUserTap: (user, _) { + if (!_selectedUsers.contains(user)) { + setState(() { + _selectedUsers.add(user); + }); + } else { + setState(() { + _selectedUsers.remove(user); + }); + } + }, + pagination: PaginationParams( + limit: 25, + ), + filter: { + if (_userNameQuery.isNotEmpty) + 'name': { + r'$autocomplete': _userNameQuery, + }, + '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: Icon( + StreamIcons.search, + size: 96, + color: Colors.grey, + ), + ), + Text('No user matches these keywords...'), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 0fe6a0ad..e5daa043 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.62+64 +version: 1.0.66+68 environment: sdk: ">=2.2.2 <3.0.0" @@ -15,6 +15,7 @@ dependencies: flutter_svg: ^0.19.1 flutter_secure_storage: ^3.3.5 yaml: ^2.2.1 + uuid: ^2.2.2 dev_dependencies: flutter_test: diff --git a/lib/src/channels_bloc.dart b/lib/src/channels_bloc.dart index 3af2300d..474d9599 100644 --- a/lib/src/channels_bloc.dart +++ b/lib/src/channels_bloc.dart @@ -31,7 +31,7 @@ class ChannelsBloc extends StatefulWidget { streamChatState = context.findAncestorStateOfType(); if (streamChatState == null) { - throw Exception('You must have a ChannelsBloc widget as anchestor'); + throw Exception('You must have a ChannelsBloc widget as ancestor'); } return streamChatState; diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index cd96119a..6845d1a4 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -104,6 +104,7 @@ class MessageInput extends StatefulWidget { this.actions, this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, + this.focusNode, }) : super(key: key); /// Message to edit @@ -149,6 +150,9 @@ class MessageInput extends StatefulWidget { /// Map that defines a thumbnail builder for an attachment type final Map attachmentThumbnailBuilders; + /// The focus node associated to the TextField + final FocusNode focusNode; + @override MessageInputState createState() => MessageInputState(); @@ -169,10 +173,10 @@ class MessageInput extends StatefulWidget { class MessageInputState extends State { final List<_SendingAttachment> _attachments = []; - final _focusNode = FocusNode(); final List _mentionedUsers = []; final _imagePicker = ImagePicker(); + FocusNode _focusNode; bool _inputEnabled = true; bool _messageIsPresent = false; bool _animateContainer = true; @@ -423,7 +427,7 @@ class MessageInputState extends State { } void _onChanged(BuildContext context, String s) { - StreamChannel.of(context).channel.keyStroke(); + StreamChannel.of(context).channel.keyStroke().catchError((e) {}); setState(() { _messageIsPresent = s.trim().isNotEmpty; @@ -1708,11 +1712,15 @@ class MessageInputState extends State { void initState() { super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + _emojiNames = Emoji.all().map((e) => e.name); if (!kIsWeb) { _keyboardListener = KeyboardVisibility.onChange.listen((visible) { - _onChanged(context, textEditingController.text); + if (_focusNode.hasFocus) { + _onChanged(context, textEditingController.text); + } }); } diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 2a60b75b..4a5e445b 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -180,7 +180,26 @@ class _MessageListViewState extends State { ? streamChannel.channel.state.threads[widget.parentMessage.id] : streamChannel.channel.state.messages, builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + final messages = snapshot.data?.reversed?.toList() ?? []; + + if (messages.isEmpty) { + return Center( + child: Text( + 'No chats here yet...', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(.5), + ), + ), + ); + } + return Stack( alignment: Alignment.center, children: [ diff --git a/lib/src/stream_channel.dart b/lib/src/stream_channel.dart index ce19b261..6fafa070 100644 --- a/lib/src/stream_channel.dart +++ b/lib/src/stream_channel.dart @@ -12,12 +12,14 @@ class StreamChannel extends StatefulWidget { Key key, @required this.child, @required this.channel, + this.showLoading = true, }) : super( key: key, ); final Widget child; final Channel channel; + final bool showLoading; /// Use this method to get the current [StreamChannelState] instance static StreamChannelState of(BuildContext context) { @@ -155,7 +157,7 @@ class StreamChannelState extends State { future: widget.channel.initialized, initialData: widget.channel.state != null, builder: (context, snapshot) { - if (!snapshot.hasData || !snapshot.data) { + if (widget.showLoading && (!snapshot.hasData || !snapshot.data)) { return Container( height: 30, child: Center( diff --git a/lib/src/url_attachment.dart b/lib/src/url_attachment.dart index a873217f..d735ad67 100644 --- a/lib/src/url_attachment.dart +++ b/lib/src/url_attachment.dart @@ -4,9 +4,9 @@ import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class UrlAttachment extends StatelessWidget { - Attachment urlAttachment; - String hostDisplayName; - EdgeInsets textPadding; + final Attachment urlAttachment; + final String hostDisplayName; + final EdgeInsets textPadding; UrlAttachment({ @required this.urlAttachment, diff --git a/lib/src/user_avatar.dart b/lib/src/user_avatar.dart index 66edba39..2e1f3e0e 100644 --- a/lib/src/user_avatar.dart +++ b/lib/src/user_avatar.dart @@ -11,16 +11,26 @@ class UserAvatar extends StatelessWidget { this.constraints, this.onlineIndicatorConstraints, this.onTap, + this.onLongPress, this.showOnlineStatus = true, this.borderRadius, + this.onlineIndicatorAlignment = Alignment.topRight, + this.selected = false, + this.selectionColor = const Color(0xFF006CFF), + this.selectionThickness = 4, }) : super(key: key); final User user; + final Alignment onlineIndicatorAlignment; final BoxConstraints constraints; final BorderRadius borderRadius; final BoxConstraints onlineIndicatorConstraints; final void Function(User) onTap; + final void Function(User) onLongPress; final bool showOnlineStatus; + final bool selected; + final Color selectionColor; + final double selectionThickness; @override Widget build(BuildContext context) { @@ -28,43 +38,54 @@ class UserAvatar extends StatelessWidget { user.extraData['image'] != null && user.extraData['image'] != ''; final streamChatTheme = StreamChatTheme.of(context); + + Widget avatar = ClipRRect( + borderRadius: borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + decoration: BoxDecoration( + color: streamChatTheme.accentColor, + ), + child: hasImage + ? CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: user.extraData['image'], + errorWidget: (_, __, ___) { + return streamChatTheme.defaultUserImage(context, user); + }, + fit: BoxFit.cover, + ) + : streamChatTheme.defaultUserImage(context, user), + ), + ); + if (selected) { + avatar = ClipRRect( + borderRadius: (borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + color: selectionColor, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } return GestureDetector( - onTap: onTap != null - ? () { - if (onTap != null) { - onTap(user); - } - } - : null, + onTap: onTap != null ? () => onTap(user) : null, + onLongPress: onLongPress != null ? () => onLongPress(user) : null, child: Stack( children: [ - ClipRRect( - borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, - child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, - decoration: BoxDecoration( - color: streamChatTheme.accentColor, - ), - child: hasImage - ? CachedNetworkImage( - filterQuality: FilterQuality.high, - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return streamChatTheme.defaultUserImage(context, user); - }, - fit: BoxFit.cover, - ) - : streamChatTheme.defaultUserImage(context, user), - ), - ), + avatar, if (showOnlineStatus && user.online == true) - Positioned( - top: 0, - right: 0, - child: Material( - child: Center( + Positioned.fill( + child: Align( + alignment: onlineIndicatorAlignment, + child: Material( + type: MaterialType.circle, child: Container( padding: const EdgeInsets.all(2.0), constraints: onlineIndicatorConstraints ?? @@ -77,9 +98,8 @@ class UserAvatar extends StatelessWidget { color: Color(0xff20E070), ), ), + color: Colors.white, ), - shape: CircleBorder(), - color: Colors.white, ), ), ], diff --git a/lib/src/user_item.dart b/lib/src/user_item.dart new file mode 100644 index 00000000..06ab0848 --- /dev/null +++ b/lib/src/user_item.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/user_list_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'stream_chat_theme.dart'; + +/// +/// It shows the current [User] preview. +/// +/// The widget uses a [StreamBuilder] to render the user information image as soon as it updates. +/// +/// Usually you don't use this widget as it's the default user preview used by [UserListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class UserItem extends StatelessWidget { + /// Instantiate a new UserItem + const UserItem({ + Key key, + @required this.user, + this.onTap, + this.onLongPress, + this.onImageTap, + this.selected = false, + this.showLastOnline = true, + }) : super(key: key); + + /// Function called when tapping this widget + final void Function(User) onTap; + + /// Function called when long pressing this widget + final void Function(User) onLongPress; + + /// User displayed + final User user; + + /// The function called when the image is tapped + final void Function(User) onImageTap; + + /// If true the [UserItem] will show a trailing checkmark + final bool selected; + + /// If true the [UserItem] will show the last seen + final bool showLastOnline; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + if (onTap != null) { + onTap(user); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(user); + } + }, + leading: UserAvatar( + user: user, + showOnlineStatus: true, + onTap: (user) { + if (onImageTap != null) { + onImageTap(user); + } + }, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + trailing: selected + ? CircleAvatar( + child: Icon( + StreamIcons.check, + size: 20, + ), + radius: 10, + ) + : null, + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: showLastOnline ? _buildLastActive(context) : null, + ); + } + + Widget _buildLastActive(context) { + return Text('Last online ${Jiffy(user.lastActive).fromNow()}'); + } +} diff --git a/lib/src/user_list_view.dart b/lib/src/user_list_view.dart new file mode 100644 index 00000000..63285d8b --- /dev/null +++ b/lib/src/user_list_view.dart @@ -0,0 +1,578 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.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 +typedef UserTapCallback = void Function(User, Widget); + +/// Builder used to create a custom [ListUserItem] from a [User] +typedef UserItemBuilder = Widget Function(BuildContext, User, bool); + +/// +/// It shows the list of current users. +/// +/// ```dart +/// class UsersListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: UsersListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// 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. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class UserListView extends StatefulWidget { + /// Instantiate a new UserListView + const UserListView({ + Key key, + this.errorBuilder, + this.emptyBuilder, + this.filter, + this.options, + this.sort, + this.pagination, + this.onUserTap, + this.onUserLongPress, + this.userWidget, + this.userItemBuilder, + this.separatorBuilder, + this.onImageTap, + this.selectedUsers, + this.swipeToAction = false, + this.pullToRefresh = true, + this.groupAlphabetically = false, + this.crossAxisCount = 1, + }) : assert( + crossAxisCount == 1 || groupAlphabetically == false, + 'Cannot group alphabetically when crossAxisCount > 1', + ), + super(key: key); + + /// 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; + + /// 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 filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// 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 sort; + + /// 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 pagination; + + /// Function called when tapping on a channel + /// By default it calls [Navigator.push] building a [MaterialPageRoute] + /// with the widget [userWidget] as child. + final UserTapCallback onUserTap; + + /// Function called when long pressing on a channel + final Function(User) onUserLongPress; + + /// Widget used when opening a channel + final Widget userWidget; + + /// Builder used to create a custom user preview + final UserItemBuilder userItemBuilder; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// The function called when the image is tapped + final Function(User) onImageTap; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + /// Sets a blue trailing checkMark in [ListUserItem] for all the [selectedUsers] + final Set selectedUsers; + + /// Set it to true to group users by their first character + /// + /// defaults to false + final bool groupAlphabetically; + + /// The number of children in the cross axis. + final int crossAxisCount; + + @override + _UserListViewState createState() => _UserListViewState(); +} + +class _UserListViewState extends State + with WidgetsBindingObserver { + final ScrollController _scrollController = ScrollController(); + + bool get _isListView => widget.crossAxisCount == 1; + + @override + void initState() { + super.initState(); + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + + _scrollController.addListener(() { + usersBloc.queryUsersLoading.first.then((loading) { + if (!loading) { + _listenUserPagination(usersBloc); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final usersBloc = UsersBloc.of(context); + + if (!widget.pullToRefresh) { + return _buildListView(usersBloc); + } + + return RefreshIndicator( + onRefresh: () async { + return usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + options: widget.options, + pagination: widget.pagination, + ); + }, + child: _buildListView(usersBloc), + ); + } + + bool get isListAlreadySorted => + widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; + + Stream> _buildUserStream( + UsersBlocState usersBlocState, + ) { + return usersBlocState.usersStream.map( + (users) { + if (widget.groupAlphabetically) { + var temp = users; + if (!isListAlreadySorted) { + temp = users..sort((curr, next) => curr.name.compareTo(next.name)); + } + final groupedUsers = >{}; + for (var e in temp) { + final alphabet = e.name[0]; + groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; + } + final items = []; + for (var key in groupedUsers.keys) { + items.add(ListHeaderItem(key)); + items.addAll(groupedUsers[key].map((e) => ListUserItem(e))); + } + return items; + } + return users.map((e) => ListUserItem(e)).toList(); + }, + ); + } + + StreamBuilder> _buildListView( + UsersBlocState usersBlocState, + ) { + return StreamBuilder( + stream: _buildUserStream(usersBlocState), + 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 channels'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + Padding( + padding: const EdgeInsets.only( + top: 16.0, + ), + child: Text(message), + ), + FlatButton( + onPressed: () { + usersBlocState.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + }, + 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 users currently'), + ), + ), + ); + }, + ); + } + + if (_isListView) { + return ListView.custom( + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + return _listItemBuilder(context, i, items); + }, + childCount: (items.length * 2) + 1, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = + items.indexWhere((item) => item.key == valueKey.value); + return index != -1 ? (index * 2) : null; + }, + ), + ); + } + return GridView.custom( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + ), + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + return _gridItemBuilder(context, i, items); + }, + childCount: items.length, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = + items.indexWhere((item) => item.key == valueKey.value); + return index != -1 ? index : null; + }, + ), + ); + }, + ); + } + + Widget _listItemBuilder(BuildContext context, int i, List items) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); + } + + i = i ~/ 2; + + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (header) { + return Container( + key: ValueKey('HEADER-$header'), + color: Colors.black.withOpacity(0.05), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6), + child: Text( + header, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.black.withOpacity(0.3)), + ), + ), + ); + }, + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : UserItem( + user: user, + onTap: (user) => widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + onImageTap: widget.onImageTap, + selected: selected, + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _gridItemBuilder(BuildContext context, int i, List items) { + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (_) => Offstage(), + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + UserAvatar( + user: user, + borderRadius: BorderRadius.circular(32), + selected: selected, + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + onTap: (user) => + widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + ), + SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + user.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _buildQueryProgressIndicator(context, UsersBlocState usersProvider) { + return StreamBuilder( + stream: usersProvider.queryUsersLoading, + 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 users'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _separatorBuilder(context, i) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ); + } + + void _listenUserPagination(UsersBlocState usersProvider) { + if (_scrollController.position.maxScrollExtent == + _scrollController.offset && + _scrollController.offset != 0) { + usersProvider.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination.copyWith( + offset: usersProvider.users?.length ?? 0, + ), + options: widget.options, + ); + } + } + + @override + void didUpdateWidget(UserListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + } + } +} + +abstract class ListItem { + String get key { + if (this is ListHeaderItem) { + final header = (this as ListHeaderItem).heading; + return 'HEADER-$header'; + } + if (this is ListUserItem) { + final user = (this as ListUserItem).user; + return 'USER-${user.id}'; + } + } + + Widget when({ + @required Widget Function(String heading) headerItem, + @required Widget Function(User user) userItem, + }) { + if (this is ListHeaderItem) { + return headerItem((this as ListHeaderItem).heading); + } + if (this is ListUserItem) { + return userItem((this as ListUserItem).user); + } + } +} + +class ListHeaderItem extends ListItem { + final String heading; + + ListHeaderItem(this.heading); +} + +class ListUserItem extends ListItem { + final User user; + + ListUserItem(this.user); +} diff --git a/lib/src/users_bloc.dart b/lib/src/users_bloc.dart new file mode 100644 index 00000000..91cbaf80 --- /dev/null +++ b/lib/src/users_bloc.dart @@ -0,0 +1,108 @@ +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 users list with pagination +class UsersBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Instantiate a new UsersBloc + const UsersBloc({ + Key key, + @required this.child, + }) : super(key: key); + + @override + UsersBlocState createState() => UsersBlocState(); + + /// Use this method to get the current [UsersBlocState] instance + static UsersBlocState of(BuildContext context) { + UsersBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a UsersBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [UsersBloc] +class UsersBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current users list + List get users => _usersController.value; + + /// The current users list as a stream + Stream> get usersStream => _usersController.stream; + + final BehaviorSubject> _usersController = BehaviorSubject(); + + final BehaviorSubject _queryUsersLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryUsersLoading => _queryUsersLoadingController.stream; + + /// Calls [Client.queryUsers] updating [queryUsersLoading] stream + Future queryUsers({ + Map filter, + List sort, + Map options, + PaginationParams pagination, + }) async { + final client = StreamChat.of(context).client; + + if (client.state?.user == null || + _queryUsersLoadingController.value == true) { + return; + } + _queryUsersLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldUsers = List.from(users ?? []); + + final usersResponse = await client.queryUsers( + filter: filter, + sort: sort, + options: options, + pagination: pagination, + ); + + if (clear) { + _usersController.add(usersResponse.users); + } else { + final temp = oldUsers + usersResponse.users; + _usersController.add(temp); + } + + _queryUsersLoadingController.add(false); + } catch (err, stackTrace) { + _queryUsersLoadingController.addError(err, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _usersController.close(); + _queryUsersLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index e433e58e..91ac68c8 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -30,3 +30,6 @@ export 'src/typing_indicator.dart'; export 'src/user_avatar.dart'; export 'src/utils.dart'; export 'src/video_attachment.dart'; +export 'src/users_bloc.dart'; +export 'src/user_list_view.dart'; +export 'src/user_item.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 4a4bfa2e..c43eea9c 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 + stream_chat: ^0.2.13+1 mime: ^0.9.6+3 visibility_detector: ^0.1.5 http_parser: ^3.1.4