Merge pull request #142 from GetStream/feature/new-chat-screens

Feature/new chat screens
This commit is contained in:
Salvatore Giordano
2020-11-23 10:44:41 +01:00
committed by GitHub
18 changed files with 1958 additions and 249 deletions
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/stream_icons.dart';
typedef ChipBuilder<T> = Widget Function(BuildContext context, T chip);
typedef OnChipAdded<T> = void Function(T chip);
typedef OnChipRemoved<T> = void Function(T chip);
class ChipsInputTextField<T> extends StatefulWidget {
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onInputChanged;
final ChipBuilder<T> chipBuilder;
final OnChipAdded<T> onChipAdded;
final OnChipRemoved<T> 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<T> createState() => ChipInputTextFieldState<T>();
}
class ChipInputTextFieldState<T> extends State<ChipsInputTextField<T>> {
final _chips = <T>{};
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,
),
),
),
],
),
),
),
),
),
);
}
}
+220
View File
@@ -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<User> selectedUsers;
const GroupChatDetailsScreen({
Key key,
@required this.selectedUsers,
}) : super(key: key);
@override
_GroupChatDetailsScreenState createState() => _GroupChatDetailsScreenState();
}
class _GroupChatDetailsScreenState extends State<GroupChatDetailsScreen> {
final _selectedUsers = <User>[];
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);
}
},
),
);
},
),
),
],
),
);
}
}
+29 -204
View File
@@ -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<CreateChannelPage> {
final ScrollController _scrollController = ScrollController();
Client client;
List<User> users = [];
List<User> 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<String> _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<User> 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<void> _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);
}
}
+40
View File
@@ -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,
),
],
),
);
}
}
+346
View File
@@ -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<NewChatScreen> {
final _chipInputTextFieldStateKey =
GlobalKey<ChipInputTextFieldState<User>>();
TextEditingController _controller;
ChipInputTextFieldState get _chipInputTextFieldState =>
_chipInputTextFieldStateKey.currentState;
String _userNameQuery = '';
final _selectedUsers = <User>{};
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<User>(
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,
);
},
),
);
}
}
+285
View File
@@ -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<NewGroupChatScreen> {
TextEditingController _controller;
String _userNameQuery = '';
final _selectedUsers = <User>{};
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...'),
],
),
),
),
);
},
);
},
),
),
],
),
),
);
}
}
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -31,7 +31,7 @@ class ChannelsBloc extends StatefulWidget {
streamChatState = context.findAncestorStateOfType<ChannelsBlocState>();
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;
+11 -3
View File
@@ -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<String, AttachmentThumbnailBuilder> 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<MessageInput> {
final List<_SendingAttachment> _attachments = [];
final _focusNode = FocusNode();
final List<User> _mentionedUsers = [];
final _imagePicker = ImagePicker();
FocusNode _focusNode;
bool _inputEnabled = true;
bool _messageIsPresent = false;
bool _animateContainer = true;
@@ -423,7 +427,7 @@ class MessageInputState extends State<MessageInput> {
}
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<MessageInput> {
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);
}
});
}
+19
View File
@@ -180,7 +180,26 @@ class _MessageListViewState extends State<MessageListView> {
? 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: [
+3 -1
View File
@@ -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<StreamChannel> {
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(
+3 -3
View File
@@ -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,
+55 -35
View File
@@ -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: <Widget>[
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,
),
),
],
+94
View File
@@ -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()}');
}
}
+578
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<SortOption> 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<User> 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<UserListView>
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<List<ListItem>> _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 = <String, List<User>>{};
for (var e in temp) {
final alphabet = e.name[0];
groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e];
}
final items = <ListItem>[];
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<List<ListItem>> _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: <Widget>[
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<String> 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<String> valueKey = key;
final index =
items.indexWhere((item) => item.key == valueKey.value);
return index != -1 ? index : null;
},
),
);
},
);
}
Widget _listItemBuilder(BuildContext context, int i, List<ListItem> 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<String>('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<String>('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<ListItem> 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<String>('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<bool>(
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);
}
+108
View File
@@ -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<UsersBlocState>();
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<UsersBloc>
with AutomaticKeepAliveClientMixin {
/// The current users list
List<User> get users => _usersController.value;
/// The current users list as a stream
Stream<List<User>> get usersStream => _usersController.stream;
final BehaviorSubject<List<User>> _usersController = BehaviorSubject();
final BehaviorSubject<bool> _queryUsersLoadingController =
BehaviorSubject.seeded(false);
/// The stream notifying the state of queryUsers call
Stream<bool> get queryUsersLoading => _queryUsersLoadingController.stream;
/// Calls [Client.queryUsers] updating [queryUsersLoading] stream
Future<void> queryUsers({
Map<String, dynamic> filter,
List<SortOption> sort,
Map<String, dynamic> 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<User>.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;
}
+3
View File
@@ -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';
+1 -1
View File
@@ -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