Merge branch 'feature/new-ui' of https://github.com/GetStream/stream-chat-flutter into feature/group-info-screen

 Conflicts:
	pubspec.yaml
This commit is contained in:
Deven Joshi
2020-12-21 20:46:39 +05:30
19 changed files with 1029 additions and 612 deletions
+1 -1
View File
@@ -1 +1 @@
c3e639ccf9b069e37a7d1345194e6b99 13a41b8138c4868054e44b5158f3bdd6
+2 -2
View File
@@ -40,7 +40,7 @@ PODS:
- Firebase/Messaging (6.33.0): - Firebase/Messaging (6.33.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 4.7.0) - FirebaseMessaging (~> 4.7.0)
- firebase_core (0.5.2-1): - firebase_core (0.5.3):
- Firebase/CoreOnly (~> 6.33.0) - Firebase/CoreOnly (~> 6.33.0)
- Flutter - Flutter
- firebase_messaging (7.0.3): - firebase_messaging (7.0.3):
@@ -258,7 +258,7 @@ SPEC CHECKSUMS:
esys_flutter_share: 403498dab005b36ce1f8d7aff377e81f0621b0b4 esys_flutter_share: 403498dab005b36ce1f8d7aff377e81f0621b0b4
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
Firebase: 8db6f2d1b2c5e2984efba4949a145875a8f65fe5 Firebase: 8db6f2d1b2c5e2984efba4949a145875a8f65fe5
firebase_core: 7423d688a1c6f2f2d859d64ae26991be39989781 firebase_core: 5d6a02f3d85acd5f8321c2d6d62877626a670659
firebase_messaging: 0aea2cd5885b65e19ede58ee3507f485c992cc75 firebase_messaging: 0aea2cd5885b65e19ede58ee3507f485c992cc75
FirebaseCore: d889d9e12535b7f36ac8bfbf1713a0836a3012cd FirebaseCore: d889d9e12535b7f36ac8bfbf1713a0836a3012cd
FirebaseCoreDiagnostics: 770ac5958e1372ce67959ae4b4f31d8e127c3ac1 FirebaseCoreDiagnostics: 770ac5958e1372ce67959ae4b4f31d8e127c3ac1
+5 -17
View File
@@ -5,39 +5,27 @@
<testcase classname="fastlane.lanes" name="0: Verifying fastlane version" time="0.00047"> <testcase classname="fastlane.lanes" name="0: Verifying fastlane version" time="0.00094">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: default_platform" time="0.000176"> <testcase classname="fastlane.lanes" name="1: default_platform" time="0.000521">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: is_ci" time="0.000168"> <testcase classname="fastlane.lanes" name="2: is_ci" time="0.000605">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: Switch to ios match_me lane" time="0.000157"> <testcase classname="fastlane.lanes" name="3: is_ci" time="0.000675">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: is_ci" time="0.000161"> <testcase classname="fastlane.lanes" name="4: match" time="48.727951">
</testcase>
<testcase classname="fastlane.lanes" name="5: match" time="32.299713">
</testcase>
<testcase classname="fastlane.lanes" name="6: gym" time="9.737819">
<failure message="/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `chdir'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing'&#10;Fastfile:28:in `block (2 levels) in parsing_binding'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane.rb:33:in `call'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `chdir'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/runner.rb:45:in `execute'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:108:in `block (2 levels) in run'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:178:in `call'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/command.rb:153:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/runner.rb:476:in `run_active_command'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:76:in `run!'&#10;/Library/Ruby/Gems/2.6.0/gems/commander-fastlane-4.4.6/lib/commander/delegates.rb:15:in `run!'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:352:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/commands_generator.rb:41:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/fastlane/lib/fastlane/cli_tools_distributor.rb:119:in `take_off'&#10;/Library/Ruby/Gems/2.6.0/gems/fastlane-2.162.0/bin/fastlane:23:in `&lt;top (required)&gt;'&#10;/usr/local/bin/fastlane:23:in `load'&#10;/usr/local/bin/fastlane:23:in `&lt;top (required)&gt;'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `load'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:74:in `kernel_load'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli/exec.rb:28:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:465:in `exec'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/command.rb:27:in `run'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/invocation.rb:126:in `invoke_command'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor.rb:387:in `dispatch'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:27:in `dispatch'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/vendor/thor/lib/thor/base.rb:466:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/cli.rb:18:in `start'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:30:in `block in &lt;top (required)&gt;'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/lib/bundler/friendly_errors.rb:124:in `with_friendly_errors'&#10;/Library/Ruby/Gems/2.6.0/gems/bundler-2.0.2/exe/bundle:22:in `&lt;top (required)&gt;'&#10;/usr/local/bin/bundle:23:in `load'&#10;/usr/local/bin/bundle:23:in `&lt;main&gt;'&#10;&#10;Error building the application - see the log above" />
</testcase> </testcase>
+2 -2
View File
@@ -363,8 +363,8 @@ class __SharedGroupsScreenState extends State<_SharedGroupsScreen> {
), ),
backgroundColor: StreamChatTheme.of(context).primaryColor, backgroundColor: StreamChatTheme.of(context).primaryColor,
), ),
body: StreamBuilder<List<Channel>>( body: FutureBuilder<List<Channel>>(
stream: chat.client.queryChannels( future: chat.client.queryChannels(
filter: { filter: {
r'$and': [ r'$and': [
{ {
+1 -1
View File
@@ -133,7 +133,7 @@ class _GroupChatDetailsScreenState extends State<GroupChatDetailsScreen> {
context, context,
Routes.CHANNEL_PAGE, Routes.CHANNEL_PAGE,
ModalRoute.withName(Routes.HOME), ModalRoute.withName(Routes.HOME),
arguments: channel, arguments: ChannelPageArgs(channel: channel),
); );
}, },
), ),
+108 -112
View File
@@ -59,7 +59,7 @@ class MyApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData.light(), theme: ThemeData.light(),
darkTheme: ThemeData.dark(), darkTheme: ThemeData.dark(),
//TODO change to system once dark theme is implemented //TODO change to system once dark theme is implemented
themeMode: ThemeMode.light, themeMode: ThemeMode.light,
onGenerateRoute: AppRoutes.generateRoute, onGenerateRoute: AppRoutes.generateRoute,
initialRoute: initialRoute:
@@ -215,12 +215,16 @@ class _HomePageState extends State<HomePage> {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: ListTile( child: ListTile(
onTap: () async { onTap: () async {
await StreamChat.of(context).client.disconnect(); Navigator.pop(context);
final secureStorage = FlutterSecureStorage(); final secureStorage = FlutterSecureStorage();
await secureStorage.deleteAll(); await secureStorage.deleteAll();
Navigator.pop(context);
Navigator.pushReplacementNamed( StreamChat.of(context).client.disconnect(
clearUser: true,
);
await Navigator.pushReplacementNamed(
context, context,
Routes.CHOOSE_USER, Routes.CHOOSE_USER,
); );
@@ -296,130 +300,113 @@ class _ChannelListPageState extends State<ChannelListPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = StreamChat.of(context).user; final user = StreamChat.of(context).user;
return ChannelsBloc( return WillPopScope(
child: MessageSearchBloc( onWillPop: () async {
child: Column( if (_isSearchActive) {
children: [ _controller.clear();
SearchTextField( setState(() => _isSearchActive = false);
controller: _controller, return false;
showCloseButton: _isSearchActive, }
), return true;
Expanded( },
child: AnimatedSwitcher( child: ChannelsBloc(
duration: const Duration(milliseconds: 350), child: MessageSearchBloc(
child: GestureDetector( child: Column(
behavior: HitTestBehavior.opaque, children: [
onPanDown: (_) => FocusScope.of(context).unfocus(), SearchTextField(
child: _isSearchActive controller: _controller,
? MessageSearchListView( showCloseButton: _isSearchActive,
messageQuery: _channelQuery, ),
filters: { Expanded(
'members': { child: AnimatedSwitcher(
r'$in': [user.id] duration: const Duration(milliseconds: 350),
} child: GestureDetector(
}, behavior: HitTestBehavior.opaque,
sortOptions: [ onPanDown: (_) => FocusScope.of(context).unfocus(),
SortOption( child: _isSearchActive
'created_at', ? MessageSearchListView(
direction: SortOption.ASC, messageQuery: _channelQuery,
), filters: {
], 'members': {
paginationParams: PaginationParams(limit: 20), r'$in': [user.id]
onItemTap: (message) {}, }
)
: ChannelListView(
onStartChatPressed: () {
Navigator.pushNamed(context, Routes.NEW_CHAT);
},
swipeToAction: true,
filter: {
'members': {
r'$in': [user.id],
}, },
}, sortOptions: [
options: { SortOption(
'presence': true, 'created_at',
}, direction: SortOption.ASC,
pagination: PaginationParams( ),
limit: 20, ],
paginationParams: PaginationParams(limit: 20),
onItemTap: (messageResponse) async {
final client = StreamChat.of(context).client;
final message = messageResponse.message;
final channel = client.channel(
messageResponse.channel.type,
id: messageResponse.channel.id,
);
if (channel.state == null) {
await channel.watch();
}
Navigator.pushNamed(
context,
Routes.CHANNEL_PAGE,
arguments: ChannelPageArgs(
channel: channel,
initialMessage: message,
),
);
},
)
: ChannelListView(
onStartChatPressed: () {
Navigator.pushNamed(context, Routes.NEW_CHAT);
},
swipeToAction: true,
filter: {
'members': {
r'$in': [user.id],
},
},
options: {
'presence': true,
},
pagination: PaginationParams(
limit: 20,
),
channelWidget: ChannelPage(),
), ),
channelWidget: ChannelPage(), ),
),
), ),
), ),
), ],
], ),
), ),
), ),
); );
} }
} }
class ChannelQuerySearchResultPage extends StatelessWidget { class ChannelPageArgs {
final Stream<List<Message>> searchResultStream; final Channel channel;
final Message initialMessage;
const ChannelQuerySearchResultPage({ const ChannelPageArgs({
Key key, this.channel,
@required this.searchResultStream, this.initialMessage,
}) : super(key: key); });
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Message>>(
initialData: const <Message>[],
stream: searchResultStream,
builder: (context, snapshot) {
final result = snapshot.data;
return Column(
children: [
if (result.isNotEmpty)
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(
'${result.length} results',
style: TextStyle(
color: Colors.black.withOpacity(0.5),
),
),
),
),
Expanded(
child: ListView.builder(
itemCount: result.length,
itemBuilder: (context, index) {
return ListTile(
leading: UserAvatar(),
title: Text(result[index].toJson().toString()),
);
},
),
),
],
);
},
);
}
} }
class ChannelPage extends StatelessWidget { class ChannelPage extends StatelessWidget {
final int initialScrollIndex;
final double initialAlignment;
final bool highlightInitialMessage;
const ChannelPage({ const ChannelPage({
Key key, Key key,
this.initialScrollIndex,
this.initialAlignment,
this.highlightInitialMessage = false,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -473,6 +460,9 @@ class ChannelPage extends StatelessWidget {
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
MessageListView( MessageListView(
initialScrollIndex: initialScrollIndex,
initialAlignment: initialAlignment,
highlightInitialMessage: highlightInitialMessage,
threadBuilder: (_, parentMessage) { threadBuilder: (_, parentMessage) {
return ThreadPage( return ThreadPage(
parent: parentMessage, parent: parentMessage,
@@ -507,10 +497,14 @@ class ChannelPage extends StatelessWidget {
class ThreadPage extends StatelessWidget { class ThreadPage extends StatelessWidget {
final Message parent; final Message parent;
final int initialScrollIndex;
final double initialAlignment;
ThreadPage({ ThreadPage({
Key key, Key key,
this.parent, this.parent,
this.initialScrollIndex,
this.initialAlignment,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -524,6 +518,8 @@ class ThreadPage extends StatelessWidget {
Expanded( Expanded(
child: MessageListView( child: MessageListView(
parentMessage: parent, parentMessage: parent,
initialScrollIndex: initialScrollIndex,
initialAlignment: initialAlignment,
), ),
), ),
if (parent.type != 'deleted') if (parent.type != 'deleted')
+55 -30
View File
@@ -37,6 +37,8 @@ class _NewChatScreenState extends State<NewChatScreen> {
bool _showUserList = true; bool _showUserList = true;
bool _channelExisted = false;
void _userNameListener() { void _userNameListener() {
if (_debounce?.isActive ?? false) _debounce.cancel(); if (_debounce?.isActive ?? false) _debounce.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () { _debounce = Timer(const Duration(milliseconds: 350), () {
@@ -56,11 +58,6 @@ class _NewChatScreenState extends State<NewChatScreen> {
_searchFocusNode.addListener(() async { _searchFocusNode.addListener(() async {
if (_searchFocusNode.hasFocus && !_showUserList) { if (_searchFocusNode.hasFocus && !_showUserList) {
if (channel.extraData['draft'] == true) {
await channel.stopWatching();
channel.dispose();
channel.client.state.channels.remove(channel.cid);
}
setState(() { setState(() {
_showUserList = true; _showUserList = true;
}); });
@@ -71,19 +68,38 @@ class _NewChatScreenState extends State<NewChatScreen> {
if (_messageInputFocusNode.hasFocus && _selectedUsers.isNotEmpty) { if (_messageInputFocusNode.hasFocus && _selectedUsers.isNotEmpty) {
final chatState = StreamChat.of(context); final chatState = StreamChat.of(context);
channel = chatState.client.channel( final res = await chatState.client.queryChannels(
'messaging', options: {
extraData: { 'state': false,
'watch': false,
},
filter: {
'members': [ 'members': [
..._selectedUsers.map((e) => e.id), ..._selectedUsers.map((e) => e.id),
chatState.user.id, chatState.user.id,
], ],
'draft': true, 'distinct': true,
}, },
messageLimit: 0,
paginationParams: PaginationParams(
limit: 1,
),
); );
if (!chatState.client.state.channels.containsKey(channel.cid)) { final _channelExisted = res.length == 1;
if (_channelExisted) {
channel = res.first;
await channel.watch(); await channel.watch();
} else {
channel = chatState.client.channel(
'messaging',
extraData: {
'members': [
..._selectedUsers.map((e) => e.id),
chatState.user.id,
],
},
);
} }
setState(() { setState(() {
@@ -134,6 +150,7 @@ class _NewChatScreenState extends State<NewChatScreen> {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
_chipInputTextFieldState.removeItem(user); _chipInputTextFieldState.removeItem(user);
_searchFocusNode.requestFocus();
}, },
child: Stack( child: Stack(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
@@ -311,18 +328,38 @@ class _NewChatScreenState extends State<NewChatScreen> {
), ),
), ),
) )
: MessageListView(), : FutureBuilder<bool>(
future: channel.initialized,
builder: (context, snapshot) {
if (snapshot.data == true) {
return MessageListView();
}
return Center(
child: Text(
'No chats here yet...',
style: TextStyle(
fontSize: 12,
color: Colors.black.withOpacity(.5),
),
),
);
},
),
), ),
MessageInput( MessageInput(
focusNode: _messageInputFocusNode, focusNode: _messageInputFocusNode,
preMessageSending: (message) async {
await channel.watch();
return message;
},
onMessageSent: (m) { onMessageSent: (m) {
if (!m.isEphemeral) { Navigator.pushNamedAndRemoveUntil(
_updateChannelAndNavigate(context); context,
} else { Routes.CHANNEL_PAGE,
channel.on('message.new').first.then((_) { ModalRoute.withName(Routes.HOME),
_updateChannelAndNavigate(context); arguments: ChannelPageArgs(channel: channel),
}); );
}
}, },
), ),
], ],
@@ -330,16 +367,4 @@ class _NewChatScreenState extends State<NewChatScreen> {
), ),
); );
} }
void _updateChannelAndNavigate(BuildContext context) {
channel.update({
'draft': false,
});
Navigator.pushNamedAndRemoveUntil(
context,
Routes.CHANNEL_PAGE,
ModalRoute.withName(Routes.HOME),
arguments: channel,
);
}
} }
+6 -2
View File
@@ -36,9 +36,13 @@ class AppRoutes {
return MaterialPageRoute( return MaterialPageRoute(
settings: const RouteSettings(name: Routes.CHANNEL_PAGE), settings: const RouteSettings(name: Routes.CHANNEL_PAGE),
builder: (_) { builder: (_) {
final arg = args as ChannelPageArgs;
return StreamChannel( return StreamChannel(
channel: args as Channel, channel: arg.channel,
child: ChannelPage(), initialMessageId: arg.initialMessage?.id,
child: ChannelPage(
highlightInitialMessage: arg.initialMessage != null,
),
); );
}); });
case Routes.NEW_CHAT: case Routes.NEW_CHAT:
+1 -1
View File
@@ -1,6 +1,6 @@
name: example name: example
description: A new Flutter project. description: A new Flutter project.
version: 1.0.88+90 version: 1.0.94+97
environment: environment:
sdk: ">=2.2.2 <3.0.0" sdk: ">=2.2.2 <3.0.0"
+4 -7
View File
@@ -85,7 +85,7 @@ class ChannelsBlocState extends State<ChannelsBloc>
paginationParams.offset == null || paginationParams.offset == null ||
paginationParams.offset == 0; paginationParams.offset == 0;
final oldChannels = List<Channel>.from(channels ?? []); final oldChannels = List<Channel>.from(channels ?? []);
client await client
.queryChannels( .queryChannels(
filter: filter, filter: filter,
sort: sortOptions, sort: sortOptions,
@@ -93,21 +93,18 @@ class ChannelsBlocState extends State<ChannelsBloc>
paginationParams: paginationParams, paginationParams: paginationParams,
onlyOffline: onlyOffline, onlyOffline: onlyOffline,
) )
.listen((channels) { .then((channels) {
if (clear) { if (clear) {
_channelsController.add(channels); _channelsController.add(channels);
} else { } else {
final l = oldChannels + channels; final l = oldChannels + channels;
_channelsController.add(l); _channelsController.add(l);
} }
}, onDone: () {
_queryChannelsLoadingController.sink.add(false); _queryChannelsLoadingController.sink.add(false);
}, onError: (err, stackTrace) {
print(err);
print(stackTrace);
_queryChannelsLoadingController.addError(err, stackTrace);
}); });
} catch (err, stackTrace) { } catch (err, stackTrace) {
print(err);
print(stackTrace);
_queryChannelsLoadingController.addError(err, stackTrace); _queryChannelsLoadingController.addError(err, stackTrace);
} }
} }
+101 -34
View File
@@ -1,33 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum LoadingStatus { LOADING, STABLE } enum _LoadingStatus { LOADING, STABLE }
/// Signature for EndOfPageListeners /// A widget that wraps a [Widget] and will trigger [onEndOfPage]/[onStartOfPage] when it
typedef EndOfPageListenerCallback = void Function(); /// reaches the bottom/start of the list
/// A widget that wraps a [Widget] and will trigger [onEndOfPage] when it
/// reaches the bottom of the list
class LazyLoadScrollView extends StatefulWidget { class LazyLoadScrollView extends StatefulWidget {
/// The [Widget] that this widget watches for changes on /// The [Widget] that this widget watches for changes on
final Widget child; final Widget child;
/// Called when the [child] reaches the end of the list /// Called when the [child] reaches the start of the list
final EndOfPageListenerCallback onEndOfPage; final AsyncCallback onStartOfPage;
/// The offset to take into account when triggering [onEndOfPage] in pixels /// Called when the [child] reaches the end of the list
final int scrollOffset; final AsyncCallback onEndOfPage;
/// Called when the list scrolling starts
final VoidCallback onPageScrollStart;
/// Called when the list scrolling ends
final VoidCallback onPageScrollEnd;
/// Called every time the [child] is in-between the list
final VoidCallback onInBetweenOfPage;
/// The offset to take into account when triggering [onEndOfPage]/[onStartOfPage] in pixels
final double 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 /// 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; final bool isLoading;
/// Initiates a LazyLoadScrollView widget
LazyLoadScrollView({ LazyLoadScrollView({
Key key, Key key,
@required this.child, @required this.child,
@required this.onEndOfPage, this.onStartOfPage,
this.onEndOfPage,
this.onPageScrollStart,
this.onPageScrollEnd,
this.onInBetweenOfPage,
this.isLoading = false, this.isLoading = false,
this.scrollOffset = 100, this.scrollOffset = 100,
}) : assert(onEndOfPage != null), }) : assert(child != null),
assert(child != null),
super(key: key); super(key: key);
@override @override
@@ -35,15 +49,8 @@ class LazyLoadScrollView extends StatefulWidget {
} }
class _LazyLoadScrollViewState extends State<LazyLoadScrollView> { class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
LoadingStatus _loadMoreStatus = LoadingStatus.STABLE; _LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE;
double _scrollPosition = 0.0;
@override
void didUpdateWidget(LazyLoadScrollView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.isLoading) {
_loadMoreStatus = LoadingStatus.STABLE;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -54,28 +61,88 @@ class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
} }
bool _onNotification(Notification notification) { bool _onNotification(Notification notification) {
if (notification is ScrollStartNotification) {
if (widget.onPageScrollStart != null) {
widget.onPageScrollStart();
return true;
}
}
if (notification is ScrollEndNotification) {
if (widget.onPageScrollEnd != null) {
widget.onPageScrollEnd();
return true;
}
}
if (notification is ScrollUpdateNotification) { if (notification is ScrollUpdateNotification) {
if (notification.metrics.maxScrollExtent > notification.metrics.pixels && final pixels = notification.metrics.pixels;
notification.metrics.maxScrollExtent - notification.metrics.pixels <= final maxScrollExtent = notification.metrics.maxScrollExtent;
widget.scrollOffset) { final minScrollExtent = notification.metrics.minScrollExtent;
if (_loadMoreStatus != null && final scrollOffset = widget.scrollOffset;
_loadMoreStatus == LoadingStatus.STABLE) {
_loadMoreStatus = LoadingStatus.LOADING; if (pixels > (minScrollExtent + scrollOffset) &&
widget.onEndOfPage(); pixels < (maxScrollExtent - scrollOffset)) {
if (widget.onInBetweenOfPage != null) {
widget.onInBetweenOfPage();
return true;
} }
} }
final extentBefore = notification.metrics.extentBefore;
final extentAfter = notification.metrics.extentAfter;
final scrollingDown = _scrollPosition < pixels;
if (scrollOffset == null || scrollOffset == 0) {
if (extentAfter == 0) {
_onEndOfPage();
}
if (extentBefore == 0) {
_onStartOfPage();
}
} else {
if (scrollingDown) {
if (extentAfter <= scrollOffset) {
_onEndOfPage();
}
} else {
if (extentBefore <= scrollOffset) {
_onStartOfPage();
}
}
}
_scrollPosition = pixels;
return true; return true;
} }
if (notification is OverscrollNotification) { if (notification is OverscrollNotification) {
if (notification.overscroll > 0) { if (notification.overscroll > 0) {
if (_loadMoreStatus != null && _onEndOfPage();
_loadMoreStatus == LoadingStatus.STABLE) { }
_loadMoreStatus = LoadingStatus.LOADING; if (notification.overscroll < 0) {
widget.onEndOfPage(); _onStartOfPage();
}
} }
return true; return true;
} }
return false; return false;
} }
void _onEndOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onEndOfPage != null) {
widget.onEndOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE;
});
}
}
}
void _onStartOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onStartOfPage != null) {
widget.onStartOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE;
});
}
}
}
} }
+30 -19
View File
@@ -21,7 +21,6 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:substring_highlight/substring_highlight.dart'; import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart'; import 'package:video_compress/video_compress.dart';
import 'package:photo_manager/photo_manager.dart';
import '../stream_chat_flutter.dart'; import '../stream_chat_flutter.dart';
import 'stream_channel.dart'; import 'stream_channel.dart';
@@ -343,7 +342,10 @@ class MessageInputState extends State<MessageInput> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (!widget.disableAttachments) _buildAttachmentButton(), if (!widget.disableAttachments) _buildAttachmentButton(),
if (widget.editMessage == null) _buildCommandButton(), if (widget.editMessage == null &&
StreamChannel.of(context).channel?.config?.commands?.isNotEmpty ==
true)
_buildCommandButton(),
], ],
), ),
duration: Duration(milliseconds: 300), duration: Duration(milliseconds: 300),
@@ -459,6 +461,7 @@ class MessageInputState extends State<MessageInput> {
} }
Timer _debounce; Timer _debounce;
void _onChanged(BuildContext context, String s) { void _onChanged(BuildContext context, String s) {
if (_debounce?.isActive == true) _debounce.cancel(); if (_debounce?.isActive == true) _debounce.cancel();
_debounce = Timer( _debounce = Timer(
@@ -543,11 +546,12 @@ class MessageInputState extends State<MessageInput> {
void _checkCommands(String s, BuildContext context) { void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) { if (s.startsWith('/')) {
var matchedCommandsList = StreamChannel.of(context) var matchedCommandsList = StreamChannel.of(context)
.channel .channel
.config .config
.commands ?.commands
.where((element) => element.name == s.substring(1)) ?.where((element) => element.name == s.substring(1))
.toList(); ?.toList() ??
[];
if (matchedCommandsList.length == 1) { if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0]; _chosenCommand = matchedCommandsList[0];
@@ -568,11 +572,12 @@ class MessageInputState extends State<MessageInput> {
OverlayEntry _buildCommandsOverlayEntry() { OverlayEntry _buildCommandsOverlayEntry() {
final text = textEditingController.text.trimLeft(); final text = textEditingController.text.trimLeft();
final commands = StreamChannel.of(context) final commands = StreamChannel.of(context)
.channel .channel
.config .config
.commands ?.commands
.where((c) => c.name.contains(text.replaceFirst('/', ''))) ?.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList(); ?.toList() ??
[];
RenderBox renderBox = context.findRenderObject(); RenderBox renderBox = context.findRenderObject();
final size = renderBox.size; final size = renderBox.size;
@@ -1282,10 +1287,11 @@ class MessageInputState extends State<MessageInput> {
children: [ children: [
if (_attachments.any((e) => e.attachment?.type == 'file')) if (_attachments.any((e) => e.attachment?.type == 'file'))
LimitedBox( LimitedBox(
maxHeight: 73.0, maxHeight: 136.0,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, reverse: true,
children: _attachments shrinkWrap: true,
children: _attachments.reversed
.where((e) => e.attachment?.type == 'file') .where((e) => e.attachment?.type == 'file')
.map( .map(
(e) => Padding( (e) => Padding(
@@ -1299,8 +1305,8 @@ class MessageInputState extends State<MessageInput> {
attachmentType: FileAttachmentType.local, attachmentType: FileAttachmentType.local,
file: e.file, file: e.file,
size: Size( size: Size(
MediaQuery.of(context).size.width * 0.55, MediaQuery.of(context).size.width * 0.65,
MediaQuery.of(context).size.height * 0.3, 56.0,
), ),
trailing: Padding( trailing: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@@ -1927,8 +1933,6 @@ class MessageInputState extends State<MessageInput> {
_mentionsOverlay?.remove(); _mentionsOverlay?.remove();
_mentionsOverlay = null; _mentionsOverlay = null;
final channel = StreamChannel.of(context).channel;
Future sendingFuture; Future sendingFuture;
Message message; Message message;
if (widget.editMessage != null) { if (widget.editMessage != null) {
@@ -1953,6 +1957,12 @@ class MessageInputState extends State<MessageInput> {
message = await widget.preMessageSending(message); message = await widget.preMessageSending(message);
} }
final streamChannel = StreamChannel.of(context);
final channel = streamChannel.channel;
if (!channel.state.isUpToDate) {
await streamChannel.reloadChannel();
}
if (widget.editMessage == null || if (widget.editMessage == null ||
widget.editMessage.status == MessageSendingStatus.FAILED) { widget.editMessage.status == MessageSendingStatus.FAILED) {
sendingFuture = channel.sendMessage(message); sendingFuture = channel.sendMessage(message);
@@ -2038,6 +2048,7 @@ class MessageInputState extends State<MessageInput> {
} }
bool _initialized = false; bool _initialized = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
if (widget.editMessage != null && !_initialized) { if (widget.editMessage != null && !_initialized) {
+443 -283
View File
@@ -1,10 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat/stream_chat.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/message_widget.dart'; import 'package:stream_chat_flutter/src/message_widget.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/system_message.dart'; import 'package:stream_chat_flutter/src/system_message.dart';
@@ -108,10 +111,11 @@ class MessageListView extends StatefulWidget {
this.onThreadTap, this.onThreadTap,
this.dateDividerBuilder, this.dateDividerBuilder,
this.scrollPhysics = const AlwaysScrollableScrollPhysics(), this.scrollPhysics = const AlwaysScrollableScrollPhysics(),
this.initialScrollIndex = 0, this.initialScrollIndex,
this.initialAlignment = 0, this.initialAlignment,
this.scrollController, this.scrollController,
this.itemPositionListener, this.itemPositionListener,
this.highlightInitialMessage = false,
}) : super(key: key); }) : super(key: key);
/// Function used to build a custom message widget /// Function used to build a custom message widget
@@ -153,6 +157,11 @@ class MessageListView extends StatefulWidget {
/// The ScrollPhysics used by the ListView /// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics; final ScrollPhysics scrollPhysics;
/// If true the list will highlight the initialMessage if there is any.
///
/// Also See [StreamChannel]
final bool highlightInitialMessage;
@override @override
_MessageListViewState createState() => _MessageListViewState(); _MessageListViewState createState() => _MessageListViewState();
} }
@@ -160,10 +169,50 @@ class MessageListView extends StatefulWidget {
class _MessageListViewState extends State<MessageListView> { class _MessageListViewState extends State<MessageListView> {
ItemScrollController _scrollController; ItemScrollController _scrollController;
bool _bottomWasVisible = false; bool _bottomWasVisible = false;
bool _topWasVisible = false;
Function _onThreadTap; Function _onThreadTap;
bool _showScrollToBottom = false; bool _showScrollToBottom = false;
ItemPositionsListener _itemPositionListener; ItemPositionsListener _itemPositionListener;
int _messageListLength;
int get _initialIndex {
if (widget.initialScrollIndex != null) return widget.initialScrollIndex;
final streamChannel = StreamChannel.of(context);
if (streamChannel.initialMessageId != null) {
final messages = streamChannel.channel.state.messages;
final totalMessages = messages.length;
final messageIndex = messages.indexWhere((e) {
return e.id == streamChannel.initialMessageId;
});
final index = totalMessages - messageIndex;
if (index != 0) return index - 1;
return index;
}
return 0;
}
double get _initialAlignment {
if (widget.initialAlignment != null) return widget.initialAlignment;
return 0;
}
bool _isInitialMessage(String id) {
final streamChannel = StreamChannel.of(context);
return streamChannel.initialMessageId == id;
}
bool get _upToDate => StreamChannel.of(context).channel.state.isUpToDate;
bool _topPaginationActive = false;
bool _bottomPaginationActive = false;
int initialIndex;
double initialAlignment;
List<Message> messages = <Message>[];
bool initialMessageHighlightComplete = false;
bool _inBetweenList = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -173,179 +222,257 @@ class _MessageListViewState extends State<MessageListView> {
? streamChannel.channel.state.threadsStream ? streamChannel.channel.state.threadsStream
.where((threads) => threads.containsKey(widget.parentMessage.id)) .where((threads) => threads.containsKey(widget.parentMessage.id))
.map((threads) => threads[widget.parentMessage.id]) .map((threads) => threads[widget.parentMessage.id])
: streamChannel.channel.state.messagesStream; : streamChannel.channel.state?.messagesStream;
return StreamBuilder<List<Message>>( return WillPopScope(
stream: messagesStream.map((messages) => messages onWillPop: () async {
.where((e) => if (!_upToDate) {
!e.isDeleted || await streamChannel.reloadChannel();
(e.isDeleted && }
e.user.id == streamChannel.channel.client.state.user.id)) return true;
.toList()), },
builder: (context, snapshot) { child: StreamBuilder<List<Message>>(
if (!snapshot.hasData) { stream: messagesStream?.map((messages) => messages
return Center( ?.where((e) =>
child: CircularProgressIndicator(), !e.isDeleted ||
); (e.isDeleted &&
} e.user.id == streamChannel.channel.client.state.user.id))
?.toList()),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
final messages = snapshot.data?.reversed?.toList() ?? []; final messageList = snapshot.data?.reversed?.toList() ?? [];
if (messages.isEmpty) { if (messageList.isEmpty) {
return Center( if (_upToDate) {
child: Text( return Center(
'No chats here yet...', child: Text(
style: TextStyle( 'No chats here yet...',
fontSize: 12, style: TextStyle(
color: Colors.black.withOpacity(.5), fontSize: 12,
), color: Colors.black.withOpacity(.5),
), ),
); ),
} );
}
} else {
messages = messageList;
}
return Stack( final newMessagesListLength = messages.length;
alignment: Alignment.center,
children: [ if (_messageListLength != null) {
ScrollablePositionedList.builder( if (_bottomPaginationActive || (_inBetweenList && _upToDate)) {
itemPositionsListener: _itemPositionListener, if (_itemPositionListener.itemPositions.value?.isNotEmpty ==
addAutomaticKeepAlives: true, true) {
key: Key('messageListView'), final first = _itemPositionListener.itemPositions.value.first;
initialScrollIndex: widget.initialScrollIndex, final diff = newMessagesListLength - _messageListLength;
initialAlignment: widget.initialAlignment, if (diff > 0) {
physics: widget.scrollPhysics, initialIndex = first.index + diff;
itemScrollController: _scrollController, initialAlignment = first.itemLeadingEdge;
reverse: true, }
itemCount: messages.length + }
1 + } else if (!_topPaginationActive && _upToDate) {
(widget.parentMessage != null ? 1 : 0), // Reset the index in-case we send any new message
itemBuilder: (context, i) { initialIndex = 0;
if (i == messages.length + 1) { initialAlignment = 0;
if (widget.parentMessageBuilder != null) { }
return widget.parentMessageBuilder( }
context,
widget.parentMessage, _messageListLength = newMessagesListLength;
);
} else { return Stack(
return Column( alignment: Alignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: <Widget>[ LazyLoadScrollView(
buildParentMessage(widget.parentMessage), onStartOfPage: () async {
Padding( _inBetweenList = false;
padding: const EdgeInsets.symmetric(horizontal: 32), if (!_upToDate) {
child: Container( _topPaginationActive = false;
padding: const EdgeInsets.all(8), _bottomPaginationActive = true;
child: Text( return _paginateData(
'Start of thread', streamChannel,
textAlign: TextAlign.center, QueryDirection.bottom,
),
color:
Theme.of(context).accentColor.withAlpha(50),
),
),
],
); );
} }
}
if (i == messages.length) {
return _buildLoadingIndicator(streamChannel);
}
final message = messages[i];
final nextMessage = i > 0 ? messages[i - 1] : null;
Widget messageWidget;
if (i == 0) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel,
);
} else if (i == messages.length - 1) {
messageWidget = _buildTopMessage(
context,
message,
messages,
streamChannel,
);
} else {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
context,
MessageDetails(
context,
message,
messages,
i,
),
messages),
);
} else {
messageWidget = buildMessage(message, messages, i);
}
}
if (nextMessage != null &&
!Jiffy(message.createdAt.toLocal())
.isSame(nextMessage.createdAt.toLocal(), Units.DAY)) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
messageWidget,
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
nextMessage.createdAt.toLocal())
: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
),
),
],
);
}
return messageWidget;
},
),
if (widget.showScrollToBottom && _showScrollToBottom)
_buildScrollToBottom(),
Positioned(
top: 20.0,
child: ValueListenableBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions,
builder: (context, values, _) {
final items = _itemPositionListener.itemPositions?.value;
if (items.isEmpty || messages.isEmpty) {
return SizedBox();
}
var index = _getTopElement(values).index;
if (index > messages.length) {
return SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
}, },
onEndOfPage: () async {
_inBetweenList = false;
_topPaginationActive = true;
_bottomPaginationActive = false;
return _paginateData(
streamChannel,
QueryDirection.top,
);
},
onInBetweenOfPage: () {
_inBetweenList = true;
},
child: ScrollablePositionedList.builder(
key: ValueKey(initialIndex + initialAlignment),
itemPositionsListener: _itemPositionListener,
addAutomaticKeepAlives: true,
initialScrollIndex: initialIndex ?? 0,
initialAlignment: initialAlignment ?? 0,
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: true,
itemCount: messages.length +
2 +
(widget.parentMessage != null ? 1 : 0),
itemBuilder: (context, i) {
if (i == messages.length + 2) {
if (widget.parentMessageBuilder != null) {
return widget.parentMessageBuilder(
context,
widget.parentMessage,
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
buildParentMessage(widget.parentMessage),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 32),
child: Container(
padding: const EdgeInsets.all(8),
child: Text(
'Start of thread',
textAlign: TextAlign.center,
),
color: Theme.of(context)
.accentColor
.withAlpha(50),
),
),
],
);
}
}
if (i == messages.length + 1) {
return _buildLoadingIndicator(
streamChannel,
QueryDirection.top,
);
}
if (i == 0) {
return _buildLoadingIndicator(
streamChannel,
QueryDirection.bottom,
);
}
final message = messages[i - 1];
final nextMessage = (i - 1) > 0 ? messages[i - 2] : null;
Widget messageWidget;
if (i == 1) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel,
);
} else if (i == messages.length - 1) {
messageWidget = _buildTopMessage(
context,
message,
messages,
streamChannel,
);
} else {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
context,
MessageDetails(
context,
message,
messages,
i,
),
messages),
);
} else {
messageWidget = buildMessage(message, messages, i);
}
}
if (nextMessage != null &&
!Jiffy(message.createdAt.toLocal()).isSame(
nextMessage.createdAt.toLocal(), Units.DAY)) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
messageWidget,
Padding(
padding:
const EdgeInsets.symmetric(vertical: 12.0),
child: widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
nextMessage.createdAt.toLocal())
: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
),
),
],
);
}
return messageWidget;
},
),
), ),
), if (widget.showScrollToBottom) _buildScrollToBottom(),
], Positioned(
); top: 20.0,
}); child: ValueListenableBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions,
builder: (context, values, _) {
final items = _itemPositionListener.itemPositions?.value;
if (items.isEmpty || messages.isEmpty) {
return SizedBox();
}
var index = _getTopElement(values).index;
if (index > messages.length) {
return SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
},
),
),
],
);
}),
);
}
Future<void> _paginateData(
StreamChannelState channel, QueryDirection direction) {
if (widget.parentMessage == null) {
return channel.queryMessages(direction: direction);
} else {
return channel.getReplies(widget.parentMessage.id);
}
} }
ItemPosition _getTopElement(Iterable<ItemPosition> values) { ItemPosition _getTopElement(Iterable<ItemPosition> values) {
@@ -357,91 +484,120 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildScrollToBottom() { Widget _buildScrollToBottom() {
final streamChannel = StreamChannel.of(context); final streamChannel = StreamChannel.of(context);
return Positioned( return StreamBuilder<Tuple2<bool, int>>(
bottom: 8, stream: Rx.combineLatest2(
right: 8, streamChannel.channel.state.isUpToDateStream,
width: 40, streamChannel.channel.state.unreadCountStream,
height: 40, (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount),
child: Stack( ),
clipBehavior: Clip.none, builder: (_, snapshot) {
children: [ if (snapshot.hasError) {
FloatingActionButton( return Offstage();
backgroundColor: Colors.white, } else if (!snapshot.hasData) {
child: StreamSvgIcon.down( return Offstage();
color: Colors.black, }
), final isUpToDate = snapshot.data.item1;
onPressed: () { final showScrollToBottom = !isUpToDate || _showScrollToBottom;
setState(() { if (!showScrollToBottom) {
_showScrollToBottom = false; return Offstage();
}); }
_scrollController.scrollTo( final unreadCount = snapshot.data.item2;
index: 0, final showUnreadCount = unreadCount > 0 &&
duration: Duration(seconds: 1), streamChannel.channel.state.members.any(
curve: Curves.easeInOut, (e) => e.userId == streamChannel.channel.client.state.user.id);
); return Positioned(
}, bottom: 8,
), right: 8,
if (streamChannel.channel.state.members.any((Member e) => width: 40,
e.userId == streamChannel.channel.client.state.user.id)) height: 40,
StreamBuilder<int>( child: Stack(
stream: streamChannel.channel.state.unreadCountStream, clipBehavior: Clip.none,
initialData: streamChannel.channel.state.unreadCount, children: [
builder: (context, snapshot) { FloatingActionButton(
if (!snapshot.hasData || snapshot.data <= 0) { backgroundColor: Colors.white,
return Offstage(); child: StreamSvgIcon.down(
color: Colors.black,
),
onPressed: () {
if (unreadCount > 0) {
streamChannel.channel.markRead();
} }
return Positioned( if (!_upToDate) {
width: 20, _bottomPaginationActive = false;
height: 20, _topPaginationActive = false;
left: 10, streamChannel.reloadChannel();
top: -10, } else {
child: CircleAvatar( setState(() => _showScrollToBottom = false);
child: Padding( _scrollController.scrollTo(
padding: const EdgeInsets.all(3.0), index: 0,
child: Text( duration: Duration(seconds: 1),
snapshot.data.toString(), curve: Curves.easeInOut,
style: TextStyle( );
fontSize: 11, }
fontWeight: FontWeight.bold, },
), ),
if (showUnreadCount)
Positioned(
width: 20,
height: 20,
left: 10,
top: -10,
child: CircleAvatar(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Text(
'$unreadCount',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
), ),
), ),
), ),
); ),
}), ),
], ],
), ),
);
},
); );
} }
Container _buildLoadingIndicator(StreamChannelState streamChannel) { Widget _buildLoadingIndicator(
return Container( StreamChannelState streamChannel,
key: Key('LOADING-INDICATOR'), QueryDirection direction,
height: 50, ) {
width: double.infinity, final stream = direction == QueryDirection.top
child: StreamBuilder<bool>( ? streamChannel.queryTopMessages
stream: streamChannel.queryMessage, : streamChannel.queryBottomMessages;
initialData: false, return StreamBuilder<bool>(
builder: (context, snapshot) { key: Key('LOADING-INDICATOR'),
if (snapshot.hasError) { stream: stream,
return Container( initialData: false,
color: Color(0xffd0021B).withAlpha(26), builder: (context, snapshot) {
child: Center( if (snapshot.hasError) {
child: Text('Error loading messages'), return Container(
), color: Color(0xffd0021B).withAlpha(26),
); child: Center(
} child: Text('Error loading messages'),
if (!snapshot.data) {
return SizedBox();
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
), ),
); );
}), }
); if (!snapshot.data) {
if (direction == QueryDirection.top) {
return Container(
height: 50,
width: double.infinity,
);
}
return Offstage();
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: const CircularProgressIndicator(),
),
);
});
} }
Widget _buildTopMessage( Widget _buildTopMessage(
@@ -468,22 +624,7 @@ class _MessageListViewState extends State<MessageListView> {
} else { } else {
messageWidget = buildMessage(message, messages, messages.length - 1); messageWidget = buildMessage(message, messages, messages.length - 1);
} }
return messageWidget;
return VisibilityDetector(
key: ValueKey<String>('TOP-MESSAGE'),
child: messageWidget,
onVisibilityChanged: (visibility) {
final topIsVisible = visibility.visibleBounds != Rect.zero;
if (topIsVisible && !_topWasVisible) {
if (widget.parentMessage == null) {
streamChannel.queryMessages();
} else {
streamChannel.getReplies(widget.parentMessage.id);
}
}
_topWasVisible = topIsVisible;
},
);
} }
Widget _buildBottomMessage( Widget _buildBottomMessage(
@@ -515,18 +656,17 @@ class _MessageListViewState extends State<MessageListView> {
key: ValueKey<String>('BOTTOM-MESSAGE'), key: ValueKey<String>('BOTTOM-MESSAGE'),
onVisibilityChanged: (visibility) { onVisibilityChanged: (visibility) {
final isVisible = visibility.visibleBounds != Rect.zero; final isVisible = visibility.visibleBounds != Rect.zero;
if (isVisible && if (isVisible && !_bottomWasVisible) {
!_bottomWasVisible && final channel = streamChannel.channel;
streamChannel.channel.config?.readEvents == true) { if (_upToDate &&
if (streamChannel.channel.state.unreadCount > 0) { channel.config?.readEvents == true &&
channel.state.unreadCount > 0) {
streamChannel.channel.markRead(); streamChannel.channel.markRead();
} }
_bottomWasVisible = !isVisible;
} }
_bottomWasVisible = isVisible;
if (mounted) { if (mounted) {
setState(() { setState(() => _showScrollToBottom = !isVisible);
_showScrollToBottom = !isVisible;
});
} }
}, },
child: messageWidget, child: messageWidget,
@@ -582,7 +722,7 @@ class _MessageListViewState extends State<MessageListView> {
final userId = StreamChat.of(context).user.id; final userId = StreamChat.of(context).user.id;
final isMyMessage = message.user.id == userId; final isMyMessage = message.user.id == userId;
final isNextUser = final isNextUser =
index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; index - 2 >= 0 && message.user.id == messages[index - 2]?.user?.id;
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
final readList = channel.state?.read final readList = channel.state?.read
@@ -597,7 +737,7 @@ class _MessageListViewState extends State<MessageListView> {
final allRead = readList.length >= (channel.memberCount ?? 0) - 1; final allRead = readList.length >= (channel.memberCount ?? 0) - 1;
return MessageWidget( Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'), key: ValueKey<String>('MESSAGE-${message.id}'),
message: message, message: message,
reverse: isMyMessage, reverse: isMyMessage,
@@ -605,7 +745,8 @@ class _MessageListViewState extends State<MessageListView> {
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 8.0, left: 8.0,
right: 8.0, right: 8.0,
bottom: index == 0 ? 30 : (isNextUser ? 5 : 10), bottom: index == 0 ? 30 : (isNextUser ? 2 : 7),
top: 3,
), ),
showUsername: !isMyMessage && !isNextUser, showUsername: !isMyMessage && !isNextUser,
showSendingIndicator: isMyMessage && showSendingIndicator: isMyMessage &&
@@ -639,6 +780,30 @@ class _MessageListViewState extends State<MessageListView> {
readList: readList, readList: readList,
allRead: allRead, allRead: allRead,
); );
if (!initialMessageHighlightComplete &&
widget.highlightInitialMessage &&
_isInitialMessage(message.id)) {
final accentColor = Theme.of(context).accentColor;
child = TweenAnimationBuilder<Color>(
tween: ColorTween(
begin: accentColor.withOpacity(0.7),
end: Colors.transparent,
),
duration: const Duration(seconds: 2),
child: child,
onEnd: () {
initialMessageHighlightComplete = true;
},
builder: (_, color, child) {
return Container(
color: color,
child: child,
);
},
);
}
return child;
} }
StreamSubscription _messageNewListener; StreamSubscription _messageNewListener;
@@ -651,26 +816,21 @@ class _MessageListViewState extends State<MessageListView> {
final streamChannel = StreamChannel.of(context); final streamChannel = StreamChannel.of(context);
initialIndex = _initialIndex;
initialAlignment = _initialAlignment;
_messageNewListener = _messageNewListener =
streamChannel.channel.on(EventType.messageNew).listen((event) { streamChannel.channel.on(EventType.messageNew).listen((event) {
final firstElementInViewport = if (_upToDate) {
_itemPositionListener.itemPositions.value.first; _bottomPaginationActive = false;
_topPaginationActive = false;
}
if (event.message.user.id == streamChannel.channel.client.state.user.id) { if (event.message.user.id == streamChannel.channel.client.state.user.id) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo( _scrollController.jumpTo(
index: 0, index: 0,
); );
}); });
} else {
if (firstElementInViewport.index != 0) {
_scrollController.jumpTo(
index: firstElementInViewport.index + 1,
alignment: firstElementInViewport.itemLeadingEdge,
);
}
}
if (firstElementInViewport.index == 0) {
streamChannel.channel.markRead();
} }
}); });
+6 -6
View File
@@ -77,16 +77,16 @@ class MessageSearchItem extends StatelessWidget {
} }
Widget _buildDate(BuildContext context, Message message) { Widget _buildDate(BuildContext context, Message message) {
final lastUpdatedAt = message.updatedAt; final createdAt = message.createdAt;
String stringDate; String stringDate;
final now = DateTime.now(); final now = DateTime.now();
if (now.year != lastUpdatedAt.year || if (now.year != createdAt.year ||
now.month != lastUpdatedAt.month || now.month != createdAt.month ||
now.day != lastUpdatedAt.day) { now.day != createdAt.day) {
stringDate = Jiffy(lastUpdatedAt.toLocal()).format('dd/MM/yyyy'); stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy');
} else { } else {
stringDate = Jiffy(lastUpdatedAt.toLocal()).format('HH:mm'); stringDate = Jiffy(createdAt.toLocal()).format('HH:mm');
} }
return Text( return Text(
+9 -6
View File
@@ -14,6 +14,10 @@ typedef MessageSearchItemTapCallback = void Function(GetMessageResponse);
typedef MessageSearchItemBuilder = Widget Function( typedef MessageSearchItemBuilder = Widget Function(
BuildContext, GetMessageResponse); BuildContext, GetMessageResponse);
/// Builder used when [MessageSearchListView] is empty
typedef EmptyMessageSearchBuilder = Widget Function(
BuildContext context, String searchQuery);
/// ///
/// It shows the list of searched messages. /// It shows the list of searched messages.
/// ///
@@ -85,7 +89,7 @@ class MessageSearchListView extends StatefulWidget {
final MessageSearchItemTapCallback onItemTap; final MessageSearchItemTapCallback onItemTap;
/// The builder used when the channel list is empty. /// The builder used when the channel list is empty.
final WidgetBuilder emptyBuilder; final EmptyMessageSearchBuilder emptyBuilder;
/// The builder that will be used in case of error /// The builder that will be used in case of error
final Widget Function(Error error) errorBuilder; final Widget Function(Error error) errorBuilder;
@@ -250,11 +254,10 @@ class _MessageSearchListViewState extends State<MessageSearchListView> {
final items = snapshot.data; final items = snapshot.data;
if (items.isEmpty && widget.emptyBuilder != null) { if (items.isEmpty) {
return widget.emptyBuilder(context); if (widget.emptyBuilder != null) {
} return widget.emptyBuilder(context, widget.messageQuery);
}
if (items.isEmpty && widget.emptyBuilder == null) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, viewportConstraints) { builder: (context, viewportConstraints) {
return SingleChildScrollView( return SingleChildScrollView(
+247 -85
View File
@@ -4,23 +4,29 @@ import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
enum QueryDirection { top, bottom }
/// Widget used to provide information about the channel to the widget tree /// Widget used to provide information about the channel to the widget tree
/// ///
/// Use [StreamChannel.of] to get the current [StreamChannelState] instance. /// Use [StreamChannel.of] to get the current [StreamChannelState] instance.
class StreamChannel extends StatefulWidget { class StreamChannel extends StatefulWidget {
StreamChannel({ const StreamChannel({
Key key, Key key,
@required this.child, @required this.child,
@required this.channel, @required this.channel,
this.showLoading = true, this.showLoading = true,
}) : super( this.initialMessageId,
key: key, }) : assert(child != null),
); assert(channel != null),
super(key: key);
final Widget child; final Widget child;
final Channel channel; final Channel channel;
final bool showLoading; final bool showLoading;
/// If passed the channel will load from this particular message.
final String initialMessageId;
/// Use this method to get the current [StreamChannelState] instance /// Use this method to get the current [StreamChannelState] instance
static StreamChannelState of(BuildContext context) { static StreamChannelState of(BuildContext context) {
StreamChannelState streamChannelState; StreamChannelState streamChannelState;
@@ -43,87 +49,123 @@ class StreamChannelState extends State<StreamChannel> {
/// Current channel /// Current channel
Channel get channel => widget.channel; Channel get channel => widget.channel;
/// InitialMessageId
String get initialMessageId => widget.initialMessageId;
/// Current channel state stream /// Current channel state stream
Stream<ChannelState> get channelStateStream => Stream<ChannelState> get channelStateStream =>
widget.channel.state.channelStateStream; widget.channel.state.channelStateStream;
final BehaviorSubject<bool> _queryMessageController = BehaviorSubject(); final _queryTopMessagesController = BehaviorSubject.seeded(false);
final _queryBottomMessagesController = BehaviorSubject.seeded(false);
/// The stream notifying the state of queryMessage call /// The stream notifying the state of [_queryTopMessages] call
Stream<bool> get queryMessage => _queryMessageController.stream; Stream<bool> get queryTopMessages => _queryTopMessagesController.stream;
bool _paginationEnded = false; /// The stream notifying the state of [_queryBottomMessages] call
Stream<bool> get queryBottomMessages => _queryBottomMessagesController.stream;
/// Calls [channel.query] updating [queryMessage] stream bool _topPaginationEnded = false;
void queryMessages() { bool _bottomPaginationEnded = false;
if (_queryMessageController.value == true || _paginationEnded) {
Future<void> _queryTopMessages({
int limit = 20,
bool preferOffline = false,
}) async {
if (_topPaginationEnded || _queryTopMessagesController?.value == true) {
return; return;
} }
_queryTopMessagesController.add(true);
_queryMessageController.add(true); if (channel.state.messages.isEmpty) {
return _queryTopMessagesController.add(false);
String firstId;
if (channel.state.messages.isNotEmpty) {
firstId = channel.state.messages.first.id;
} }
final messageLimit = 50; final oldestMessage = channel.state.messages.first;
widget.channel try {
.query( final state = await queryBeforeMessage(
messagesPagination: PaginationParams( oldestMessage.id,
lessThan: firstId, limit: limit,
limit: messageLimit, preferOffline: preferOffline,
), );
preferOffline: true, if (state.messages.isEmpty || state.messages.length < limit) {
) _topPaginationEnded = true;
.then((res) {
if (res.messages.isEmpty || res.messages.length < messageLimit) {
_paginationEnded = true;
} }
_queryMessageController.add(false); _queryTopMessagesController.add(false);
}).catchError((e, stack) { } catch (e, stk) {
if (!_queryMessageController.isClosed) { _queryTopMessagesController.addError(e, stk);
_queryMessageController.addError(e, stack); }
}
Future<void> _queryBottomMessages({
int limit = 20,
bool preferOffline = false,
}) async {
if (_bottomPaginationEnded ||
_queryBottomMessagesController?.value == true ||
channel?.state?.isUpToDate == true) return;
_queryBottomMessagesController.add(true);
if (channel.state.messages.isEmpty) {
return _queryBottomMessagesController.add(false);
}
final recentMessage = channel.state.messages.last;
try {
final state = await queryAfterMessage(
recentMessage.id,
limit: limit,
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
_bottomPaginationEnded = true;
} }
}); _queryBottomMessagesController.add(false);
} catch (e, stk) {
_queryBottomMessagesController.addError(e, stk);
}
}
/// Calls [channel.query] updating [queryMessage] stream
Future<void> queryMessages({QueryDirection direction = QueryDirection.top}) {
if (direction == QueryDirection.top) return _queryTopMessages();
return _queryBottomMessages();
} }
/// Calls [channel.getReplies] updating [queryMessage] stream /// Calls [channel.getReplies] updating [queryMessage] stream
Future<void> getReplies(String parentId) async { Future<void> getReplies(
if (_queryMessageController.value == true || _paginationEnded) { String parentId, {
return; int limit = 50,
bool preferOffline = false,
}) async {
if (_topPaginationEnded || _queryTopMessagesController.value) return;
_queryTopMessagesController.add(true);
if (!channel.state.threads.containsKey(parentId)) {
return _queryTopMessagesController.add(false);
} }
_queryMessageController.add(true); final thread = channel.state.threads[parentId];
String firstId; if (thread.isEmpty) return _queryTopMessagesController.add(false);
if (widget.channel.state.threads.containsKey(parentId)) {
final thread = widget.channel.state.threads[parentId];
if (thread != null && thread.isNotEmpty) { final message = thread.first;
firstId = thread?.first?.id;
try {
final state = await queryBeforeMessage(
message.id,
limit: limit,
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
_topPaginationEnded = true;
} }
_queryTopMessagesController.add(false);
} catch (e, stk) {
_queryTopMessagesController.addError(e, stk);
} }
final messageLimit = 50;
return widget.channel
.getReplies(
parentId,
PaginationParams(
lessThan: firstId,
limit: messageLimit,
),
preferOffline: true,
)
.then((res) {
if (res.messages.isEmpty || res.messages.length < messageLimit) {
_paginationEnded = true;
}
_queryMessageController.add(false);
}).catchError((e, stack) {
_queryMessageController.addError(e, stack);
});
} }
/// Query the channel members and watchers /// Query the channel members and watchers
@@ -140,41 +182,161 @@ class StreamChannelState extends State<StreamChannel> {
); );
} }
/// Loads channel at specific message
Future<void> loadChannelAtMessage(
String messageId, {
int before = 20,
int after = 20,
bool preferOffline = false,
}) {
return queryAtMessage(
messageId: messageId,
before: before,
after: after,
preferOffline: preferOffline,
);
}
///
Future<void> queryAtMessage({
String messageId,
int before = 20,
int after = 20,
bool preferOffline = false,
}) async {
if (channel.state == null) return;
channel.state.isUpToDate = false;
channel.state.truncate();
if (messageId == null) {
await channel.query(
messagesPagination: PaginationParams(
limit: before,
),
preferOffline: preferOffline,
);
channel.state.isUpToDate = true;
return;
}
return Future.wait([
queryBeforeMessage(
messageId,
limit: before,
preferOffline: preferOffline,
),
queryAfterMessage(
messageId,
limit: after,
preferOffline: preferOffline,
),
]);
}
///
Future<ChannelState> queryBeforeMessage(
String messageId, {
int limit = 20,
bool preferOffline = false,
}) {
return channel.query(
messagesPagination: PaginationParams(
lessThan: messageId,
limit: limit,
),
preferOffline: preferOffline,
);
}
///
Future<ChannelState> queryAfterMessage(
String messageId, {
int limit = 20,
bool preferOffline = false,
}) async {
final state = await channel.query(
messagesPagination: PaginationParams(
greaterThanOrEqual: messageId,
limit: limit,
),
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
channel.state.isUpToDate = true;
}
return state;
}
/// Reloads the channel with latest message
Future<void> reloadChannel() => queryAtMessage(before: 30);
List<Future<bool>> _futures;
Future<bool> get _loadChannelAtMessage async {
try {
await loadChannelAtMessage(initialMessageId);
return true;
} catch (e, stk) {
print('Error: $e\nStack: $stk');
rethrow;
}
}
@override
void initState() {
super.initState();
_futures = [widget.channel.initialized];
if (initialMessageId != null) {
_futures.add(_loadChannelAtMessage);
}
}
@override @override
void dispose() { void dispose() {
_queryMessageController.close(); _queryTopMessagesController.close();
_queryBottomMessagesController.close();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.channel == null) { Widget child = FutureBuilder<List<bool>>(
return Center( future: Future.wait(_futures),
child: CircularProgressIndicator(), initialData: [
); channel.state != null,
} if (initialMessageId != null) false,
return FutureBuilder<bool>( ],
future: widget.channel.initialized,
initialData: widget.channel.state != null,
builder: (context, snapshot) { builder: (context, snapshot) {
if (widget.showLoading && (!snapshot.hasData || !snapshot.data)) { if (snapshot.hasError) {
return Container( if (snapshot.error is Error) {
height: 30, print((snapshot.error as Error).stackTrace);
child: Center( }
child: CircularProgressIndicator(), 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: Text(message),
); );
} else if (snapshot.hasError) {
return Container(
height: 30,
child: Center(
child: Text(snapshot.error),
),
);
} else {
return widget.child;
} }
final initialized = snapshot.data[0];
final dataLoaded = initialMessageId == null ? true : snapshot.data[1];
if (widget.showLoading && (!initialized || !dataLoaded)) {
return Center(
child: CircularProgressIndicator(),
);
}
return widget.child;
}, },
); );
if (initialMessageId != null) {
child = Material(child: child);
}
return child;
} }
} }
+3 -1
View File
@@ -90,6 +90,8 @@ class UserItem extends StatelessWidget {
} }
Widget _buildLastActive(context) { Widget _buildLastActive(context) {
return Text('Last online ${Jiffy(user.lastActive).fromNow()}'); return user.online == true
? Text('Online')
: Text('Last online ${Jiffy(user.lastActive).fromNow()}');
} }
} }
+4 -2
View File
@@ -190,7 +190,7 @@ class _UserListViewState extends State<UserListView>
} }
final groupedUsers = <String, List<User>>{}; final groupedUsers = <String, List<User>>{};
for (var e in temp) { for (var e in temp) {
final alphabet = e.name[0]; final alphabet = e.name[0]?.toUpperCase();
groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e];
} }
final items = <ListItem>[]; final items = <ListItem>[];
@@ -339,7 +339,9 @@ class _UserListViewState extends State<UserListView>
); );
return LazyLoadScrollView( return LazyLoadScrollView(
onEndOfPage: () => _listenUserPagination(usersBlocState), onEndOfPage: () async {
return _listenUserPagination(usersBlocState);
},
child: child, child: child,
); );
}, },
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies:
file_picker: ^2.0.12 file_picker: ^2.0.12
image_picker: ^0.6.7+2 image_picker: ^0.6.7+2
flutter_keyboard_visibility: ^3.3.0 flutter_keyboard_visibility: ^3.3.0
stream_chat: ^0.2.19 stream_chat: ^0.2.20
mime: ^0.9.6+3 mime: ^0.9.6+3
video_compress: ^2.1.1 video_compress: ^2.1.1
visibility_detector: ^0.1.5 visibility_detector: ^0.1.5