diff --git a/lib/core/constants/keys.dart b/lib/core/constants/keys.dart index 85872ac..2922e24 100644 --- a/lib/core/constants/keys.dart +++ b/lib/core/constants/keys.dart @@ -187,5 +187,6 @@ class Keys { // General static const popupMenuButton = ValueKey('popup_menu_button'); + static const popupMenuItemPrefix = ValueKey('popup_menu_item_'); Keys._(); } \ No newline at end of file diff --git a/lib/core/routes/routes.dart b/lib/core/routes/routes.dart index 80a5135..6debd2f 100644 --- a/lib/core/routes/routes.dart +++ b/lib/core/routes/routes.dart @@ -306,7 +306,11 @@ class Routes { path: Routes.callScreen, builder: (context, state) { final Map? extra = state.extra as Map?; - return CallScreen(callee: extra?['user'] as UserModel?, voiceCallId: extra?['voiceCallId'] as String?); + return CallScreen( + chatId: extra?['chatId'] as String?, + callee: extra?['user'] as UserModel?, + voiceCallId: extra?['voiceCallId'] as String? + ); }, ), GoRoute( diff --git a/lib/core/view/widget/popup_menu_widget.dart b/lib/core/view/widget/popup_menu_widget.dart index e927072..8a69ccf 100644 --- a/lib/core/view/widget/popup_menu_widget.dart +++ b/lib/core/view/widget/popup_menu_widget.dart @@ -1,6 +1,9 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:telware_cross_platform/core/constants/keys.dart'; import 'package:telware_cross_platform/core/view/widget/popup_menu_item_widget.dart'; import 'package:telware_cross_platform/core/theme/palette.dart'; +import 'package:telware_cross_platform/features/chat/view/screens/pinned_messages_screen.dart'; class PopupMenuWidget extends StatelessWidget { final List items; @@ -26,10 +29,11 @@ class PopupMenuWidget extends StatelessWidget { color: Palette.secondary, position: RelativeRect.fromLTRB(position.dx, position.dy - renderBox.size.height, position.dx + 100, position.dy), items: >[ - ...items.map((item) { + ...items.mapIndexed((index, item) { return PopupMenuItem( value: item['value'], child: PopupMenuItemWidget( + key: ValueKey(Keys.popupMenuItemPrefix.value + index.toString()), icon: item['icon'], text: item['text'], trailing: item['trailing'], diff --git a/lib/features/chat/enum/chatting_enums.dart b/lib/features/chat/enum/chatting_enums.dart index d9d59b5..5304508 100644 --- a/lib/features/chat/enum/chatting_enums.dart +++ b/lib/features/chat/enum/chatting_enums.dart @@ -50,6 +50,7 @@ enum EventType { receiveLeftCall(event: 'CLIENT-LEFT'), receiveCallSignal(event: 'SIGNAL-CLIENT'), receiveCallStarted(event: 'CALL-STARTED'), + receiveCallEnded(event: 'CALL-ENDED'), ; final String event; diff --git a/lib/features/chat/enum/message_enums.dart b/lib/features/chat/enum/message_enums.dart index 05e2f5c..b250f2d 100644 --- a/lib/features/chat/enum/message_enums.dart +++ b/lib/features/chat/enum/message_enums.dart @@ -71,7 +71,9 @@ enum MessageContentType { @HiveField(7) file(content: 'file'), @HiveField(8) - link(content: 'link'); + link(content: 'link'), + @HiveField(9) + call(content: 'call'); static MessageContentType getType(String type) { switch (type) { @@ -91,6 +93,8 @@ enum MessageContentType { return MessageContentType.file; case 'link': return MessageContentType.link; + case 'call': + return MessageContentType.call; default: return MessageContentType.text; } diff --git a/lib/features/chat/services/signaling_service.dart b/lib/features/chat/services/signaling_service.dart index 66ea55c..99eb442 100644 --- a/lib/features/chat/services/signaling_service.dart +++ b/lib/features/chat/services/signaling_service.dart @@ -6,7 +6,7 @@ import 'package:telware_cross_platform/features/chat/view_model/event_handler.da typedef StreamStateCallback = void Function(MediaStream stream, String senderId); typedef DescriptionCallback = void Function(RTCSessionDescription description, String senderId); -typedef CandidateCallback = void Function(RTCIceCandidate candidate); +typedef CandidateCallback = void Function(RTCIceCandidate candidate, String senderId); typedef ResponseCallback = void Function(dynamic response); final TURN_SERVER_USERNAME = dotenv.env['TURN_SERVER_USERNAME'] ?? ''; @@ -46,7 +46,7 @@ class Signaling { ] }; - RTCPeerConnection? peerConnection; + Map peerConnections = {}; MediaStream? localStream; Map remoteStreams = {}; StreamStateCallback? onAddRemoteStream; @@ -56,6 +56,7 @@ class Signaling { ResponseCallback? onCallStarted; ResponseCallback? onReceiveJoinedCall; ResponseCallback? onReceiveLeftCall; + ResponseCallback? onReceiveEndCall; String? roomId; @@ -75,59 +76,59 @@ class Signaling { data['data']['sdpMid'], data['data']['sdpMLineIndex'], ); - onICECandidate?.call(candidate); + onICECandidate?.call(candidate, data['senderId']); } } - Future createOffer(calleeId) async { - await registerPeerConnection(calleeId); + Future createOffer(clientId) async { + await registerPeerConnection(clientId); localStream?.getTracks().forEach((track) { - peerConnection!.addTrack(track, localStream!); + peerConnections[clientId]!.addTrack(track, localStream!); }); - var offer = await peerConnection!.createOffer(); - await setLocalDescription(offer); + var offer = await peerConnections[clientId]!.createOffer(); + await setLocalDescription(offer, clientId); return offer; } - Future createAnswer(String callerId) async { + Future createAnswer(String clientId) async { localStream?.getTracks().forEach((track) { - peerConnection!.addTrack(track, localStream!); + peerConnections[clientId]!.addTrack(track, localStream!); }); - peerConnection!.onTrack = (RTCTrackEvent event) async { + peerConnections[clientId]!.onTrack = (RTCTrackEvent event) async { debugPrint('Remote stream added'); event.streams[0].getTracks().forEach((track) { - remoteStreams[callerId]!.addTrack(track); + remoteStreams[clientId]!.addTrack(track); }); }; - var answer = await peerConnection!.createAnswer(); - await setLocalDescription(answer); + var answer = await peerConnections[clientId]!.createAnswer(); + await setLocalDescription(answer, clientId); return answer; } - Future addCandidate(RTCIceCandidate candidate) async { - await peerConnection!.addCandidate(candidate); + Future addCandidate(RTCIceCandidate candidate, String clientId) async { + await peerConnections[clientId]!.addCandidate(candidate); } - Future registerPeerConnection(String callerId) async { - peerConnection ??= await createPeerConnection(configuration); - registerPeerConnectionListeners(callerId); + Future registerPeerConnection(String clientId) async { + peerConnections[clientId] ??= await createPeerConnection(configuration); + registerPeerConnectionListeners(clientId); } - Future setRemoteDescription(RTCSessionDescription description) async { + Future setRemoteDescription(RTCSessionDescription description, String clientId) async { debugPrint('Setting remote description to: ${description.sdp}'); debugPrint('Remote SDP: ${description.sdp}'); - await peerConnection!.setRemoteDescription(description); + await peerConnections[clientId]!.setRemoteDescription(description); } - Future setLocalDescription(RTCSessionDescription description) async { + Future setLocalDescription(RTCSessionDescription description, String clientId) async { debugPrint('Local SDP: ${description.sdp}'); - await peerConnection!.setLocalDescription(description); + await peerConnections[clientId]!.setLocalDescription(description); } Future hangUp(RTCVideoRenderer localRenderer) async { @@ -138,9 +139,13 @@ class Signaling { track.stop(); }); }); + remoteStreams.clear(); + + peerConnections.forEach((key, value) { + value.close(); + }); + peerConnections.clear(); - if (peerConnection != null) peerConnection!.close(); - peerConnection = null; _eventHandler.addEvent( LeaveCallEvent( { @@ -150,11 +155,20 @@ class Signaling { ); localStream!.dispose(); - remoteStreams.clear(); } - void registerPeerConnectionListeners(String calleeId) { - peerConnection!.onIceCandidate = (RTCIceCandidate? candidate) { + Future removeRemoteClient(String clientId) async { + peerConnections[clientId]?.close(); + peerConnections.remove(clientId); + + remoteStreams[clientId]?.getTracks().forEach((track) { + track.stop(); + }); + remoteStreams.remove(clientId); + } + + void registerPeerConnectionListeners(String clientId) { + peerConnections[clientId]!.onIceCandidate = (RTCIceCandidate? candidate) { if (candidate != null) { debugPrint('Generated ICE Candidate'); _eventHandler.addEvent( @@ -166,7 +180,7 @@ class Signaling { 'sdpMid': candidate.sdpMid, 'sdpMLineIndex': candidate.sdpMLineIndex, }, - 'targetId': calleeId, + 'targetId': clientId, 'voiceCallId': roomId, }, ) @@ -174,35 +188,35 @@ class Signaling { } }; - peerConnection!.onConnectionState = (RTCPeerConnectionState state) { + peerConnections[clientId]!.onConnectionState = (RTCPeerConnectionState state) { debugPrint('Connection state changed: $state'); }; - peerConnection!.onSignalingState = (RTCSignalingState state) { + peerConnections[clientId]!.onSignalingState = (RTCSignalingState state) { debugPrint('Signaling state changed: $state'); }; - peerConnection!.onIceConnectionState = (RTCIceConnectionState state) { + peerConnections[clientId]!.onIceConnectionState = (RTCIceConnectionState state) { debugPrint('ICE connection state changed: $state'); }; - peerConnection!.onTrack = (RTCTrackEvent event) async { + peerConnections[clientId]!.onTrack = (RTCTrackEvent event) async { if (event.streams.isNotEmpty) { for (var track in event.streams[0].getTracks()) { - remoteStreams[calleeId]?.addTrack(track); + remoteStreams[clientId]?.addTrack(track); } - onAddRemoteStream?.call(event.streams[0], calleeId); + onAddRemoteStream?.call(event.streams[0], clientId); } }; - peerConnection!.onIceGatheringState = (RTCIceGatheringState state) { + peerConnections[clientId]!.onIceGatheringState = (RTCIceGatheringState state) { debugPrint('ICE gathering state changed: $state'); }; - peerConnection!.onAddStream = (MediaStream stream) { + peerConnections[clientId]!.onAddStream = (MediaStream stream) { debugPrint('Remote stream added'); - onAddRemoteStream?.call(stream, calleeId); - remoteStreams[calleeId] = stream; + onAddRemoteStream?.call(stream, clientId); + remoteStreams[clientId] = stream; }; } diff --git a/lib/features/chat/view/screens/call_screen.dart b/lib/features/chat/view/screens/call_screen.dart index e1e4e72..9fee6a6 100644 --- a/lib/features/chat/view/screens/call_screen.dart +++ b/lib/features/chat/view/screens/call_screen.dart @@ -19,11 +19,13 @@ import 'package:telware_cross_platform/features/chat/view_model/chats_view_model class CallScreen extends ConsumerStatefulWidget { static const String route = '/call'; + final String? chatId; final UserModel? callee; final String? voiceCallId; const CallScreen({ super.key, + this.chatId, this.callee, this.voiceCallId, }); @@ -47,6 +49,7 @@ class _CallScreenState extends ConsumerState { isCaller = ref.read(callStateProvider).isCaller; callee = widget.callee ?? ref.read(callStateProvider).callee; voiceCallId = widget.voiceCallId ?? ref.read(callStateProvider).voiceCallId; + String? chatId = widget.chatId; isReceivingCall = !isCaller && voiceCallId != null; @@ -71,9 +74,12 @@ class _CallScreenState extends ConsumerState { _initializeSignaling(); if (voiceCallId == null && isCaller) { debugPrint("Call: Creating voice call"); - ChatModel chat = ref.read(chatsViewModelProvider.notifier) - .getChat(ref.read(userProvider)!, callee!, ChatType.private); - _signaling.createVoiceCall(chat.id, callee!.id); + if (chatId == null) { + ChatModel chat = ref.read(chatsViewModelProvider.notifier) + .getChat(ref.read(userProvider)!, callee!, ChatType.private); + chatId = chat.id; + } + _signaling.createVoiceCall(chatId, callee?.id); } }); } @@ -88,19 +94,19 @@ class _CallScreenState extends ConsumerState { _signaling.onOffer = (description, senderId) async { // If receiving a call, set remote description and create an answer await _signaling.registerPeerConnection(senderId); - await _signaling.setRemoteDescription(description); + await _signaling.setRemoteDescription(description, senderId); final answer = await _signaling.createAnswer(senderId); _signaling.sendAnswer(answer, senderId); startCall(); }; _signaling.onAnswer = (description, senderId) async { - await _signaling.setRemoteDescription(description); + await _signaling.setRemoteDescription(description, senderId); startCall(); }; - _signaling.onICECandidate = (candidate) { - _signaling.addCandidate(candidate); + _signaling.onICECandidate = (candidate, senderId) { + _signaling.addCandidate(candidate, senderId); }; _signaling.onCallStarted = (response) { @@ -126,6 +132,12 @@ class _CallScreenState extends ConsumerState { }; _signaling.onReceiveLeftCall = (response) { + // Terminate connection with the specific user + final clientId = response['clientId']; + _signaling.removeRemoteClient(clientId); + }; + + _signaling.onReceiveEndCall = (response) { // Terminate connection with user endCall(); context.pop(); diff --git a/lib/features/chat/view/screens/chat_info_screen.dart b/lib/features/chat/view/screens/chat_info_screen.dart index 0c7047b..d3b73b8 100644 --- a/lib/features/chat/view/screens/chat_info_screen.dart +++ b/lib/features/chat/view/screens/chat_info_screen.dart @@ -15,6 +15,7 @@ import 'package:telware_cross_platform/core/view/widget/tab_bar_widget.dart'; import 'package:telware_cross_platform/core/view/widget/lottie_viewer.dart'; import 'package:telware_cross_platform/core/view/widget/popup_menu_widget.dart'; import 'package:telware_cross_platform/features/auth/view/widget/title_element.dart'; +import 'package:telware_cross_platform/features/chat/providers/call_provider.dart'; import 'package:telware_cross_platform/features/chat/view/widget/member_tile_widget.dart'; import 'package:telware_cross_platform/features/chat/view_model/chats_view_model.dart'; import 'package:telware_cross_platform/features/chat/view_model/chatting_controller.dart'; @@ -337,11 +338,25 @@ class _ChatInfoScreen extends ConsumerState case 'delete-group': _confirmDelete(context); break; + case 'video-call': + _createGroupCall(); + break; default: showToastMessage('Coming soon'); } } + void _createGroupCall() { + if (ref.read(callStateProvider).voiceCallId == null) { + ref.read(callStateProvider.notifier).setCaller(true); + context.push(Routes.callScreen, extra: { + 'chatId': chat.id, + }); + } else { + showToastMessage('You are already in a call'); + } + } + void _addMembers() { Navigator.push( context, diff --git a/lib/features/chat/view_model/event_handler.dart b/lib/features/chat/view_model/event_handler.dart index 6cf75ad..30620c7 100644 --- a/lib/features/chat/view_model/event_handler.dart +++ b/lib/features/chat/view_model/event_handler.dart @@ -314,6 +314,15 @@ class EventHandler { debugPrint('!!! Error in receiving a call started:\n${e.toString()}'); } }); + // get a call ended + _socket.on(EventType.receiveCallEnded.event, (response) async { + try { + debugPrint('### got a call ended: $response'); + signaling.onReceiveEndCall?.call(response); + } on Exception catch (e) { + debugPrint('!!! Error in receiving a call ended:\n${e.toString()}'); + } + }); } // Private constructor diff --git a/lib/features/user/view/screens/user_profile_screen.dart b/lib/features/user/view/screens/user_profile_screen.dart index 57ba3e5..cbe85cf 100644 --- a/lib/features/user/view/screens/user_profile_screen.dart +++ b/lib/features/user/view/screens/user_profile_screen.dart @@ -8,6 +8,7 @@ import 'package:telware_cross_platform/core/routes/routes.dart'; import 'package:telware_cross_platform/core/theme/dimensions.dart'; import 'package:telware_cross_platform/core/theme/palette.dart'; import 'package:telware_cross_platform/core/theme/sizes.dart'; +import 'package:telware_cross_platform/core/utils.dart'; import 'package:telware_cross_platform/core/view/widget/tab_bar_widget.dart'; import 'package:telware_cross_platform/core/view/widget/lottie_viewer.dart'; import 'package:telware_cross_platform/features/auth/view/widget/title_element.dart'; @@ -100,6 +101,11 @@ class _UserProfileScreen extends ConsumerState key: CallKeys.startCallButton, icon: const Icon(Icons.call), onPressed: () { + // if user is in call, show dialog instead + if (ref.read(callStateProvider).voiceCallId != null) { + showToastMessage("You are already in a call"); + return; + } ref.read(callStateProvider.notifier).setCaller(true); debugPrint("Sending user: $_user"); context.push(Routes.callScreen, extra: {"user": _user, "voiceCallId": null});