diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 1f3b51d2..d3522886 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -4,7 +4,6 @@ import 'package:stream_chat_persistence/stream_chat_persistence.dart'; final chatPersistentClient = StreamChatPersistenceClient( logLevel: Level.INFO, - connectionMode: ConnectionMode.background, ); void main() async { diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index 0fa7bca4..c52c3797 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -12,6 +12,9 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Callback called when tapping on a channel 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] typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); @@ -84,6 +87,9 @@ class ChannelListView extends StatefulWidget { this.emptyBuilder, this.loadingBuilder, this.listBuilder, + this.onMoreDetailsPressed, + this.onDeletePressed, + this.swipeActions, }) : super(key: key); /// If true a default swipe to action behaviour will be added to this widget @@ -169,6 +175,15 @@ class ChannelListView extends StatefulWidget { /// The builder used when the channel list is empty. 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? swipeActions; + @override _ChannelListViewState createState() => _ChannelListViewState(); } @@ -477,61 +492,79 @@ class _ChannelListViewState extends State { enabled: widget.swipeToAction, actionPane: const SlidableBehindActionPane(), actionExtentRatio: 0.12, - secondaryActions: [ - IconSlideAction( - color: backgroundColor, - icon: Icons.more_horiz, - onTap: () { - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), - ), - context: context, - builder: (context) => StreamChannel( - channel: channel, - child: ChannelBottomSheet( - onViewInfoTap: () { - widget.onViewInfoTap?.call(channel); - }, - ), - ), - ); - }, - ), - if ([ - 'admin', - 'owner', - ].contains(channel.state!.members - .firstWhereOrNull( - (m) => m.userId == channel.client.state.user?.id) - ?.role)) - IconSlideAction( - color: backgroundColor, - iconWidget: StreamSvgIcon.delete( - color: chatThemeData.colorTheme.accentRed, + secondaryActions: widget.swipeActions + ?.map((e) => IconSlideAction( + color: e.color, + iconWidget: e.iconWidget, + onTap: () { + e.onTap?.call(channel); + }, + )) + .toList() ?? + [ + IconSlideAction( + color: backgroundColor, + icon: Icons.more_horiz, + onTap: widget.onMoreDetailsPressed != null + ? () { + widget.onMoreDetailsPressed!(channel); + } + : () { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + builder: (context) => StreamChannel( + channel: channel, + child: ChannelBottomSheet( + onViewInfoTap: () { + widget.onViewInfoTap?.call(channel); + }, + ), + ), + ); + }, ), - onTap: () async { - final res = await showConfirmationDialog( - context, - title: 'Delete Conversation', - okText: 'DELETE', - question: - 'Are you sure you want to delete this conversation?', - cancelText: 'CANCEL', - icon: StreamSvgIcon.delete( + if ([ + 'admin', + 'owner', + ].contains(channel.state!.members + .firstWhereOrNull( + (m) => m.userId == channel.client.state.user?.id) + ?.role)) + IconSlideAction( + color: backgroundColor, + iconWidget: StreamSvgIcon.delete( color: chatThemeData.colorTheme.accentRed, ), - ); - if (res == true) { - await channel.delete(); - } - }, - ), - ], + onTap: widget.onDeletePressed != null + ? () { + 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( color: chatThemeData.colorTheme.whiteSnow, child: widget.channelPreviewBuilder?.call(context, channel) ?? @@ -652,3 +685,22 @@ class _ChannelListViewState extends State { ); } } + +/// 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; +} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 424d4bcf..64acae98 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -39,6 +39,7 @@ class StreamChat extends StatefulWidget { this.streamChatThemeData, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), + this.connectivityStream, }) : super(key: key); /// Client to do chat ops with @@ -59,6 +60,11 @@ class StreamChat extends StatefulWidget { /// upon the [Event.type] final EventHandler? onBackgroundEventReceived; + /// Stream of connectivity result + /// Visible for testing + @visibleForTesting + final Stream? connectivityStream; + @override StreamChatState createState() => StreamChatState(); @@ -102,6 +108,7 @@ class StreamChatState extends State { client: client, onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, + connectivityStream: widget.connectivityStream, child: widget.child ?? const Offstage(), ), ); diff --git a/packages/stream_chat_flutter/test/src/deleted_message_test.dart b/packages/stream_chat_flutter/test/src/deleted_message_test.dart index bcf9cf62..206d92e3 100644 --- a/packages/stream_chat_flutter/test/src/deleted_message_test.dart +++ b/packages/stream_chat_flutter/test/src/deleted_message_test.dart @@ -79,6 +79,7 @@ void main() { ), ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), ), surfaceSize: const Size.square(200), @@ -131,6 +132,7 @@ void main() { ), ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), ), surfaceSize: const Size.square(200), @@ -187,6 +189,7 @@ void main() { ), ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), ), surfaceSize: const Size.square(200), diff --git a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart index 770a040b..c5183f35 100644 --- a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart +++ b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart @@ -60,6 +60,7 @@ void main() { maskColor: theme.ownMessageTheme.reactionsMaskColor!, ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), surfaceSize: const Size(100, 100), ); @@ -96,6 +97,7 @@ void main() { maskColor: theme.ownMessageTheme.reactionsMaskColor!, ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), surfaceSize: const Size(100, 100), ); @@ -118,6 +120,7 @@ void main() { StreamChat( client: client, streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + connectivityStream: Stream.value(ConnectivityResult.mobile), child: Container( color: Colors.black, child: ReactionBubble( @@ -162,6 +165,7 @@ void main() { StreamChat( client: client, streamChatThemeData: StreamChatThemeData.fromTheme(themeData), + connectivityStream: Stream.value(ConnectivityResult.mobile), child: Container( color: Colors.black, child: ReactionBubble( @@ -204,6 +208,7 @@ void main() { await tester.pumpWidgetBuilder( StreamChat( client: client, + connectivityStream: Stream.value(ConnectivityResult.mobile), streamChatThemeData: StreamChatThemeData.fromTheme(themeData), child: SizedBox( child: ReactionBubble( diff --git a/packages/stream_chat_flutter/test/src/system_message_test.dart b/packages/stream_chat_flutter/test/src/system_message_test.dart index 8fa3ad44..6bb1da43 100644 --- a/packages/stream_chat_flutter/test/src/system_message_test.dart +++ b/packages/stream_chat_flutter/test/src/system_message_test.dart @@ -100,6 +100,7 @@ void main() { ), ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), ), surfaceSize: const Size.square(200), @@ -151,6 +152,7 @@ void main() { ), ), ), + connectivityStream: Stream.value(ConnectivityResult.mobile), ), ), surfaceSize: const Size.square(200), diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 0bf78b2e..aa23a959 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -43,6 +44,7 @@ class StreamChatCore extends StatefulWidget { required this.child, this.onBackgroundEventReceived, this.backgroundKeepAlive = const Duration(minutes: 1), + this.connectivityStream, }) : super(key: key); /// Instance of Stream Chat Client containing information about the current @@ -61,6 +63,11 @@ class StreamChatCore extends StatefulWidget { /// upon the [Event.type] final EventHandler? onBackgroundEventReceived; + /// Stream of connectivity result + /// Visible for testing + @visibleForTesting + final Stream? connectivityStream; + @override StreamChatCoreState createState() => StreamChatCoreState(); @@ -96,48 +103,104 @@ class StreamChatCoreState extends State /// The current user as a stream Stream get userStream => client.state.userStream; + StreamSubscription? _connectivitySubscription; + + var _isInForeground = true; + var _isConnectionAvailable = true; + @override void initState() { super.initState(); WidgetsBinding.instance?.addObserver(this); + _subscribeToConnectivityChange(widget.connectivityStream); + } + + void _subscribeToConnectivityChange([ + Stream? 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.openConnection(); + } + } else { + if (client.wsConnectionStatus == ConnectionStatus.connected) { + client.closeConnection(); + } + } + }); + } + } + + 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; @override void didChangeAppLifecycleState(AppLifecycleState state) { + _isInForeground = state == AppLifecycleState.resumed; if (user != null) { - if (state == AppLifecycleState.paused) { - if (widget.onBackgroundEventReceived == null) { - client.closeConnection(); - return; - } - _eventSubscription = client.on().listen( - widget.onBackgroundEventReceived, - ); - - void onTimerComplete() { - _eventSubscription?.cancel(); - client.closeConnection(); - } - - _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.openConnection(); - } - } + if (_isInForeground) { + _onForeground(); + } else { + _onBackground(); } } } + void _onForeground() { + if (_disconnectTimer?.isActive == true) { + _eventSubscription?.cancel(); + _disconnectTimer?.cancel(); + } else if (client.wsConnectionStatus == ConnectionStatus.disconnected && + _isConnectionAvailable) { + client.openConnection(); + } + } + + void _onBackground() { + if (widget.onBackgroundEventReceived == null) { + if (client.wsConnectionStatus != ConnectionStatus.disconnected) { + client.closeConnection(); + } + return; + } + + _eventSubscription = client.on().listen(widget.onBackgroundEventReceived); + + void onTimerComplete() { + _eventSubscription?.cancel(); + client.closeConnection(); + } + + _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); + return; + } + @override void dispose() { WidgetsBinding.instance?.removeObserver(this); + _unsubscribeFromConnectivityChange(); _eventSubscription?.cancel(); _disconnectTimer?.cancel(); super.dispose(); diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 01463de0..ef947a0f 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -1,5 +1,6 @@ library stream_chat_flutter_core; +export 'package:connectivity_plus/connectivity_plus.dart'; export 'package:stream_chat/stream_chat.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index cb99c452..b1a52d5f 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: collection: ^1.15.0 + connectivity_plus: ^1.0.1 flutter: sdk: flutter meta: ^1.3.0 @@ -25,5 +26,5 @@ dev_dependencies: fake_async: ^1.2.0 flutter_test: sdk: flutter - mocktail: ^0.1.1 + mocktail: ^0.1.3 diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 94b38238..62306bc6 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; @@ -100,6 +102,7 @@ void main() { child: Offstage(key: childKey), onBackgroundEventReceived: mockOnBackgroundEventReceived, backgroundKeepAlive: backgroundKeepAlive, + connectivityStream: Stream.value(ConnectivityResult.mobile), ); await tester.pumpWidget(streamChatCore); @@ -191,6 +194,7 @@ void main() { key: streamChatCoreKey, client: mockClient, child: Offstage(key: childKey), + connectivityStream: Stream.value(ConnectivityResult.mobile), ); 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( + find.byKey(streamChatCoreKey), + ); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verifyNever(() => mockClient.connect()); + }); + }, + ); + testWidgets( 'streamChatCoreState.userStream should emit all the user events ' '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( + 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()); + }); + }, + ); }