Merge pull request #142 from GetStream/feature/new-chat-screens
Feature/new chat screens
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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()}');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user