Merge branch 'develop' into perf

This commit is contained in:
Salvatore Giordano
2021-06-02 09:52:25 +02:00
committed by GitHub
21 changed files with 500 additions and 111 deletions
@@ -510,9 +510,9 @@ class Channel {
/// Pins provided message /// Pins provided message
Future<UpdateMessageResponse> pinMessage( Future<UpdateMessageResponse> pinMessage(
Message message, Message message, [
Object? timeoutOrExpirationDate, Object? timeoutOrExpirationDate,
) { ]) {
assert(() { assert(() {
if (timeoutOrExpirationDate is! DateTime && if (timeoutOrExpirationDate is! DateTime &&
timeoutOrExpirationDate != null && timeoutOrExpirationDate != null &&
@@ -520,7 +520,7 @@ class Channel {
throw ArgumentError('Invalid timeout or Expiration date'); throw ArgumentError('Invalid timeout or Expiration date');
} }
return true; return true;
}(), 'Check for invalid token or expiration date'); }(), 'Check whether timeout is valid');
DateTime? pinExpires; DateTime? pinExpires;
if (timeoutOrExpirationDate is DateTime) { if (timeoutOrExpirationDate is DateTime) {
@@ -100,7 +100,7 @@ class WebSocket {
/// connection unhealthy /// connection unhealthy
final int reconnectionMonitorTimeout; final int reconnectionMonitorTimeout;
final _connectionStatusController = final BehaviorSubject<ConnectionStatus> _connectionStatusController =
BehaviorSubject.seeded(ConnectionStatus.disconnected); BehaviorSubject.seeded(ConnectionStatus.disconnected);
set _connectionStatus(ConnectionStatus status) => set _connectionStatus(ConnectionStatus status) =>
+14 -7
View File
@@ -1361,12 +1361,13 @@ class StreamChatClient {
/// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds
/// to be added to [DateTime.now] /// to be added to [DateTime.now]
Future<UpdateMessageResponse> pinMessage( Future<UpdateMessageResponse> pinMessage(
Message message, Message message, [
Object timeoutOrExpirationDate, Object? timeoutOrExpirationDate,
) { ]) {
assert(() { assert(() {
if (timeoutOrExpirationDate is! DateTime && if (timeoutOrExpirationDate is! DateTime &&
timeoutOrExpirationDate is! num) { timeoutOrExpirationDate is! num &&
timeoutOrExpirationDate != null) {
throw ArgumentError('Invalid timeout or Expiration date'); throw ArgumentError('Invalid timeout or Expiration date');
} }
return true; return true;
@@ -1383,13 +1384,19 @@ class StreamChatClient {
.toUtc(); .toUtc();
} }
return updateMessage( return updateMessage(
message.copyWith(pinned: true, pinExpires: pinExpires), message.copyWith(
pinned: true,
pinExpires: pinExpires,
),
); );
} }
/// Unpins provided message /// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) => Future<UpdateMessageResponse> unpinMessage(Message message) => updateMessage(
updateMessage(message.copyWith(pinned: false)); message.copyWith(
pinned: false,
),
);
} }
/// The class that handles the state of the channel listening to the events /// The class that handles the state of the channel listening to the events
@@ -449,6 +449,51 @@ void main() {
.called(1); .called(1);
}); });
test('should be pinned successfully with null timeout', () async {
final mockDio = MockDio();
when(() => mockDio.options).thenReturn(BaseOptions());
when(() => mockDio.interceptors).thenReturn(Interceptors());
final client = StreamChatClient(
'api-key',
httpClient: mockDio,
tokenProvider: (_) async => '',
);
final channelClient = client.channel('messaging', id: 'testid');
final message = Message(text: 'Hello', id: 'test');
when(() => mockDio.post<String>(
any(),
data: any(named: 'data'),
)).thenAnswer((_) async => Response(
data: jsonEncode(ChannelState()),
statusCode: 200,
requestOptions: FakeRequestOptions(),
));
await channelClient.watch();
when(
() => mockDio.post<String>(
'/messages/${message.id}',
data: anything,
),
).thenAnswer(
(_) async => Response(
data: jsonEncode({'message': message}),
statusCode: 200,
requestOptions: FakeRequestOptions(),
),
);
await channelClient.pinMessage(message);
verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything))
.called(1);
});
test('should be unpinned successfully', () async { test('should be unpinned successfully', () async {
final mockDio = MockDio(); final mockDio = MockDio();
@@ -1112,6 +1112,28 @@ void main() {
data: {'message': anything})).called(1); data: {'message': anything})).called(1);
}); });
test('should complete successfully with a null value', () async {
final message = Message(text: 'Hello');
when(
() => mockDio.post<String>(
'/messages/${message.id}',
data: anything,
),
).thenAnswer(
(_) async => Response(
data: jsonEncode({'message': message}),
statusCode: 200,
requestOptions: FakeRequestOptions(),
),
);
await client.pinMessage(message);
verify(() => mockDio.post<String>('/messages/${message.id}',
data: {'message': anything})).called(1);
});
test('should unpin message successfully', () async { test('should unpin message successfully', () async {
final message = Message(text: 'Hello'); final message = Message(text: 'Hello');
@@ -4,7 +4,6 @@ import 'package:stream_chat_persistence/stream_chat_persistence.dart';
final chatPersistentClient = StreamChatPersistenceClient( final chatPersistentClient = StreamChatPersistenceClient(
logLevel: Level.INFO, logLevel: Level.INFO,
connectionMode: ConnectionMode.background,
); );
void main() async { void main() async {
@@ -137,6 +137,7 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
showMessage: showConnectionStateTile ? showStatus : false, showMessage: showConnectionStateTile ? showStatus : false,
message: statusString, message: statusString,
child: AppBar( child: AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
leading: leadingWidget, leading: leadingWidget,
@@ -120,6 +120,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
showMessage: showConnectionStateTile ? showStatus : false, showMessage: showConnectionStateTile ? showStatus : false,
message: statusString, message: statusString,
child: AppBar( child: AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
backgroundColor: chatThemeData.channelListHeaderTheme.color, backgroundColor: chatThemeData.channelListHeaderTheme.color,
@@ -12,6 +12,9 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Callback called when tapping on a channel /// Callback called when tapping on a channel
typedef ChannelTapCallback = void Function(Channel, Widget?); typedef ChannelTapCallback = void Function(Channel, Widget?);
/// Callback called when tapping on a channel
typedef ChannelInfoCallback = void Function(Channel);
/// Builder used to create a custom [ChannelPreview] from a [Channel] /// Builder used to create a custom [ChannelPreview] from a [Channel]
typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel);
@@ -78,6 +81,9 @@ class ChannelListView extends StatefulWidget {
this.emptyBuilder, this.emptyBuilder,
this.loadingBuilder, this.loadingBuilder,
this.listBuilder, this.listBuilder,
this.onMoreDetailsPressed,
this.onDeletePressed,
this.swipeActions,
}) : super(key: key); }) : super(key: key);
/// If true a default swipe to action behaviour will be added to this widget /// If true a default swipe to action behaviour will be added to this widget
@@ -158,6 +164,15 @@ class ChannelListView extends StatefulWidget {
/// The builder used when the channel list is empty. /// The builder used when the channel list is empty.
final WidgetBuilder? emptyBuilder; final WidgetBuilder? emptyBuilder;
/// Callback used when the more details slidable option is pressed
final ChannelInfoCallback? onMoreDetailsPressed;
/// Callback used when the delete slidable option is pressed
final ChannelInfoCallback? onDeletePressed;
/// List of actions for slidable
final List<SwipeAction>? swipeActions;
@override @override
_ChannelListViewState createState() => _ChannelListViewState(); _ChannelListViewState createState() => _ChannelListViewState();
} }
@@ -466,61 +481,79 @@ class _ChannelListViewState extends State<ChannelListView> {
enabled: widget.swipeToAction, enabled: widget.swipeToAction,
actionPane: const SlidableBehindActionPane(), actionPane: const SlidableBehindActionPane(),
actionExtentRatio: 0.12, actionExtentRatio: 0.12,
secondaryActions: <Widget>[ secondaryActions: widget.swipeActions
IconSlideAction( ?.map((e) => IconSlideAction(
color: backgroundColor, color: e.color,
icon: Icons.more_horiz, iconWidget: e.iconWidget,
onTap: () { onTap: () {
showModalBottomSheet( e.onTap?.call(channel);
clipBehavior: Clip.hardEdge, },
shape: const RoundedRectangleBorder( ))
borderRadius: BorderRadius.only( .toList() ??
topLeft: Radius.circular(32), <Widget>[
topRight: Radius.circular(32), IconSlideAction(
), color: backgroundColor,
), icon: Icons.more_horiz,
context: context, onTap: widget.onMoreDetailsPressed != null
builder: (context) => StreamChannel( ? () {
channel: channel, widget.onMoreDetailsPressed!(channel);
child: ChannelBottomSheet( }
onViewInfoTap: () { : () {
widget.onViewInfoTap?.call(channel); showModalBottomSheet(
}, clipBehavior: Clip.hardEdge,
), shape: const RoundedRectangleBorder(
), borderRadius: BorderRadius.only(
); topLeft: Radius.circular(32),
}, topRight: Radius.circular(32),
), ),
if ([ ),
'admin', context: context,
'owner', builder: (context) => StreamChannel(
].contains(channel.state!.members channel: channel,
.firstWhereOrNull( child: ChannelBottomSheet(
(m) => m.userId == channel.client.state.user?.id) onViewInfoTap: () {
?.role)) widget.onViewInfoTap?.call(channel);
IconSlideAction( },
color: backgroundColor, ),
iconWidget: StreamSvgIcon.delete( ),
color: chatThemeData.colorTheme.accentRed, );
},
), ),
onTap: () async { if ([
final res = await showConfirmationDialog( 'admin',
context, 'owner',
title: 'Delete Conversation', ].contains(channel.state!.members
okText: 'DELETE', .firstWhereOrNull(
question: (m) => m.userId == channel.client.state.user?.id)
'Are you sure you want to delete this conversation?', ?.role))
cancelText: 'CANCEL', IconSlideAction(
icon: StreamSvgIcon.delete( color: backgroundColor,
iconWidget: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed, color: chatThemeData.colorTheme.accentRed,
), ),
); onTap: widget.onDeletePressed != null
if (res == true) { ? () {
await channel.delete(); widget.onDeletePressed!(channel);
} }
}, : () async {
), final res = await showConfirmationDialog(
], context,
title: 'Delete Conversation',
okText: 'DELETE',
question:
// ignore: lines_longer_than_80_chars
'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: chatThemeData.colorTheme.accentRed,
),
);
if (res == true) {
await channel.delete();
}
},
),
],
child: Container( child: Container(
color: chatThemeData.colorTheme.whiteSnow, color: chatThemeData.colorTheme.whiteSnow,
child: widget.channelPreviewBuilder?.call(context, channel) ?? child: widget.channelPreviewBuilder?.call(context, channel) ??
@@ -641,3 +674,22 @@ class _ChannelListViewState extends State<ChannelListView> {
); );
} }
} }
/// Class for slidable action
class SwipeAction {
/// Constructor for creating [SwipeAction]
SwipeAction({
this.color,
required this.iconWidget,
this.onTap,
});
/// Background color of action
Color? color;
/// Widget to display as icon
Widget iconWidget;
/// Callback when icon is tapped
ChannelInfoCallback? onTap;
}
@@ -53,6 +53,7 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
return AppBar( return AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
leading: showBackButton leading: showBackButton
@@ -571,9 +571,9 @@ class _MessageListViewState extends State<MessageListView> {
return const SizedBox(); return const SizedBox();
} }
var index = _getTopElement(values).index; var index = _getTopElement(values)?.index;
if (index > messages.length) { if (index == null || index > messages.length) {
return const SizedBox(); return const SizedBox();
} }
@@ -599,10 +599,17 @@ class _MessageListViewState extends State<MessageListView> {
StreamChannelState? channel, QueryDirection direction) => StreamChannelState? channel, QueryDirection direction) =>
_messageListController.paginateData!(direction: direction); _messageListController.paginateData!(direction: direction);
ItemPosition _getTopElement(Iterable<ItemPosition> values) => values ItemPosition? _getTopElement(Iterable<ItemPosition> values) {
.where((ItemPosition position) => position.itemLeadingEdge < 0.9) final inView =
.reduce((ItemPosition max, ItemPosition position) => values.where((ItemPosition position) => position.itemLeadingEdge < 0.9);
position.itemLeadingEdge > max.itemLeadingEdge ? position : max);
if (inView.isEmpty) {
return null;
}
return inView.reduce((ItemPosition max, ItemPosition position) =>
position.itemLeadingEdge > max.itemLeadingEdge ? position : max);
}
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>( Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2( stream: Rx.combineLatest2(
@@ -39,6 +39,7 @@ class StreamChat extends StatefulWidget {
this.streamChatThemeData, this.streamChatThemeData,
this.onBackgroundEventReceived, this.onBackgroundEventReceived,
this.backgroundKeepAlive = const Duration(minutes: 1), this.backgroundKeepAlive = const Duration(minutes: 1),
this.connectivityStream,
}) : super(key: key); }) : super(key: key);
/// Client to do chat ops with /// Client to do chat ops with
@@ -59,6 +60,11 @@ class StreamChat extends StatefulWidget {
/// upon the [Event.type] /// upon the [Event.type]
final EventHandler? onBackgroundEventReceived; final EventHandler? onBackgroundEventReceived;
/// Stream of connectivity result
/// Visible for testing
@visibleForTesting
final Stream<ConnectivityResult>? connectivityStream;
@override @override
StreamChatState createState() => StreamChatState(); StreamChatState createState() => StreamChatState();
@@ -102,6 +108,7 @@ class StreamChatState extends State<StreamChat> {
client: client, client: client,
onBackgroundEventReceived: widget.onBackgroundEventReceived, onBackgroundEventReceived: widget.onBackgroundEventReceived,
backgroundKeepAlive: widget.backgroundKeepAlive, backgroundKeepAlive: widget.backgroundKeepAlive,
connectivityStream: widget.connectivityStream,
child: widget.child ?? const Offstage(), child: widget.child ?? const Offstage(),
), ),
); );
@@ -230,22 +230,23 @@ class StreamChatThemeData {
), ),
), ),
channelPreviewTheme: ChannelPreviewTheme( channelPreviewTheme: ChannelPreviewTheme(
unreadCounterColor: colorTheme.accentRed, unreadCounterColor: colorTheme.accentRed,
avatarTheme: AvatarTheme( avatarTheme: AvatarTheme(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
constraints: const BoxConstraints.tightFor( constraints: const BoxConstraints.tightFor(
height: 40, height: 40,
width: 40, width: 40,
),
), ),
title: textTheme.bodyBold, ),
subtitle: textTheme.footnote.copyWith( title: textTheme.bodyBold,
color: const Color(0xff7A7A7A), subtitle: textTheme.footnote.copyWith(
), color: const Color(0xff7A7A7A),
lastMessageAt: textTheme.footnote.copyWith( ),
color: colorTheme.black.withOpacity(.5), lastMessageAt: textTheme.footnote.copyWith(
), color: colorTheme.black.withOpacity(.5),
indicatorIconSize: 16), ),
indicatorIconSize: 16,
),
channelListHeaderTheme: ChannelListHeaderTheme( channelListHeaderTheme: ChannelListHeaderTheme(
avatarTheme: AvatarTheme( avatarTheme: AvatarTheme(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -1186,7 +1187,8 @@ class MessageInputTheme {
actionButtonIdleColor: other.actionButtonIdleColor, actionButtonIdleColor: other.actionButtonIdleColor,
sendButtonColor: other.sendButtonColor, sendButtonColor: other.sendButtonColor,
sendButtonIdleColor: other.sendButtonIdleColor, sendButtonIdleColor: other.sendButtonIdleColor,
inputTextStyle: other.inputTextStyle, inputTextStyle:
inputTextStyle?.merge(other.inputTextStyle) ?? other.inputTextStyle,
inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? inputDecoration: inputDecoration?.merge(other.inputDecoration) ??
other.inputDecoration, other.inputDecoration,
activeBorderGradient: other.activeBorderGradient, activeBorderGradient: other.activeBorderGradient,
@@ -101,6 +101,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
return AppBar( return AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness, brightness: Theme.of(context).brightness,
elevation: 1, elevation: 1,
leading: leading ?? leading: leading ??
@@ -79,6 +79,7 @@ void main() {
), ),
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
), ),
surfaceSize: const Size.square(200), surfaceSize: const Size.square(200),
@@ -131,6 +132,7 @@ void main() {
), ),
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
), ),
surfaceSize: const Size.square(200), surfaceSize: const Size.square(200),
@@ -187,6 +189,7 @@ void main() {
), ),
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
), ),
surfaceSize: const Size.square(200), surfaceSize: const Size.square(200),
@@ -60,6 +60,7 @@ void main() {
maskColor: theme.ownMessageTheme.reactionsMaskColor!, maskColor: theme.ownMessageTheme.reactionsMaskColor!,
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
surfaceSize: const Size(100, 100), surfaceSize: const Size(100, 100),
); );
@@ -96,6 +97,7 @@ void main() {
maskColor: theme.ownMessageTheme.reactionsMaskColor!, maskColor: theme.ownMessageTheme.reactionsMaskColor!,
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
surfaceSize: const Size(100, 100), surfaceSize: const Size(100, 100),
); );
@@ -118,6 +120,7 @@ void main() {
StreamChat( StreamChat(
client: client, client: client,
streamChatThemeData: StreamChatThemeData.fromTheme(themeData), streamChatThemeData: StreamChatThemeData.fromTheme(themeData),
connectivityStream: Stream.value(ConnectivityResult.mobile),
child: Container( child: Container(
color: Colors.black, color: Colors.black,
child: ReactionBubble( child: ReactionBubble(
@@ -162,6 +165,7 @@ void main() {
StreamChat( StreamChat(
client: client, client: client,
streamChatThemeData: StreamChatThemeData.fromTheme(themeData), streamChatThemeData: StreamChatThemeData.fromTheme(themeData),
connectivityStream: Stream.value(ConnectivityResult.mobile),
child: Container( child: Container(
color: Colors.black, color: Colors.black,
child: ReactionBubble( child: ReactionBubble(
@@ -204,6 +208,7 @@ void main() {
await tester.pumpWidgetBuilder( await tester.pumpWidgetBuilder(
StreamChat( StreamChat(
client: client, client: client,
connectivityStream: Stream.value(ConnectivityResult.mobile),
streamChatThemeData: StreamChatThemeData.fromTheme(themeData), streamChatThemeData: StreamChatThemeData.fromTheme(themeData),
child: SizedBox( child: SizedBox(
child: ReactionBubble( child: ReactionBubble(
@@ -100,6 +100,7 @@ void main() {
), ),
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
), ),
surfaceSize: const Size.square(200), surfaceSize: const Size.square(200),
@@ -151,6 +152,7 @@ void main() {
), ),
), ),
), ),
connectivityStream: Stream.value(ConnectivityResult.mobile),
), ),
), ),
surfaceSize: const Size.square(200), surfaceSize: const Size.square(200),
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
@@ -43,6 +44,7 @@ class StreamChatCore extends StatefulWidget {
required this.child, required this.child,
this.onBackgroundEventReceived, this.onBackgroundEventReceived,
this.backgroundKeepAlive = const Duration(minutes: 1), this.backgroundKeepAlive = const Duration(minutes: 1),
this.connectivityStream,
}) : super(key: key); }) : super(key: key);
/// Instance of Stream Chat Client containing information about the current /// Instance of Stream Chat Client containing information about the current
@@ -61,6 +63,11 @@ class StreamChatCore extends StatefulWidget {
/// upon the [Event.type] /// upon the [Event.type]
final EventHandler? onBackgroundEventReceived; final EventHandler? onBackgroundEventReceived;
/// Stream of connectivity result
/// Visible for testing
@visibleForTesting
final Stream<ConnectivityResult>? connectivityStream;
@override @override
StreamChatCoreState createState() => StreamChatCoreState(); StreamChatCoreState createState() => StreamChatCoreState();
@@ -96,48 +103,104 @@ class StreamChatCoreState extends State<StreamChatCore>
/// The current user as a stream /// The current user as a stream
Stream<User?> get userStream => client.state.userStream; Stream<User?> get userStream => client.state.userStream;
StreamSubscription<ConnectivityResult>? _connectivitySubscription;
var _isInForeground = true;
var _isConnectionAvailable = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance?.addObserver(this);
_subscribeToConnectivityChange(widget.connectivityStream);
}
void _subscribeToConnectivityChange([
Stream<ConnectivityResult>? connectivityStream,
]) {
if (_connectivitySubscription == null) {
connectivityStream ??= Connectivity().onConnectivityChanged;
_connectivitySubscription =
connectivityStream.distinct().listen((result) {
_isConnectionAvailable = result != ConnectivityResult.none;
if (!_isInForeground) return;
if (_isConnectionAvailable) {
if (client.wsConnectionStatus == ConnectionStatus.disconnected) {
client.connect();
}
} else {
if (client.wsConnectionStatus == ConnectionStatus.connected) {
client.disconnect();
}
}
});
}
}
void _unsubscribeFromConnectivityChange() {
if (_connectivitySubscription != null) {
_connectivitySubscription?.cancel();
_connectivitySubscription = null;
}
}
@override
void didUpdateWidget(StreamChatCore oldWidget) {
super.didUpdateWidget(oldWidget);
final connectivityStream = widget.connectivityStream;
if (connectivityStream != oldWidget.connectivityStream) {
_unsubscribeFromConnectivityChange();
_subscribeToConnectivityChange(connectivityStream);
}
} }
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_isInForeground = state == AppLifecycleState.resumed;
if (user != null) { if (user != null) {
if (state == AppLifecycleState.paused) { if (_isInForeground) {
if (widget.onBackgroundEventReceived == null) { _onForeground();
client.disconnect(); } else {
return; _onBackground();
}
_eventSubscription = client.on().listen(
widget.onBackgroundEventReceived,
);
void onTimerComplete() {
_eventSubscription?.cancel();
client.disconnect();
}
_disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete);
} else if (state == AppLifecycleState.resumed) {
if (_disconnectTimer?.isActive == true) {
_eventSubscription?.cancel();
_disconnectTimer?.cancel();
} else {
if (client.wsConnectionStatus == ConnectionStatus.disconnected) {
client.connect();
}
}
} }
} }
} }
void _onForeground() {
if (_disconnectTimer?.isActive == true) {
_eventSubscription?.cancel();
_disconnectTimer?.cancel();
} else if (client.wsConnectionStatus == ConnectionStatus.disconnected &&
_isConnectionAvailable) {
client.connect();
}
}
void _onBackground() {
if (widget.onBackgroundEventReceived == null) {
if (client.wsConnectionStatus != ConnectionStatus.disconnected) {
client.disconnect();
}
return;
}
_eventSubscription = client.on().listen(widget.onBackgroundEventReceived);
void onTimerComplete() {
_eventSubscription?.cancel();
client.disconnect();
}
_disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete);
return;
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance?.removeObserver(this); WidgetsBinding.instance?.removeObserver(this);
_unsubscribeFromConnectivityChange();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_disconnectTimer?.cancel(); _disconnectTimer?.cancel();
super.dispose(); super.dispose();
@@ -1,5 +1,6 @@
library stream_chat_flutter_core; library stream_chat_flutter_core;
export 'package:connectivity_plus/connectivity_plus.dart';
export 'package:stream_chat/stream_chat.dart'; export 'package:stream_chat/stream_chat.dart';
export 'src/better_stream_builder.dart'; export 'src/better_stream_builder.dart';
@@ -11,6 +11,7 @@ environment:
dependencies: dependencies:
collection: ^1.15.0 collection: ^1.15.0
connectivity_plus: ^1.0.1
flutter: flutter:
sdk: flutter sdk: flutter
meta: ^1.3.0 meta: ^1.3.0
@@ -21,5 +22,5 @@ dev_dependencies:
fake_async: ^1.2.0 fake_async: ^1.2.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
mocktail: ^0.1.1 mocktail: ^0.1.3
@@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart'; import 'mocks.dart';
@@ -100,6 +102,7 @@ void main() {
child: Offstage(key: childKey), child: Offstage(key: childKey),
onBackgroundEventReceived: mockOnBackgroundEventReceived, onBackgroundEventReceived: mockOnBackgroundEventReceived,
backgroundKeepAlive: backgroundKeepAlive, backgroundKeepAlive: backgroundKeepAlive,
connectivityStream: Stream.value(ConnectivityResult.mobile),
); );
await tester.pumpWidget(streamChatCore); await tester.pumpWidget(streamChatCore);
@@ -191,6 +194,7 @@ void main() {
key: streamChatCoreKey, key: streamChatCoreKey,
client: mockClient, client: mockClient,
child: Offstage(key: childKey), child: Offstage(key: childKey),
connectivityStream: Stream.value(ConnectivityResult.mobile),
); );
await tester.pumpWidget(streamChatCore); await tester.pumpWidget(streamChatCore);
@@ -222,6 +226,51 @@ void main() {
}, },
); );
testWidgets(
'didChangeAppLifecycleState should not call client.connect() '
'if connection is not available in case the '
'widget lifestyle changes to AppLifecycleState.resume',
(tester) async {
await tester.runAsync(() async {
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
connectivityStream: Stream.value(ConnectivityResult.none),
);
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey),
);
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.paused);
await Future.delayed(const Duration(seconds: 1));
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.resumed);
verifyNever(() => mockClient.connect());
});
},
);
testWidgets( testWidgets(
'streamChatCoreState.userStream should emit all the user events ' 'streamChatCoreState.userStream should emit all the user events '
'provided by client', 'provided by client',
@@ -264,4 +313,124 @@ void main() {
}); });
}, },
); );
testWidgets(
'should call connect if in foreground and connection is back',
(tester) async {
await tester.runAsync(() async {
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final _connectivityController =
BehaviorSubject.seeded(ConnectivityResult.none);
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
connectivityStream: _connectivityController.stream,
);
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
_connectivityController.add(ConnectivityResult.mobile);
await Future.delayed(const Duration(seconds: 1));
verify(() => mockClient.connect()).called(1);
});
},
);
testWidgets(
'should call disconnect if in foreground and connection goes away',
(tester) async {
await tester.runAsync(() async {
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final _connectivityController =
BehaviorSubject.seeded(ConnectivityResult.mobile);
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
connectivityStream: _connectivityController.stream,
);
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.connected);
_connectivityController.add(ConnectivityResult.none);
await Future.delayed(const Duration(seconds: 1));
verify(() => mockClient.disconnect()).called(1);
});
},
);
testWidgets(
'should ignore connectivity in background',
(tester) async {
await tester.runAsync(() async {
final mockClient = MockClient();
const streamChatCoreKey = Key('streamChatCore');
const childKey = Key('child');
final _connectivityController =
BehaviorSubject.seeded(ConnectivityResult.none);
final streamChatCore = StreamChatCore(
key: streamChatCoreKey,
client: mockClient,
child: Offstage(key: childKey),
connectivityStream: _connectivityController.stream,
);
await tester.pumpWidget(streamChatCore);
expect(find.byKey(streamChatCoreKey), findsOneWidget);
expect(find.byKey(childKey), findsOneWidget);
final event = Event();
when(() => mockClient.on()).thenAnswer((_) => Stream.value(event));
when(() => mockClient.connect()).thenAnswer((_) async => event);
when(mockClient.disconnect).thenAnswer((_) async => null);
when(() => mockClient.wsConnectionStatus)
.thenReturn(ConnectionStatus.disconnected);
final streamChatCoreState = tester.state<StreamChatCoreState>(
find.byKey(streamChatCoreKey),
);
streamChatCoreState
.didChangeAppLifecycleState(AppLifecycleState.paused);
await Future.delayed(const Duration(seconds: 1));
_connectivityController.add(ConnectivityResult.mobile);
await Future.delayed(const Duration(seconds: 1));
verifyNever(() => mockClient.disconnect());
});
},
);
} }