diff --git a/example/lib/chips_input_text_field.dart b/example/lib/chips_input_text_field.dart index 19c5c761..26f2d2b5 100644 --- a/example/lib/chips_input_text_field.dart +++ b/example/lib/chips_input_text_field.dart @@ -43,7 +43,6 @@ class ChipInputTextFieldState extends State> { _chips.remove(item); if (_chips.isEmpty) resumeItemAddition(); }); - if (widget.focusNode != null) widget.focusNode.requestFocus(); if (widget.onChipRemoved != null) widget.onChipRemoved(item); } @@ -51,25 +50,20 @@ class ChipInputTextFieldState extends State> { if (!_pauseItemAddition) { setState(() => _pauseItemAddition = true); } + widget.focusNode?.unfocus(); } void resumeItemAddition() { if (_pauseItemAddition) { setState(() => _pauseItemAddition = false); } + widget.focusNode?.requestFocus(); } @override Widget build(BuildContext context) { return GestureDetector( - onTap: _pauseItemAddition - ? () { - setState(() { - _pauseItemAddition = false; - widget.focusNode?.requestFocus(); - }); - } - : null, + onTap: _pauseItemAddition ? resumeItemAddition : null, child: Material( elevation: 1, color: Colors.white, diff --git a/example/lib/choose_user_page.dart b/example/lib/choose_user_page.dart index 48b66b2d..1ff2a268 100644 --- a/example/lib/choose_user_page.dart +++ b/example/lib/choose_user_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'notifications_service.dart'; +import 'routes/routes.dart'; const kStreamApiKey = 'STREAM_API_KEY'; const kStreamUserId = 'STREAM_USER_ID'; @@ -154,18 +155,10 @@ class ChooseUserPage extends StatelessWidget { if (!kIsWeb) { initNotifications(client); } - - Navigator.pop(context); - await Navigator.pushReplacement( + Navigator.pushReplacementNamed( context, - MaterialPageRoute( - builder: (context) { - return StreamChat( - client: client, - child: ChannelListPage(), - ); - }, - ), + Routes.CHANNEL_LIST, + arguments: client, ); }, leading: UserAvatar( @@ -188,12 +181,7 @@ class ChooseUserPage extends StatelessWidget { }), ListTile( onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AdvancedOptionsPage(), - ), - ); + Navigator.pushNamed(context, Routes.ADVANCED_OPTIONS); }, leading: CircleAvatar( child: StreamSvgIcon.settings( diff --git a/example/lib/group_chat_details_screen.dart b/example/lib/group_chat_details_screen.dart index e8a88bac..88de33a9 100644 --- a/example/lib/group_chat_details_screen.dart +++ b/example/lib/group_chat_details_screen.dart @@ -3,6 +3,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:uuid/uuid.dart'; import 'main.dart'; +import 'routes/routes.dart'; class GroupChatDetailsScreen extends StatefulWidget { final List selectedUsers; @@ -52,169 +53,173 @@ class _GroupChatDetailsScreenState extends State { @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, + return WillPopScope( + onWillPop: () async { + Navigator.pop(context, _selectedUsers); + return false; + }, + child: 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)), + 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: [ - StreamNeumorphicButton( - child: IconButton( - padding: const EdgeInsets.all(0), - icon: StreamSvgIcon.check( - size: 24, - color: _isGroupNameEmpty ? Colors.grey : 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, - ); - }, - ), + actions: [ + StreamNeumorphicButton( + child: IconButton( + padding: const EdgeInsets.all(0), + icon: StreamSvgIcon.check( + size: 24, + color: _isGroupNameEmpty ? Colors.grey : 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.pushNamedAndRemoveUntil( + context, + Routes.CHANNEL_PAGE, + ModalRoute.withName(Routes.CHANNEL_LIST), + arguments: 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, + ), + ], + ), + 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( + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + 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), - ); - } - 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); - } - }, - ), - ); - }, + 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, _selectedUsers); + } + }, + ), + ); + }, + ), + ), ), - ), - ], + ], + ), ), ); } diff --git a/example/lib/main.dart b/example/lib/main.dart index d2be225a..6cf35e0e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,6 +10,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'notifications_service.dart'; +import 'routes/app_routes.dart'; +import 'routes/routes.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -47,19 +49,19 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - //TODO change to system once dark theme is implemented - themeMode: ThemeMode.light, - builder: (context, widget) { - return StreamChat( - child: widget, - client: client, - ); - }, - home: client.state.user == null ? ChooseUserPage() : ChannelListPage(), + return StreamChat( + client: client, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + //TODO change to system once dark theme is implemented + themeMode: ThemeMode.light, + onGenerateRoute: AppRoutes.generateRoute, + initialRoute: client.state.user == null + ? Routes.CHOOSE_USER + : Routes.CHANNEL_LIST, + ), ); } } @@ -71,11 +73,7 @@ class ChannelListPage extends StatelessWidget { return Scaffold( appBar: ChannelListHeader( onNewChatButtonTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => NewChatScreen(), - ), - ); + Navigator.pushNamed(context, Routes.NEW_CHAT); }, ), drawer: _buildDrawer(context, user), @@ -83,9 +81,7 @@ class ChannelListPage extends StatelessWidget { body: ChannelsBloc( child: ChannelListView( onStartChatPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return NewChatScreen(); - })); + Navigator.pushNamed(context, Routes.NEW_CHAT); }, swipeToAction: true, filter: { @@ -147,11 +143,10 @@ class ChannelListPage extends StatelessWidget { color: Colors.black.withOpacity(.5), ), onTap: () { - Navigator.of(context) - ..pop() - ..push(MaterialPageRoute(builder: (context) { - return NewChatScreen(); - })); + Navigator.popAndPushNamed( + context, + Routes.NEW_CHAT, + ); }, title: Text( 'New direct message', @@ -165,11 +160,10 @@ class ChannelListPage extends StatelessWidget { color: Colors.black.withOpacity(.5), ), onTap: () { - Navigator.of(context) - ..pop() - ..push(MaterialPageRoute(builder: (context) { - return NewGroupChatScreen(); - })); + Navigator.popAndPushNamed( + context, + Routes.NEW_GROUP_CHAT, + ); }, title: Text( 'New group', @@ -188,11 +182,9 @@ class ChannelListPage extends StatelessWidget { final secureStorage = FlutterSecureStorage(); await secureStorage.deleteAll(); Navigator.pop(context); - await Navigator.pushReplacement( + Navigator.pushReplacementNamed( context, - MaterialPageRoute( - builder: (context) => ChooseUserPage(), - ), + Routes.CHOOSE_USER, ); }, leading: StreamSvgIcon.user( diff --git a/example/lib/new_chat_screen.dart b/example/lib/new_chat_screen.dart index 690e831f..364c8b7e 100644 --- a/example/lib/new_chat_screen.dart +++ b/example/lib/new_chat_screen.dart @@ -6,6 +6,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'chips_input_text_field.dart'; import 'main.dart'; import 'new_group_chat_screen.dart'; +import 'routes/routes.dart'; class NewChatScreen extends StatefulWidget { @override @@ -182,9 +183,9 @@ class _NewChatScreenState extends State { Container( child: InkWell( onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute(builder: (_) => NewGroupChatScreen()), + Routes.NEW_GROUP_CHAT, ); }, child: Padding( @@ -243,67 +244,71 @@ class _NewChatScreenState extends State { ), 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, + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: 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); + } }, - }, - sort: [ - SortOption( - 'name', - direction: 1, + pagination: PaginationParams( + limit: 25, ), - ], - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: StreamSvgIcon.search( - size: 96, - color: Colors.grey, + filter: { + if (_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: StreamSvgIcon.search( + size: 96, + color: Colors.grey, + ), ), - ), - Text( - 'No user matches these keywords...'), - ], + Text( + 'No user matches these keywords...'), + ], + ), ), ), - ), - ); - }, - ); - }, + ); + }, + ); + }, + ), ), ) : MessageListView(), @@ -330,16 +335,11 @@ class _NewChatScreenState extends State { channel.update({ 'draft': false, }); - Navigator.pushReplacement( + Navigator.pushNamedAndRemoveUntil( context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - child: ChannelPage(), - channel: channel, - ); - }, - ), + Routes.CHANNEL_PAGE, + ModalRoute.withName(Routes.CHANNEL_LIST), + arguments: channel, ); } } diff --git a/example/lib/new_group_chat_screen.dart b/example/lib/new_group_chat_screen.dart index 8361c895..ff77e44d 100644 --- a/example/lib/new_group_chat_screen.dart +++ b/example/lib/new_group_chat_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'group_chat_details_screen.dart'; +import 'search_text_field.dart'; +import 'routes/routes.dart'; class NewGroupChatScreen extends StatefulWidget { @override @@ -24,11 +26,12 @@ class _NewGroupChatScreenState extends State { void _userNameListener() { if (_debounce?.isActive ?? false) _debounce.cancel(); _debounce = Timer(const Duration(milliseconds: 350), () { - if (mounted) + if (mounted) { setState(() { _userNameQuery = _controller.text; _isSearchActive = _userNameQuery.isNotEmpty; }); + } }); } @@ -48,184 +51,167 @@ class _NewGroupChatScreenState extends State { @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: StreamSvgIcon.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( - prefixIconConstraints: BoxConstraints.tight( - Size( - 40, - 24, - ), - ), - prefixIcon: Padding( - padding: const EdgeInsets.only( - left: 8, - right: 8, - ), - child: StreamSvgIcon.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), + return SafeArea( + child: Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + pinned: true, + forceElevated: true, + 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: StreamSvgIcon.arrow_right( + color: Color(0xFF006CFF), + ), + onPressed: () async { + final updatedList = await Navigator.pushNamed( + context, + Routes.NEW_GROUP_CHAT_DETAILS, + arguments: _selectedUsers.toList(growable: false), + ); + if (updatedList != null) { + setState(() { + _selectedUsers + ..clear() + ..addAll(updatedList); + }); + } + }, + ) + ], ), - ), - 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( + SliverToBoxAdapter( + child: SearchTextField( + controller: _controller, + ), + ), + if (_selectedUsers.isNotEmpty) + SliverToBoxAdapter( + child: 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: [ - 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: StreamSvgIcon.close( - color: Colors.black, - size: 24, - ), + 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: StreamSvgIcon.close( + color: Colors.black, + size: 24, + ), + ), + ), + ), + ) + ], + ), + SizedBox(height: 4), + Text( + user.name.split(' ')[0], + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, ), - ) + ), ], + ); + }, + ), + ), + ), + SliverPersistentHeader( + pinned: true, + delegate: _HeaderDelegate( + height: 30, + child: 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), ), - 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( + ]; + }, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: UsersBloc( child: UserListView( selectedUsers: _selectedUsers, + pullToRefresh: false, groupAlphabetically: _isSearchActive ? false : true, onUserTap: (user, _) { if (!_selectedUsers.contains(user)) { @@ -286,9 +272,37 @@ class _NewGroupChatScreenState extends State { }, ), ), - ], + ), ), ), ); } } + +class _HeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + final double height; + + const _HeaderDelegate({ + @required this.child, + @required this.height, + }); + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + color: Colors.white, + child: child, + ); + } + + @override + double get maxExtent => height; + + @override + double get minExtent => height; + + @override + bool shouldRebuild(_HeaderDelegate oldDelegate) => true; +} diff --git a/example/lib/routes/app_routes.dart b/example/lib/routes/app_routes.dart new file mode 100644 index 00000000..19352319 --- /dev/null +++ b/example/lib/routes/app_routes.dart @@ -0,0 +1,64 @@ +import 'routes.dart'; +import 'package:flutter/material.dart'; +import '../choose_user_page.dart'; +import '../advanced_options_page.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../main.dart'; +import '../group_chat_details_screen.dart'; +import '../new_group_chat_screen.dart'; +import '../new_chat_screen.dart'; + +class AppRoutes { + /// Add entry for new route here + static Route generateRoute(RouteSettings settings) { + final args = settings.arguments; + switch (settings.name) { + case Routes.CHANNEL_LIST: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.CHANNEL_LIST), + builder: (_) { + return ChannelListPage(); + }); + case Routes.CHOOSE_USER: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.CHOOSE_USER), + builder: (_) { + return ChooseUserPage(); + }); + case Routes.ADVANCED_OPTIONS: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.ADVANCED_OPTIONS), + builder: (_) => AdvancedOptionsPage(), + ); + case Routes.CHANNEL_PAGE: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.CHANNEL_PAGE), + builder: (_) { + return StreamChannel( + channel: args as Channel, + child: ChannelPage(), + ); + }); + case Routes.NEW_CHAT: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.NEW_CHAT), + builder: (_) { + return NewChatScreen(); + }); + case Routes.NEW_GROUP_CHAT: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.NEW_GROUP_CHAT), + builder: (_) { + return NewGroupChatScreen(); + }); + case Routes.NEW_GROUP_CHAT_DETAILS: + return MaterialPageRoute( + settings: const RouteSettings(name: Routes.NEW_GROUP_CHAT_DETAILS), + builder: (_) { + return GroupChatDetailsScreen( + selectedUsers: args, + ); + }); + } + } +} diff --git a/example/lib/routes/routes.dart b/example/lib/routes/routes.dart new file mode 100644 index 00000000..f4ace9a4 --- /dev/null +++ b/example/lib/routes/routes.dart @@ -0,0 +1,10 @@ +/// Define all the route names here +class Routes { + static const String CHANNEL_LIST = '/channel_list'; + static const String CHOOSE_USER = '/choose_user'; + static const String ADVANCED_OPTIONS = '/advance_options'; + static const String CHANNEL_PAGE = '/channel_page'; + static const String NEW_CHAT = '/new_chat'; + static const String NEW_GROUP_CHAT = '/new_group_chat'; + static const String NEW_GROUP_CHAT_DETAILS = '/new_group_chat_details'; +} diff --git a/example/lib/search_text_field.dart b/example/lib/search_text_field.dart new file mode 100644 index 00000000..f46a48b0 --- /dev/null +++ b/example/lib/search_text_field.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class SearchTextField extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final String hintText; + final VoidCallback onTap; + final bool showCloseButton; + + const SearchTextField({ + Key key, + @required this.controller, + this.onChanged, + this.onTap, + this.hintText = 'Search', + this.showCloseButton = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return 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: Row( + children: [ + Expanded( + child: TextField( + onTap: onTap, + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + prefixIconConstraints: BoxConstraints.tight(Size(40, 24)), + prefixIcon: Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + ), + child: StreamSvgIcon.search( + color: Colors.black, + size: 24, + ), + ), + hintText: hintText, + 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 (showCloseButton) + Material( + color: Colors.transparent, + child: IconButton( + padding: const EdgeInsets.all(0), + icon: StreamSvgIcon.close_small( + color: Colors.grey, + ), + splashRadius: 24, + onPressed: () { + if (controller.text.isNotEmpty) { + Future.microtask( + () => [ + controller.clear(), + onChanged(''), + ], + ); + } + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/back_button.dart b/lib/src/back_button.dart index 25c1c9da..10b177d2 100644 --- a/lib/src/back_button.dart +++ b/lib/src/back_button.dart @@ -30,7 +30,7 @@ class StreamBackButton extends StatelessWidget { if (onPressed != null) { onPressed(); } else { - Navigator.of(context).pop(); + Navigator.maybePop(context); } }, child: StreamSvgIcon.left( diff --git a/lib/src/lazy_load_scroll_view.dart b/lib/src/lazy_load_scroll_view.dart new file mode 100644 index 00000000..e83fc777 --- /dev/null +++ b/lib/src/lazy_load_scroll_view.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; + +enum LoadingStatus { LOADING, STABLE } + +/// Signature for EndOfPageListeners +typedef EndOfPageListenerCallback = void Function(); + +/// A widget that wraps a [Widget] and will trigger [onEndOfPage] when it +/// reaches the bottom of the list +class LazyLoadScrollView extends StatefulWidget { + /// The [Widget] that this widget watches for changes on + final Widget child; + + /// Called when the [child] reaches the end of the list + final EndOfPageListenerCallback onEndOfPage; + + /// The offset to take into account when triggering [onEndOfPage] in pixels + final int scrollOffset; + + /// Used to determine if loading of new data has finished. You should use set this if you aren't using a FutureBuilder or StreamBuilder + final bool isLoading; + + LazyLoadScrollView({ + Key key, + @required this.child, + @required this.onEndOfPage, + this.isLoading = false, + this.scrollOffset = 100, + }) : assert(onEndOfPage != null), + assert(child != null), + super(key: key); + + @override + State createState() => _LazyLoadScrollViewState(); +} + +class _LazyLoadScrollViewState extends State { + LoadingStatus _loadMoreStatus = LoadingStatus.STABLE; + + @override + void didUpdateWidget(LazyLoadScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.isLoading) { + _loadMoreStatus = LoadingStatus.STABLE; + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + child: widget.child, + onNotification: _onNotification, + ); + } + + bool _onNotification(Notification notification) { + if (notification is ScrollUpdateNotification) { + if (notification.metrics.maxScrollExtent > notification.metrics.pixels && + notification.metrics.maxScrollExtent - notification.metrics.pixels <= + widget.scrollOffset) { + if (_loadMoreStatus != null && + _loadMoreStatus == LoadingStatus.STABLE) { + _loadMoreStatus = LoadingStatus.LOADING; + widget.onEndOfPage(); + } + } + return true; + } + if (notification is OverscrollNotification) { + if (notification.overscroll > 0) { + if (_loadMoreStatus != null && + _loadMoreStatus == LoadingStatus.STABLE) { + _loadMoreStatus = LoadingStatus.LOADING; + widget.onEndOfPage(); + } + } + return true; + } + return false; + } +} diff --git a/lib/src/stream_svg_icon.dart b/lib/src/stream_svg_icon.dart index 2851df3a..9a87ce9d 100644 --- a/lib/src/stream_svg_icon.dart +++ b/lib/src/stream_svg_icon.dart @@ -373,4 +373,28 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); } + + factory StreamSvgIcon.arrow_right({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_arrow_right.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.close_small({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_close_sml.svg', + color: color, + width: size, + height: size, + ); + } } diff --git a/lib/src/user_list_view.dart b/lib/src/user_list_view.dart index 63285d8b..569eb38a 100644 --- a/lib/src/user_list_view.dart +++ b/lib/src/user_list_view.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/lazy_load_scroll_view.dart'; import 'package:stream_chat_flutter/src/users_bloc.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -144,8 +145,6 @@ class UserListView extends StatefulWidget { class _UserListViewState extends State with WidgetsBindingObserver { - final ScrollController _scrollController = ScrollController(); - bool get _isListView => widget.crossAxisCount == 1; @override @@ -158,14 +157,6 @@ class _UserListViewState extends State pagination: widget.pagination, options: widget.options, ); - - _scrollController.addListener(() { - usersBloc.queryUsersLoading.first.then((loading) { - if (!loading) { - _listenUserPagination(usersBloc); - } - }); - }); } @override @@ -327,57 +318,41 @@ class _UserListViewState extends State ); } - if (_isListView) { - return ListView.custom( - physics: AlwaysScrollableScrollPhysics(), - controller: _scrollController, - childrenDelegate: SliverChildBuilderDelegate( - (context, i) { - return _listItemBuilder(context, i, items); - }, - childCount: (items.length * 2) + 1, - findChildIndexCallback: (key) { - final ValueKey valueKey = key; - final index = - items.indexWhere((item) => item.key == valueKey.value); - return index != -1 ? (index * 2) : null; - }, - ), - ); - } - return GridView.custom( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.crossAxisCount, - ), - physics: AlwaysScrollableScrollPhysics(), - controller: _scrollController, - childrenDelegate: SliverChildBuilderDelegate( - (context, i) { - return _gridItemBuilder(context, i, items); - }, - childCount: items.length, - findChildIndexCallback: (key) { - final ValueKey valueKey = key; - final index = - items.indexWhere((item) => item.key == valueKey.value); - return index != -1 ? index : null; - }, - ), + final child = _isListView + ? ListView.separated( + physics: AlwaysScrollableScrollPhysics(), + // controller: _scrollController, + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) { + return _listItemBuilder(context, index, items); + }, + ) + : GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + ), + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + physics: AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) { + return _gridItemBuilder(context, index, items); + }, + ); + + return LazyLoadScrollView( + onEndOfPage: () => _listenUserPagination(usersBlocState), + child: child, ); }, ); } Widget _listItemBuilder(BuildContext context, int i, List items) { - if (i % 2 != 0) { - if (widget.separatorBuilder != null) { - return widget.separatorBuilder(context, i); - } - return _separatorBuilder(context, i); - } - - i = i ~/ 2; - final usersProvider = UsersBloc.of(context); if (i < items.length) { final item = items[i]; @@ -507,18 +482,14 @@ class _UserListViewState extends State } 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, - ); - } + usersProvider.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination.copyWith( + offset: usersProvider.users?.length ?? 0, + ), + options: widget.options, + ); } @override