From 5670408c5ed89b90b8a50baa7d6d715d4347776e Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:56:03 +0300 Subject: [PATCH] webrtc implemented --- android/app/src/main/AndroidManifest.xml | 2 + assets/images/calling/speaker_disable.svg | 17 + .../firebase_notification_manger.dart | 40 +- .../chat/calling/audio_call_page.dart | 46 +- .../cx_module/chat/calling/call_provider.dart | 591 +++++++++------- .../chat/calling/video_call_page.dart | 337 ++++++++++ .../chat/calling/webrtc_service.dart | 635 ++++++++++++++++++ lib/modules/cx_module/chat/chat_page.dart | 21 +- lib/modules/cx_module/chat/chat_provider.dart | 25 +- lib/new_views/pages/land_page/land_page.dart | 56 +- 10 files changed, 1507 insertions(+), 263 deletions(-) create mode 100644 assets/images/calling/speaker_disable.svg create mode 100644 lib/modules/cx_module/chat/calling/video_call_page.dart create mode 100644 lib/modules/cx_module/chat/calling/webrtc_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9efe0945..495496a9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + diff --git a/assets/images/calling/speaker_disable.svg b/assets/images/calling/speaker_disable.svg new file mode 100644 index 00000000..06acf25b --- /dev/null +++ b/assets/images/calling/speaker_disable.svg @@ -0,0 +1,17 @@ + + + + multimedia / 5 - multimedia, audio, music, off, sound, sound off, speaker, volume icon + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 69b062be..bf84f13e 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -18,6 +18,7 @@ import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/models/device/device_transfer.dart'; import 'package:test_sa/models/new_models/gas_refill_model.dart'; import 'package:test_sa/modules/cm_module/cm_detail_page.dart'; +import 'package:test_sa/modules/cx_module/chat/calling/call_provider.dart'; import 'package:test_sa/modules/cx_module/survey/survey_page.dart'; import 'package:test_sa/modules/pm_module/ppm_wo/ppm_details_page.dart'; import 'package:test_sa/modules/pm_module/recurrent_wo/recurrent_work_order_view.dart'; @@ -72,6 +73,15 @@ class FirebaseNotificationManger { static FirebaseMessaging messaging = FirebaseMessaging.instance; static String? token; static String? voipToken; + static Timer? _missedCallTimer; + static String? _activeMissedCallId; + static void cancelMissedCallTimer([String? callId]) { + if (_activeMissedCallId == null) return; + if (callId != null && _activeMissedCallId != callId) return; + _missedCallTimer?.cancel(); + _missedCallTimer = null; + _activeMissedCallId = null; + } static Future getToken() async { NotificationSettings settings = await messaging.requestPermission(alert: true, announcement: false, badge: true, carPlay: false, criticalAlert: false, provisional: false, sound: true); @@ -357,7 +367,8 @@ Future handleDataMessage(Map messageData, bool isFromBack print("handleDataMessage:moduleId:$moduleId"); WidgetsFlutterBinding.ensureInitialized(); - Timer? _callTimeoutTimer; + FirebaseNotificationManger.cancelMissedCallTimer(); + FirebaseNotificationManger._activeMissedCallId = callId; try { final callKitParams = CallKitParams( id: callId, @@ -429,18 +440,33 @@ Future handleDataMessage(Map messageData, bool isFromBack await FlutterCallkitIncoming.requestFullIntentPermission(); FlutterCallkitIncoming.unsilenceEvents(); FlutterCallkitIncoming.showCallkitIncoming(callKitParams); - _callTimeoutTimer?.cancel(); - _callTimeoutTimer = Timer(const Duration(seconds: 30), () async { + + FirebaseNotificationManger._missedCallTimer = Timer(const Duration(seconds: 30), () async { + if (FirebaseNotificationManger._activeMissedCallId != callId) return; + await FlutterCallkitIncoming.endCall(callId); await FlutterCallkitIncoming.endAllCalls(); await FlutterCallkitIncoming.showMissCallNotification(callKitParams); + try { + final notifyProvider = CallProvider(); + final connected = await notifyProvider.buildHubConnection(calleeEmployeeNumber); + if (connected) { + await notifyProvider.declineCall( + calleeEmployeeNumber, + callerEmployeeNumber, + moduleId: int.tryParse(moduleId ?? '') ?? 0, + reason: 'NO_ANSWER', + ); + } + await notifyProvider.endSession(); + } catch (_) { + // Ignore - this is a best-effort notification only. + } - _callTimeoutTimer?.cancel(); - _callTimeoutTimer = null; + FirebaseNotificationManger.cancelMissedCallTimer(callId); }); } catch (e, stackTrace) { - _callTimeoutTimer?.cancel(); - _callTimeoutTimer = null; + FirebaseNotificationManger.cancelMissedCallTimer(callId); } } } diff --git a/lib/modules/cx_module/chat/calling/audio_call_page.dart b/lib/modules/cx_module/chat/calling/audio_call_page.dart index e970a1c6..0d32ff55 100644 --- a/lib/modules/cx_module/chat/calling/audio_call_page.dart +++ b/lib/modules/cx_module/chat/calling/audio_call_page.dart @@ -60,7 +60,7 @@ class _AudioCallPageState extends State { callPro.initAudioCallListeners(); if (widget.isAccept) { - callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", false, moduleId: widget.moduleId,onSuccess: (String msg) { + callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", false, moduleId: widget.moduleId, onSuccess: (String msg) { _closeCall(); }, onFailed: (String msg) { _showFailure(msg, title: "Failed"); @@ -163,18 +163,40 @@ class _AudioCallPageState extends State { Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - callButton("calling/call_user_avatar", () {}, size: 85), + callButton( + "calling/call_user_avatar", + () {}, + size: 85, + ), 24.height, - InfoTextLabelWidget(label: chatProvider.recipient?.userName ?? "", textStyle: AppTextStyles.heading4.copyWith(fontWeight: FontWeight.w600), color: Colors.white), + InfoTextLabelWidget( + label: chatProvider.recipient?.userName ?? '', + textStyle: AppTextStyles.heading4.copyWith( + fontWeight: FontWeight.w600, + ), + color: Colors.white, + ), 4.height, Selector( - selector: (_, myModel) => myModel.currentStatus, // Selects only the userName - builder: (_, currentStatus, __) => InfoTextLabelWidget(label: currentStatus, textStyle: AppTextStyles.heading6, color: Colors.white), + selector: (_, provider) { + return provider.currentStatus == "Connected" ? provider.formattedCallDuration : provider.currentStatus; + }, + builder: (_, status, __) => InfoTextLabelWidget( + label: status, + textStyle: AppTextStyles.heading6, + color: Colors.white, + ), ), 4.height, Selector( - selector: (_, myModel) => myModel.isPeerMuted, - builder: (_, isPeerMuted, __) => isPeerMuted ? InfoTextLabelWidget(label: "Mic Mute", textStyle: AppTextStyles.tinyFont, color: AppColor.red30) : const SizedBox(), + selector: (_, provider) => provider.isPeerMuted, + builder: (_, isPeerMuted, __) => isPeerMuted + ? InfoTextLabelWidget( + label: "Mic Mute", + textStyle: AppTextStyles.tinyFont, + color: AppColor.red30, + ) + : const SizedBox(), ), ], ), @@ -192,7 +214,15 @@ class _AudioCallPageState extends State { callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId); Navigator.pop(context); }, isEndCall: true, size: 68), - callButton("calling/speaker_enable", () {}), + Selector( + selector: (_, provider) => provider.isSpeakerOn, + builder: (_, isSpeakerOn, __) => callButton( + isSpeakerOn ? "calling/speaker_enable" : "calling/speaker_disable", //need to replace with speaker_disable icon + () { + callPro.toggleSpeaker(); + }, + ), + ), ], ) ], diff --git a/lib/modules/cx_module/chat/calling/call_provider.dart b/lib/modules/cx_module/chat/calling/call_provider.dart index d3397a8a..5bab2d28 100644 --- a/lib/modules/cx_module/chat/calling/call_provider.dart +++ b/lib/modules/cx_module/chat/calling/call_provider.dart @@ -1,8 +1,5 @@ +import 'dart:async'; import 'dart:convert'; -import 'dart:math'; -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:signalr_netcore/http_connection_options.dart'; @@ -12,40 +9,61 @@ import 'package:signalr_netcore/itransport.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import 'package:test_sa/modules/cx_module/chat/api_client.dart'; import 'package:http/http.dart' as http; - +import 'package:test_sa/modules/cx_module/chat/calling/webrtc_service.dart'; +import 'package:uuid/uuid.dart'; import '../chat_provider.dart'; import '../model/chat_login_response_model.dart'; HubConnection? callHubConnection; +void _logError(String label, Object e) { + if (!kDebugMode) return; + try { + print('$label: $e'); + } catch (_) { + print('$label: ${e.runtimeType}'); + } +} + class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { - ///////////////////// Web RTC Video Calling ////////////////////// - // Video Call - late RTCPeerConnection _peerConnection; - RTCVideoRenderer _localVideoRenderer = RTCVideoRenderer(); - final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); + final WebRTCService _webRTCService = WebRTCService(); - MediaStream? _localStream; - MediaStream? _remoteStream; + WebRTCService get webRTCService => _webRTCService; bool isMuted = false; bool isSpeakerOn = false; bool isCameraOn = true; bool isPeerMuted = false; bool isPeerCameraOn = true; - + Timer? _callDurationTimer; + Timer? _noAnswerTimer; + Duration callDuration = Duration.zero; bool isLoading = false; String currentStatus = "Connecting..."; bool _ownsConnection = false; + String? _currentCallId; void _setStatus(String status) { if (currentStatus == status) return; currentStatus = status; notifyListeners(); } - + void _startCallDurationTimer() { + _callDurationTimer?.cancel(); + callDuration = Duration.zero; + + _callDurationTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + callDuration = Duration( + seconds: callDuration.inSeconds + 1, + ); + notifyListeners(); + }, + ); + } Future _disposeConnection() async { final HubConnection? connection = callHubConnection; final bool owns = _ownsConnection; @@ -55,24 +73,36 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { try { await connection.stop(); } catch (e) { - if (kDebugMode) { - print('⚠️ Error closing call SignalR connection: $e'); - } + _logError('Error closing call SignalR connection', e); } } Future endSession() async { + _cancelNoAnswerTimer(); stopListeners(); onFailed = null; onSuccess = null; isMuted = false; isPeerMuted = false; + // `CallProvider` is a long-lived singleton (registered once in main.dart), + // so without resetting these here, toggling the camera off near the end + // of one video call would leak into the *next* call, making it look like + // the camera defaults to "off" even though permission is granted. + isCameraOn = true; + isPeerCameraOn = true; currentStatus = "Connecting..."; + _callDurationTimer?.cancel(); + _callDurationTimer = null; + callDuration = Duration.zero; + await _webRTCService.dispose(); await _disposeConnection(); } @override void dispose() { + _cancelNoAnswerTimer(); + _callDurationTimer?.cancel(); + _webRTCService.dispose(); _disposeConnection().then((_) {}).catchError((error) {}); super.dispose(); } @@ -116,46 +146,95 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { final String query = uri.query.isEmpty ? '' : '?${uri.query}'; return '${uri.scheme}://${uri.host}:$port${uri.path}$query'; } + String get formattedCallDuration { + final hours = callDuration.inHours; + final minutes = callDuration.inMinutes.remainder(60); + final seconds = callDuration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours.toString().padLeft(2, '0')}:' + '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + return '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; + } + + Future _initializeWebRTC() async { + _webRTCService.onIceCandidate = _handleLocalIceCandidate; + + _webRTCService.onRemoteStream = (_) => notifyListeners(); + + _webRTCService.onIceConnectionStateChange = (RTCIceConnectionState state) { + switch (state) { + case RTCIceConnectionState.RTCIceConnectionStateConnected: + case RTCIceConnectionState.RTCIceConnectionStateCompleted: + if (currentStatus != "Connected") { + _setStatus("Connected"); + _startCallDurationTimer(); + } + break; + case RTCIceConnectionState.RTCIceConnectionStateFailed: + _setStatus("Call failed"); + break; + default: + break; + } + }; + + _webRTCService.onCallEnded = () { + if (currentStatus != "Call Ended" && currentStatus != "Call failed") { + _setStatus("Call failed"); + onFailed?.call("The call connection was lost."); + } + }; + + if (isVideo == true) { + // Always start a fresh video call with both cameras assumed "on", + // regardless of whatever state was left over from a previous session + // on this singleton CallProvider (see endSession()). + isCameraOn = true; + isPeerCameraOn = true; + await _webRTCService.initializeForVideoCall(); + _webRTCService.setCameraEnabled(true); + notifyListeners(); + } else { + await _webRTCService.initializeForAudioCall(); + } + } Future buildHubConnection(String employeeNumber, {HubConnection? hubC}) async { _setStatus("Connecting..."); try { - if (hubC != null) { - final bool reused = await _adoptExistingConnection(hubC); - if (reused) return true; - if (kDebugMode) { - print('ℹ️ Shared chat hub not usable (state: ${hubC.state}), opening a dedicated call connection.'); + if (hubC != null) { + final bool reused = await _adoptExistingConnection(hubC); + if (reused) return true; + if (kDebugMode) print('Shared chat hub not usable (state: ${hubC.state}), opening a dedicated call connection.'); } - } - await _disposeConnection(); + await _disposeConnection(); - final http.Response response = await ApiClient().postJsonForResponse( - URLs.chatSdkToken, - {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "voIPToken": null}, - ); - if (response.statusCode != 200) { - if (kDebugMode) { - print('⚠️ Call token request failed: HTTP ${response.statusCode}'); + final http.Response response = await ApiClient().postJsonForResponse( + URLs.chatSdkToken, + {"apiKey": URLs.chatApiKey, "employeeNumber": employeeNumber, "voIPToken": null}, + ); + if (response.statusCode != 200) { + if (kDebugMode) print('Call token request failed: HTTP ${response.statusCode}'); + return false; } - return false; - } - final ChatLoginResponse chatResponse = ChatLoginResponse.fromJson(jsonDecode(response.body)); - if (chatResponse.token == null || chatResponse.token!.isEmpty) { - if (kDebugMode) { - print('⚠️ Call token request returned an empty token.'); + final ChatLoginResponse chatResponse = ChatLoginResponse.fromJson(jsonDecode(response.body)); + if (chatResponse.token == null || chatResponse.token!.isEmpty) { + if (kDebugMode) print('Call token request returned an empty token.'); + return false; } - return false; - } - callHubConnection = await getHubConnection(chatResponse.token!); - _ownsConnection = true; - await callHubConnection!.start(); - return callHubConnection?.state == HubConnectionState.Connected; + callHubConnection = await getHubConnection(chatResponse.token!); + _ownsConnection = true; + await callHubConnection!.start(); + return callHubConnection?.state == HubConnectionState.Connected; } catch (e) { - if (kDebugMode) { - print('⚠️ Error building call SignalR connection: $e'); - } + _logError('Error building call SignalR connection', e); await _disposeConnection(); return false; } @@ -186,9 +265,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { return true; } } catch (e) { - if (kDebugMode) { - print('⚠️ Could not restart shared chat hub for call: $e'); - } + _logError('Could not restart shared chat hub for call', e); } return false; default: @@ -216,7 +293,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { hub.on("OnCallAcceptedAsync", onCallAcceptedAsync); hub.on("OnIceCandidateAsync", onIceCandidateAsync); hub.on("OnOfferAsync", onOfferAsync); - hub.on("OnAnswerOffer", onAnswerOffer); + hub.on("OnAnswerOfferAsync", onAnswerOffer); hub.on("OnHangUpAsync", onHangUpAsync); hub.on("OnCallDeclinedAsync", onCallDeclinedAsync); hub.on('OnAudioToggle', onAudioToggle); @@ -229,104 +306,6 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { hub.on('OnCameraToggle', onCameraToggle); } - //Video Constraints - var videoConstraints = { - "video": { - "mandatory": { - "width": {"min": 320}, - "height": {"min": 180} - }, - "optional": [ - { - "width": {"max": 1280} - }, - {"frameRate": 25}, - {"facingMode": "user"} - ] - }, - "frameRate": 25, - "width": 420, //420,//640,//1280, - "height": 240 //240//480//720 - }; - - // Audio Constraints - var audioConstraints = { - "sampleRate": 8000, - "sampleSize": 16, - "channelCount": 2, - "echoCancellation": true, - "audio": true, - }; - - Future _createPeerConnection() async { - Map configuration = { - "iceServers": [ - {"urls": 'stun:15.185.116.59:3478'}, - { - "urls": "turn:15.185.116.59:3479", - "username": "admin", - "credential": "admin", - }, - {"urls": "stun:stun.l.google.com:19302"}, - {"urls": "stun:stun1.l.google.com:19302"}, - ] - }; - - Map offerSdpConstraints = { - "mandatory": { - "OfferToReceiveAudio": true, - "OfferToReceiveVideo": true, - }, - "optional": [], - }; - - RTCPeerConnection pc = await createPeerConnection(configuration, offerSdpConstraints); - // if (pc != null) print(pc); - //pc.addStream(widget.localStream); - - pc.onIceCandidate = (e) { - if (e.candidate != null) { - print(json.encode({ - 'candidate': e.candidate.toString(), - 'sdpMid': e.sdpMid.toString(), - 'sdpMlineIndex': e.sdpMLineIndex, - })); - } - }; - pc.onIceConnectionState = (e) { - print(e); - }; - pc.onAddStream = (stream) { - print('addStream: ' + stream.id); - _remoteRenderer.srcObject = stream; - }; - return pc; - } - - void init() { - initRenderers(); - _createPeerConnection().then((pc) { - _peerConnection = pc; - // _setRemoteDescription(widget.info); - }); - } - - void initRenderers() { - _localVideoRenderer.initialize(); - _remoteRenderer.initialize(); - initLocalCamera(); - } - - void initLocalCamera() async { - _localStream = await navigator.mediaDevices.getUserMedia({'video': true, 'audio': true}); - _localVideoRenderer.srcObject = _localStream; - // _localVideoRenderer.srcObject = await navigator.mediaDevices - // .getUserMedia({'video': true, 'audio': true}); - print('this source Object'); - print('this suarce ${_localVideoRenderer.srcObject != null}'); - notifyListeners(); - } - String? senderN; String? recipientN; bool? isVideo; @@ -337,10 +316,11 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { Function(String message)? onSuccess; void startCall(String? senderN, String? recipientN, bool? isVideo, int? moduleId, int? referenceID, {Function(String message)? onFailed, Function(String message)? onSuccess}) async { + _currentCallId = const Uuid().v4(); this.senderN = senderN; this.recipientN = recipientN; this.isVideo = isVideo; - // this.moduleId = moduleId; + if (moduleId != null) this.moduleId = moduleId; this.referenceID = referenceID; this.onFailed = onFailed; this.onSuccess = onSuccess; @@ -349,41 +329,75 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { return; } try { + await _initializeWebRTC(); await invoke(invokeMethod: "CallUserAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo ?? false, data: [this.moduleId, referenceID]); _setStatus("Ringing..."); + _startNoAnswerTimer(); } catch (e) { - if (kDebugMode) { - print('⚠️ CallUserAsync failed: $e'); - } + _logError('CallUserAsync failed', e); _setStatus("Call failed"); onFailed?.call("Could not reach the call service. Please check your connection and try again."); } } + /// Starts (or restarts) the outgoing "no answer" timeout. If the callee + /// hasn't accepted/declined within the window, we fail the call locally + /// and best-effort notify the backend so the callee's ringing UI clears. + void _startNoAnswerTimer() { + _noAnswerTimer?.cancel(); + _noAnswerTimer = Timer(const Duration(seconds: 45), _handleNoAnswerTimeout); + } + + void _cancelNoAnswerTimer() { + _noAnswerTimer?.cancel(); + _noAnswerTimer = null; + } + + Future _handleNoAnswerTimeout() async { + _noAnswerTimer = null; + + // The call may have already connected/ended through another path. + if (currentStatus != "Ringing...") return; + + _setStatus("Call failed"); + + try { + if (senderN != null && recipientN != null) { + await declineCall(senderN!, recipientN!, moduleId: moduleId, reason: 'NO_ANSWER'); + } + } catch (e) { + _logError('Failed to notify backend of no-answer timeout', e); + } + + onFailed?.call("No Answer, User did not answer your call."); + } + void acceptCall(String senderN, String recipientN, bool isVideo, {required int moduleId, Function(String message)? onFailed, Function(String message)? onSuccess}) async { this.senderN = senderN; this.recipientN = recipientN; + this.isVideo = isVideo; this.onFailed = onFailed; this.onSuccess = onSuccess; try { + _setStatus("Connecting..."); await invoke(invokeMethod: "AnswerCallAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: isVideo, data: [this.moduleId]); - _setStatus("Connected"); } catch (e) { - if (kDebugMode) { - print('⚠️ AnswerCallAsync failed: $e'); - } + _logError('AnswerCallAsync failed', e); _setStatus("Call failed"); onFailed?.call("Could not join the call. Please try again."); } } - Future declineCall(String senderN, String recipientN, {required int moduleId}) async { + Future declineCall(String senderN, String recipientN, {required int moduleId, String? reason}) async { try { - await invoke(invokeMethod: "CallDeclinedAsync", currentUserID: senderN, targetUserID: recipientN, isVideoCall: false, data: [this.moduleId]); + await invoke( + invokeMethod: "CallDeclinedAsync", + currentUserID: senderN, + targetUserID: recipientN, + data: reason != null ? [moduleId, reason] : [moduleId], + ); } catch (e) { - if (kDebugMode) { - print('⚠️ CallDeclinedAsync failed: $e'); - } + _logError('CallDeclinedAsync failed', e); } } @@ -391,106 +405,206 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { try { await invoke(invokeMethod: "HangUpAsync", currentUserID: senderN, targetUserID: recipientN, data: [moduleId]); } catch (e) { - if (kDebugMode) { - print('⚠️ HangUpAsync failed: $e'); - } + _logError('HangUpAsync failed', e); } } - void toggleMute() async { + Future toggleMute() async { if (senderN == null || recipientN == null) return; + + final newMutedState = !isMuted; + + _webRTCService.setMicrophoneMuted(newMutedState); + + isMuted = newMutedState; + notifyListeners(); + try { - await invoke(invokeMethod: "AudioToggle", currentUserID: senderN!, targetUserID: recipientN!); - isMuted = !isMuted; - notifyListeners(); + await invoke( + invokeMethod: "AudioToggle", + currentUserID: senderN!, + targetUserID: recipientN!, + ); } catch (e) { - if (kDebugMode) { - print('⚠️ AudioToggle failed: $e'); - } + _logError('AudioToggle failed', e); } } - void checkCall(Map message) { - switch (message["callStatus"]) { - case 'connected': - {} - break; - case 'offer': - {} - break; - case 'accept': - {} - break; - case 'candidate': - {} - break; - case 'bye': - {} - break; - case 'leave': - {} - break; + Future toggleCamera() async { + if (senderN == null || recipientN == null) return; + + final newCameraState = !isCameraOn; + + _webRTCService.setCameraEnabled(newCameraState); + + isCameraOn = newCameraState; + notifyListeners(); + + try { + await invoke( + invokeMethod: "CameraToggle", + currentUserID: senderN!, + targetUserID: recipientN!, + ); + } catch (e) { + _logError('CameraToggle failed', e); } } + Future switchCamera() async { + await _webRTCService.switchCamera(); + } + //// Listeners Methods //// + Future onCallAcceptedAsync(List? params) async { + _cancelNoAnswerTimer(); + try { + final RTCSessionDescription offer = await _webRTCService.createOffer(); + if (offer.sdp == null || offer.sdp!.isEmpty) { + throw Exception("Generated WebRTC offer SDP is empty"); + } - void onCallAcceptedAsync(List? params) { - print("onCallAcceptedAsync: $params"); - _setStatus("Connected"); + if (_currentCallId == null) { + throw Exception("Current call ID is missing"); + } + await invoke( + invokeMethod: "OfferAsync", + currentUserID: senderN!, + targetUserID: recipientN!, + data: [ + offer.sdp, + _currentCallId, + ], + ); + _setStatus("Connecting..."); + } catch (e) { + _logError('Failed to create/send WebRTC offer', e); + _setStatus("Call failed"); + onFailed?.call("Failed to establish the call."); + } } - void onIceCandidateAsync(List? params) { - print("onCallAcceptedAsync: $params"); + Future toggleSpeaker() async { + final newSpeakerState = !isSpeakerOn; + + await _webRTCService.setSpeakerphoneEnabled(newSpeakerState); + + isSpeakerOn = newSpeakerState; + notifyListeners(); } - void onOfferAsync(List? params) { - print("onCallAcceptedAsync: $params"); - print("onCallAcceptedAsync: $params"); + Future onIceCandidateAsync(List? params) async { + if (params == null || params.length < 2) return; + + try { + final String? candidateJson = params[0]?.toString(); + if (candidateJson == null || candidateJson.isEmpty) return; + + final Map candidateData = jsonDecode(candidateJson) as Map; + + final RTCIceCandidate candidate = RTCIceCandidate( + candidateData['candidate']?.toString(), + candidateData['sdpMid']?.toString(), + candidateData['sdpMLineIndex'] is int + ? candidateData['sdpMLineIndex'] as int + : int.tryParse( + candidateData['sdpMLineIndex']?.toString() ?? '', + ), + ); + + await _webRTCService.addIceCandidate(candidate); + } catch (e) { + _logError('Failed to process remote ICE candidate', e); + } + } + + Future onOfferAsync(List? params) async { + if (params == null || params.length < 2) return; + + final String? offerSdp = params[0]?.toString(); + final String? callId = params[1]?.toString(); + + if (offerSdp == null || offerSdp.isEmpty) return; + if (callId == null || callId.isEmpty) return; + + try { + _currentCallId = callId; + + await _initializeWebRTC(); + + final RTCSessionDescription answer = await _webRTCService.createAnswer(offerSdp); + + if (answer.sdp == null || answer.sdp!.isEmpty) { + throw Exception("Generated WebRTC answer SDP is empty"); + } + + await invoke( + invokeMethod: "AnswerOfferAsync", + currentUserID: senderN!, + targetUserID: recipientN!, + data: [ + answer.sdp, + _currentCallId, + ], + ); + } catch (e) { + _logError('Failed to handle incoming offer', e); + } } - void onAnswerOffer(List? params) { - print("onAnswerOffer: $params"); + Future onAnswerOffer(List? params) async { + if (params == null || params.length < 2) return; + + final String? answerSdp = params[0]?.toString(); + final String? callId = params[1]?.toString(); + + if (answerSdp == null || answerSdp.isEmpty) return; + if (callId == null || callId.isEmpty) return; + + try { + _currentCallId = callId; + await _webRTCService.setRemoteAnswer(answerSdp); + } catch (e) { + _logError('Failed to set remote answer', e); + } } void onHangUpAsync(List? params) { - print("onHangUpAsync: $params"); handleResponse(params); } void onCallDeclinedAsync(List? params) { - print("onCallDeclinedAsync: $params"); handleResponse(params); - // try { - // if (params?.isNotEmpty ?? false) { - // print("onCallDeclinedAsync:${params?.length}:${params}"); - // if (params!.length > 2) { - // print("runTimeData:${params[1].runtimeType}"); - // var data = jsonDecode(jsonEncode(params[1].toString())); - // print("runTimeData:${data.runtimeType}"); - // onFailed?.call("Call Declined, ${data["targetUserId"]} has declined your call."); - // return; - // } else { print("runTimeData:${params[0].runtimeType}"); - // var data = jsonDecode(params[0].toString()); - // print("runTimeData:${data.runtimeType}"); - // onFailed?.call("Call Declined, ${data["targetUserId"]} has declined your call."); - // return; - // } - // } - // } catch (ex) { - // print("Ex:$ex"); - // onFailed?.call("Call Declined, User has declined your call."); - // } + } + + void _handleLocalIceCandidate(RTCIceCandidate candidate) { + if (senderN == null || recipientN == null) return; + if (_currentCallId == null) return; + invoke( + invokeMethod: "IceCandidateAsync", + currentUserID: senderN!, + targetUserID: recipientN!, + data: [ + jsonEncode({ + "candidate": candidate.candidate, + "sdpMid": candidate.sdpMid, + "sdpMLineIndex": candidate.sdpMLineIndex, + }), + _currentCallId, + ], + ).catchError((error) { + _logError('Failed to send ICE candidate', error); + }); } void onAudioToggle(List? params) { - print("onAudioToggle: $params"); isPeerMuted = !isPeerMuted; notifyListeners(); } void onCameraToggle(List? params) { - print("onCameraToggle: $params"); + isPeerCameraOn = !isPeerCameraOn; + notifyListeners(); } //// Invoke Methods @@ -498,26 +612,31 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { Future invoke({required String invokeMethod, required String currentUserID, required String targetUserID, bool isVideoCall = false, var data}) async { List args = []; if (data != null) { - data = data.toString().replaceFirst("[", "").replaceFirst("]", ""); + if (data is List) { + data = data.map((e) => e.toString()).toList(); + } else { + data = data.toString(); + } } if (invokeMethod == "AnswerCallAsync") { - args = [currentUserID, targetUserID, data]; + args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == 'CallDeclinedAsync') { - args = [currentUserID, targetUserID, data]; + args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == "CallUserAsync") { args = [currentUserID, targetUserID, isVideoCall]; } else if (invokeMethod == "HangUpAsync") { - args = [currentUserID, targetUserID, data]; + args = [currentUserID, targetUserID, ...data]; } else if (invokeMethod == "AudioToggle") { args = [currentUserID, targetUserID]; + } else if (invokeMethod == "CameraToggle") { + args = [currentUserID, targetUserID]; } else if (invokeMethod == "IceCandidateAsync") { - args = [targetUserID, data]; + args = [targetUserID, ...data]; } else if (invokeMethod == "OfferAsync") { - args = [targetUserID, data]; + args = [targetUserID, ...data]; } else if (invokeMethod == "AnswerOfferAsync") { - args = [targetUserID, data]; + args = [targetUserID, ...data]; } - print("Invoking:$invokeMethod:$args"); final HubConnection? hub = callHubConnection; if (hub == null || hub.state != HubConnectionState.Connected) { throw StateError("Call hub is not connected (state: ${hub?.state}). Cannot invoke $invokeMethod."); @@ -529,7 +648,7 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { callHubConnection?.off('OnCallDeclinedAsync'); callHubConnection?.off('OnCallAcceptedAsync'); callHubConnection?.off('OnIceCandidateAsync'); - callHubConnection?.off('OnAnswerOffer'); + callHubConnection?.off('OnAnswerOfferAsync'); callHubConnection?.off('OnOfferAsync'); callHubConnection?.off('OnHangUpAsync'); callHubConnection?.off('OnAudioToggle'); @@ -537,13 +656,11 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin { } handleResponse(List? params) { + _cancelNoAnswerTimer(); if (params?.isNotEmpty ?? false) { if (params![0] == "NO_ANSWER") { onFailed?.call("No Answer, User did not answer your call."); return; - } else if (params[0] == "NO_ANSWER") { - onFailed?.call("Call Declined, User has declined your call."); - return; } else if (params[0] == 'USER_BUSY') { onFailed?.call("The user is currently busy on another call"); } else if (params[0] == 'USER_DECLINE') { diff --git a/lib/modules/cx_module/chat/calling/video_call_page.dart b/lib/modules/cx_module/chat/calling/video_call_page.dart new file mode 100644 index 00000000..ccc0fbdf --- /dev/null +++ b/lib/modules/cx_module/chat/calling/video_call_page.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:provider/provider.dart'; +import 'package:test_sa/extensions/int_extensions.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart'; +import 'package:test_sa/views/widgets/item_views/info_text_widget.dart'; + +import '../chat_provider.dart'; +import 'call_provider.dart'; + +class VideoCallPage extends StatefulWidget { + final Participants sender; + final Participants recipient; + final int moduleId; + final int? referenceID; + final bool isAccept; + + const VideoCallPage({Key? key, required this.sender, required this.recipient, this.isAccept = false, required this.moduleId, this.referenceID}) : super(key: key); + + @override + State createState() => _VideoCallPageState(); +} + +class _VideoCallPageState extends State { + late CallProvider callPro; + + bool isDialogOpen = false; + bool isLocalVideoFullscreen = false; + + @override + void initState() { + super.initState(); + callPro = Provider.of(context, listen: false); + makeCall(); + } + + void makeCall() async { + bool isConnected = false; + try { + isConnected = await callPro.buildHubConnection(widget.sender.employeeNumber ?? "", hubC: chatHubConnection); + } catch (e) { + isConnected = false; + } + + if (!mounted) return; + if (!isConnected) { + _showFailure("Error while connecting to call service. Please try again later.", title: "Connection Error"); + return; + } + + callPro.initCallListeners(); + if (widget.isAccept) { + callPro.acceptCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", true, moduleId: widget.moduleId, onSuccess: (String msg) { + _closeCall(); + }, onFailed: (String msg) { + _showFailure(msg, title: "Failed"); + }); + } else { + callPro.startCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", true, widget.moduleId, widget.referenceID, onSuccess: (String msg) { + _closeCall(); + }, onFailed: (String msg) { + _showFailure(msg, title: "Failed"); + }); + } + } + + void _closeCall() { + callPro.endSession(); + if (mounted) Navigator.pop(context); + } + + void _showFailure(String message, {required String title}) { + callPro.endSession(); + if (!mounted || isDialogOpen) return; + isDialogOpen = true; + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (BuildContext cxt) => PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) return; + }, + child: SingleBtnDialog( + title: title, + message: message, + okTitle: "Go Back", + onTap: () { + Navigator.pop(cxt); + if (mounted) Navigator.pop(context); + }), + ), + ).then((_) { + isDialogOpen = false; + }); + } + + @override + void dispose() { + callPro.endSession(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + _background(), + // NOTE: the selector must include renderer/srcObject readiness, not + // just isPeerCameraOn. isPeerCameraOn rarely changes during a call, + // so a Selector keyed on it alone never rebuilds once the remote + // video stream attaches later (after WebRTC negotiation finishes) - + // which made the remote feed look permanently "off". + Selector( + selector: (_, p) => (p.isPeerCameraOn, p.webRTCService.remoteRenderer?.srcObject != null), + builder: (_, state, __) => isLocalVideoFullscreen ? _fullscreenLocalVideo() : _remoteVideo(state.$1), + ), + isLocalVideoFullscreen ? _smallRemoteVideo() : _localVideoPreview(), + _topBar(), + _bottomControls(), + ], + ), + ); + } + + Widget _background() { + return Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF202641), + Color(0xFF283859), + Color(0xFF335073), + Color(0xFF425E7A), + Color(0xFF4A637D), + Color(0xFF39495F), + Color(0xFF1A2032), + Color(0xFF121426), + Color(0xFF121426), + Color(0xFF25283A), + Color(0xFF383D4D), + ], + stops: [0.0, 0.08, 0.15, 0.23, 0.27, 0.34, 0.42, 0.51, 0.70, 0.89, 0.97], + ), + ), + ); + } + + Widget _remoteVideo(bool isPeerCameraOn) { + final renderer = callPro.webRTCService.remoteRenderer; + final hasStream = renderer?.srcObject != null; + return Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => isLocalVideoFullscreen = true), + child: !isPeerCameraOn || !hasStream + ? _avatarPlaceholder(widget.recipient.userName ?? '', large: true) + : RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover), + ), + ); + } + + Widget _fullscreenLocalVideo() { + return Selector( + selector: (_, p) => (p.isCameraOn, p.webRTCService.localRenderer?.srcObject != null), + builder: (_, state, __) { + final isCameraOn = state.$1; + final renderer = callPro.webRTCService.localRenderer; + final hasStream = renderer?.srcObject != null; + return Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => isLocalVideoFullscreen = false), + child: !isCameraOn || !hasStream + ? const Center(child: Icon(Icons.videocam_off, color: Colors.white54, size: 64)) + : RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true), + ), + ); + }, + ); + } + + Widget _localVideoPreview() { + return Positioned( + top: 90, + right: 16, + child: GestureDetector( + onTap: () => setState(() => isLocalVideoFullscreen = true), + child: Selector( + selector: (_, p) => (p.isCameraOn, p.webRTCService.localRenderer?.srcObject != null), + builder: (_, state, __) { + final isCameraOn = state.$1; + final renderer = callPro.webRTCService.localRenderer; + final hasStream = renderer?.srcObject != null; + return _previewBox( + child: !isCameraOn || !hasStream + ? const Center(child: Icon(Icons.videocam_off, color: Colors.white, size: 28)) + : RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true), + ); + }, + ), + ), + ); + } + + Widget _smallRemoteVideo() { + return Positioned( + top: 90, + right: 16, + child: GestureDetector( + onTap: () => setState(() => isLocalVideoFullscreen = false), + child: Selector( + selector: (_, p) => (p.isPeerCameraOn, p.webRTCService.remoteRenderer?.srcObject != null), + builder: (_, state, __) { + final isPeerCameraOn = state.$1; + final renderer = callPro.webRTCService.remoteRenderer; + final hasStream = renderer?.srcObject != null; + return _previewBox( + child: !isPeerCameraOn || !hasStream + ? _avatarPlaceholder(widget.recipient.userName ?? '', large: false) + : RTCVideoView(renderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover), + ); + }, + ), + ), + ); + } + + Widget _previewBox({required Widget child}) { + return Container( + width: 110, + height: 150, + decoration: BoxDecoration( + color: const Color(0xff414352), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: .2), width: 1), + ), + child: ClipRRect(borderRadius: BorderRadius.circular(11), child: child), + ); + } + + Widget _avatarPlaceholder(String name, {required bool large}) { + return Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person, size: large ? 80 : 36, color: Colors.white54), + if (large && name.isNotEmpty) ...[ + 12.height, + InfoTextLabelWidget(label: name, textStyle: AppTextStyles.heading5, color: Colors.white), + ], + ], + ), + ); + } + + Widget _topBar() { + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Column( + children: [ + 16.height, + InfoTextLabelWidget(label: widget.recipient.userName ?? '', textStyle: AppTextStyles.heading4.copyWith(fontWeight: FontWeight.w600), color: Colors.white), + 4.height, + Selector( + selector: (_, p) => p.currentStatus == "Connected" ? p.formattedCallDuration : p.currentStatus, + builder: (_, status, __) => InfoTextLabelWidget(label: status, textStyle: AppTextStyles.heading6, color: Colors.white), + ), + 4.height, + Selector( + selector: (_, p) => p.isPeerMuted, + builder: (_, isPeerMuted, __) => isPeerMuted ? InfoTextLabelWidget(label: "Mic Mute", textStyle: AppTextStyles.tinyFont, color: AppColor.red30) : const SizedBox(), + ), + ], + ), + ), + ); + } + + Widget _bottomControls() { + return Positioned( + left: 24, + right: 24, + bottom: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Selector( + selector: (_, p) => p.isMuted, + builder: (_, isMuted, __) => callButton(isMuted ? "calling/mic_disable" : "calling/mic_enable", () => callPro.toggleMute()), + ), + Selector( + selector: (_, p) => p.isCameraOn, + builder: (_, isCameraOn, __) => callButton(isCameraOn ? "calling/video_enable" : "calling/video_disable", () => callPro.toggleCamera()), + ), + callButton("calling/toggle_camera", () => callPro.switchCamera()), + callButton("calling/end_call", () { + callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId); + Navigator.pop(context); + }, isEndCall: true, size: 68), + ], + ), + ); + } + + Widget callButton(String icon, VoidCallback onTap, {bool isEndCall = false, double size = 52}) { + return (isEndCall + ? Container( + height: size, + width: size, + padding: const EdgeInsets.all(18), + decoration: const BoxDecoration(color: AppColor.red30, shape: BoxShape.circle), + child: icon.toSvgAsset(height: 24, width: 24, color: Colors.white), + ) + : Container( + height: size, + width: size, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: const Color(0xff414352), shape: BoxShape.circle, border: Border.all(color: Colors.white.withValues(alpha: .2), width: 1)), + child: icon.toSvgAsset(height: 24, width: 24, color: Colors.white), + )) + .onPress(onTap); + } +} diff --git a/lib/modules/cx_module/chat/calling/webrtc_service.dart b/lib/modules/cx_module/chat/calling/webrtc_service.dart new file mode 100644 index 00000000..a764c0ff --- /dev/null +++ b/lib/modules/cx_module/chat/calling/webrtc_service.dart @@ -0,0 +1,635 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class WebRTCService { + // Peer connection + RTCPeerConnection? _peerConnection; + + // Media streams + MediaStream? _localStream; + MediaStream? _remoteStream; + + // Video renderers (for future video support) + RTCVideoRenderer? localRenderer; + RTCVideoRenderer? remoteRenderer; + + // Callbacks + Function(MediaStream stream)? onLocalStream; + Function(MediaStream stream)? onRemoteStream; + Function(RTCIceCandidate candidate)? onIceCandidate; + Function(RTCIceConnectionState state)? onIceConnectionStateChange; + Function()? onCallEnded; + + final List _iceCandidateQueue = []; + bool _remoteDescriptionSet = false; + + Completer? _initializationCompleter; + bool _isFullyInitialized = false; + + bool _isVideoCall = false; + + static bool _useTestMode = false; + + bool get isFullyInitialized => _isFullyInitialized; + bool get isPeerConnectionInitialized => _peerConnection != null && _isFullyInitialized; + Future waitForInitialization() async { + if (_isFullyInitialized) { + return; + } + + if (_initializationCompleter != null) { + await _initializationCompleter!.future; + } + } + + static void enableTestMode() { + _useTestMode = true; + } + + static void disableTestMode() { + _useTestMode = false; + } + + static const Map _mediaConstraints = { + 'audio': true, + 'video': false, + }; + + static const Map _videoConstraints = { + 'audio': true, + 'video': { + 'facingMode': 'user', + 'width': {'ideal': 1280}, + 'height': {'ideal': 720}, + }, + }; + + static const Map _testConfiguration = { + 'iceServers': [ + { + 'urls': [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + 'stun:stun2.l.google.com:19302', + ] + }, + ], + 'sdpSemantics': 'unified-plan', + 'iceTransportPolicy': 'all', + 'iceCandidatePoolSize': 10, + }; + //Need to move the to safe place.. + + final Map _configuration = { + 'iceServers': [ + { + 'urls': [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + ] + }, + + // Backend TURN server (PRIMARY) + { + 'urls': [ + 'turn:15.185.116.59:3479', + 'turn:15.185.116.59:3479?transport=tcp', + ], + 'username': 'admin', + 'credential': 'admin' + }, + + // FALLBACK: Public TURN servers + { + 'urls': 'turn:a.relay.metered.ca:80', + 'username': 'e14d93f87f9ff517f1bee797', + 'credential': 'PGfCGmR6CR2aM9OY', + }, + { + 'urls': 'turn:a.relay.metered.ca:443', + 'username': 'e14d93f87f9ff517f1bee797', + 'credential': 'PGfCGmR6CR2aM9OY', + }, + ], + 'sdpSemantics': 'unified-plan', + 'iceTransportPolicy': 'all', + 'iceCandidatePoolSize': 10, + 'bundlePolicy': 'max-bundle', + 'rtcpMuxPolicy': 'require', + }; + + // Initialize WebRTC for audio call + Future initializeForAudioCall() async { + if (_isFullyInitialized) { + return; + } + + _initializationCompleter = Completer(); + _isFullyInitialized = false; + + try { + _isVideoCall = false; + try { + _localStream = await navigator.mediaDevices.getUserMedia( + _mediaConstraints, + ); + + final audioTracks = _localStream!.getAudioTracks(); + + for (var i = 0; i < audioTracks.length; i++) { + final track = audioTracks[i]; + + if (!track.enabled) { + track.enabled = true; + } + } + + if (audioTracks.isEmpty) { + throw Exception( + 'No audio tracks captured from microphone', + ); + } + } catch (e) { + throw Exception( + 'Microphone access denied or unavailable', + ); + } + _peerConnection = await createPeerConnection( + _configuration, + ); + + if (_peerConnection == null) { + throw Exception( + 'Failed to create peer connection', + ); + } + _setupPeerConnectionListeners(); + + final tracks = _localStream!.getTracks(); + + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + + await _peerConnection!.addTrack( + track, + _localStream!, + ); + } + + try { + await Helper.setSpeakerphoneOn(false); + } catch (e) {} + + _isFullyInitialized = true; + + if (!_initializationCompleter!.isCompleted) { + _initializationCompleter!.complete(); + } + } catch (e, stackTrace) { + log('[WebRTC] Audio init error: $e', name: 'WebRTCService'); + + if (_initializationCompleter != null && + !_initializationCompleter!.isCompleted) { + _initializationCompleter!.completeError( + e, + stackTrace, + ); + } + + _isFullyInitialized = false; + + await dispose(); + + rethrow; + } + } + + //video call initialization + Future initializeForVideoCall() async { + if (_isFullyInitialized) { + return; + } + _initializationCompleter = Completer(); + _isFullyInitialized = false; + + try { + _isVideoCall = true; + + // Initialize video renderers + localRenderer = RTCVideoRenderer(); + remoteRenderer = RTCVideoRenderer(); + + await localRenderer!.initialize(); + await remoteRenderer!.initialize(); + + // Get camera + microphone stream first + try { + _localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints); + } catch (e) { + throw Exception('Camera or microphone access denied or unavailable'); + } + + if (_localStream == null) { + throw Exception('Local video stream is null'); + } + + for (final track in _localStream!.getTracks()) { + if (!track.enabled) { + track.enabled = true; + } + } + + // Attach local stream to renderer + localRenderer!.srcObject = _localStream; + + // Create peer connection + final config = _useTestMode ? _testConfiguration : _configuration; + + _peerConnection = await createPeerConnection(config); + + if (_peerConnection == null) { + throw Exception('Failed to create peer connection'); + } + + // Setup listeners + _setupPeerConnectionListeners(); + + // Add local tracks + final tracks = _localStream!.getTracks(); + + if (tracks.isEmpty) { + throw Exception('No media tracks found'); + } + + for (final track in tracks) { + await _peerConnection!.addTrack( + track, + _localStream!, + ); + } + + _isFullyInitialized = true; + + if (!_initializationCompleter!.isCompleted) { + _initializationCompleter!.complete(); + } + } catch (e, stackTrace) { + log('[WebRTC] Video init error: $e', name: 'WebRTCService'); + + if (_initializationCompleter != null && + !_initializationCompleter!.isCompleted) { + _initializationCompleter!.completeError( + e, + stackTrace, + ); + } + + _isFullyInitialized = false; + + await dispose(); + + rethrow; + } + } + + // Setup peer connection event listener + void _setupPeerConnectionListeners() { + _peerConnection!.onIceCandidate = (RTCIceCandidate candidate) { + onIceCandidate?.call(candidate); + }; + + _peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {}; + + _peerConnection!.onIceConnectionState = (RTCIceConnectionState state) { + log('[WEBRTC] ICE connection state: $state', name: 'WebRTCService'); + onIceConnectionStateChange?.call(state); + }; + + _peerConnection!.onTrack = (RTCTrackEvent event) { + if (event.streams.isEmpty) { + return; + } + + final stream = event.streams.first; + + if (_remoteStream == null || _remoteStream!.id != stream.id) { + _remoteStream = stream; + + if (_isVideoCall && remoteRenderer != null) { + try { + remoteRenderer!.srcObject = _remoteStream; + + // Safety refresh for renderer race condition + Future.delayed(const Duration(milliseconds: 100), () { + if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { + remoteRenderer!.srcObject = _remoteStream; + } + }); + + Future.delayed(const Duration(milliseconds: 300), () { + if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { + remoteRenderer!.srcObject = _remoteStream; + } + }); + } catch (e) { + log('[WEBRTC] Remote renderer error: $e', name: 'WebRTCService'); + } + } + + onRemoteStream?.call(_remoteStream!); + } else { + // Refresh renderer if stream exists but UI missed it + if (_isVideoCall && remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) { + try { + remoteRenderer!.srcObject = _remoteStream; + } catch (e) { + log('[WEBRTC] Renderer refresh failed: $e', name: 'WebRTCService'); + } + } + + onRemoteStream?.call(_remoteStream!); + } + }; + + _peerConnection!.onConnectionState = (RTCPeerConnectionState state) { + log('[WEBRTC] Connection state: $state', name: 'WebRTCService'); + + if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) { + onCallEnded?.call(); + } + }; + } + + + /// Create SDP offer (caller side) + Future createOffer() async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + final offer = await _peerConnection!.createOffer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + 'iceRestart': false, + }); + + await _peerConnection!.setLocalDescription(offer); + + return offer; + } catch (e, stackTrace) { + log('❌ [WebRTC] Error creating offer: $e\nStack: $stackTrace', name: 'WebRTCService'); + rethrow; + } + } + + /// Create SDP answer (callee side) + Future createAnswer(String offerSdp) async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + final offer = RTCSessionDescription(offerSdp, 'offer'); + await _peerConnection!.setRemoteDescription(offer); + _remoteDescriptionSet = true; + + await _flushIceCandidateQueue(); + + final answer = await _peerConnection!.createAnswer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + }); + + await _peerConnection!.setLocalDescription(answer); + + return answer; + } catch (e, stackTrace) { + log('❌ [WebRTC] Error creating answer: $e\nStack: $stackTrace', name: 'WebRTCService'); + rethrow; + } + } + + /// Set remote answer (caller side) + Future setRemoteAnswer(String answerSdp) async { + try { + if (_peerConnection == null) { + throw Exception('Peer connection not initialized'); + } + + final answer = RTCSessionDescription(answerSdp, 'answer'); + await _peerConnection!.setRemoteDescription(answer); + _remoteDescriptionSet = true; + + await _flushIceCandidateQueue(); + + } catch (e, stackTrace) { + log('❌ [WebRTC] Error setting remote answer: $e\nStack: $stackTrace', name: 'WebRTCService'); + rethrow; + } + } + + /// Send SDP answer (convenience method for callee) + Future sendSDPAnswer(String? offerSdp) async { + if (offerSdp == null) { + throw Exception('Offer SDP is null'); + } + return await createAnswer(offerSdp); + } + + /// Add remote ICE candidate + Future addIceCandidate(RTCIceCandidate candidate) async { + try { + // If peer connection not created yet, queue the candidate + if (_peerConnection == null) { + _iceCandidateQueue.add(candidate); + return; + } + + // If remote description not set yet, queue the candidate + if (!_remoteDescriptionSet) { + _iceCandidateQueue.add(candidate); + return; + } + + await _peerConnection!.addCandidate(candidate); + } catch (e) { + log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService'); + // Don't rethrow - ICE candidate errors shouldn't break the call + } + } + + /// Flush queued ICE candidates + Future _flushIceCandidateQueue() async { + if (_iceCandidateQueue.isEmpty) { + return; + } + + for (final candidate in _iceCandidateQueue) { + try { + await _peerConnection!.addCandidate(candidate); + } catch (e) { + log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService'); + } + } + + _iceCandidateQueue.clear(); + } + + /// Toggle microphone mute + void setMicrophoneMuted(bool muted) { + if (_localStream != null) { + final audioTracks = _localStream!.getAudioTracks(); + for (final track in audioTracks) { + track.enabled = !muted; + } + } + } + + /// Toggle camera on/off for video calls + void setCameraEnabled(bool enabled) { + if (_localStream != null) { + final videoTracks = _localStream!.getVideoTracks(); + for (final track in videoTracks) { + track.enabled = enabled; + } + } + } + + /// Switch between front and rear camera + Future switchCamera() async { + if (_localStream != null) { + final videoTracks = _localStream!.getVideoTracks(); + if (videoTracks.isNotEmpty) { + try { + await Helper.switchCamera(videoTracks.first); + } catch (e) { + log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService'); + } + } + } + } + + /// Enable/disable speakerphone + Future setSpeakerphoneEnabled(bool enabled) async { + try { + await Helper.setSpeakerphoneOn(enabled); + } catch (e) { + log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService'); + } + } + + /// Get local stream + MediaStream? get localStream => _localStream; + + /// Get remote stream + MediaStream? get remoteStream => _remoteStream; + + /// Check if remote stream has video tracks + bool get hasRemoteVideo { + if (_remoteStream == null) return false; + return _remoteStream!.getVideoTracks().isNotEmpty; + } + + /// Force refresh remote renderer + void refreshRemoteRenderer() { + if (_remoteStream != null && remoteRenderer != null && _isVideoCall) { + if (remoteRenderer!.srcObject == null) { + remoteRenderer!.srcObject = _remoteStream; + } + } + } + + /// Restart ICE negotiation (used when connection fails) + Future restartIce() async { + try { + if (_peerConnection == null) { + return null; + } + + final offer = await _peerConnection!.createOffer({ + 'offerToReceiveAudio': true, + 'offerToReceiveVideo': _isVideoCall, + 'iceRestart': true, + }); + + await _peerConnection!.setLocalDescription(offer); + + return offer; + } catch (e) { + log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService'); + return null; + } + } + + /// Get peer connection state + RTCPeerConnectionState? getPeerConnectionState() { + return _peerConnection?.connectionState; + } + + /// Get ICE connection state + RTCIceConnectionState? getIceConnectionState() { + return _peerConnection?.iceConnectionState; + } + + /// Dispose and cleanup + Future dispose() async { + try { + if (_localStream != null) { + _localStream!.getTracks().forEach((track) { + track.stop(); + }); + await _localStream!.dispose(); + _localStream = null; + } + + if (_remoteStream != null) { + _remoteStream!.getTracks().forEach((track) { + track.stop(); + }); + await _remoteStream!.dispose(); + _remoteStream = null; + } + if (localRenderer != null) { + + localRenderer!.srcObject = null; + + await localRenderer!.dispose(); + + localRenderer = null; + } + + + if (remoteRenderer != null) { + + remoteRenderer!.srcObject = null; + + await remoteRenderer!.dispose(); + + remoteRenderer = null; + } + + + if (_peerConnection != null) { + await _peerConnection!.close(); + await _peerConnection!.dispose(); + _peerConnection = null; + } + + _iceCandidateQueue.clear(); + _remoteDescriptionSet = false; + + // Reset service state because this is a singleton + _isFullyInitialized = false; + _initializationCompleter = null; + _isVideoCall = false; + + } catch (e) { + log( + '⚠️ [WebRTC] Error during dispose: $e', + name: 'WebRTCService', + ); + } + } +} diff --git a/lib/modules/cx_module/chat/chat_page.dart b/lib/modules/cx_module/chat/chat_page.dart index 66b0aa56..1a246add 100644 --- a/lib/modules/cx_module/chat/chat_page.dart +++ b/lib/modules/cx_module/chat/chat_page.dart @@ -17,7 +17,6 @@ import 'package:test_sa/helper/utils.dart'; import 'package:test_sa/models/service_request/service_request.dart'; import 'package:test_sa/modules/cm_module/cm_detail_provider.dart'; import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart'; -import 'package:test_sa/modules/cx_module/chat/calling/call_page.dart'; import 'package:test_sa/modules/cx_module/chat/chat_api_client.dart'; import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; import 'package:test_sa/modules/cx_module/chat/view_all_attachment_page.dart'; @@ -28,6 +27,7 @@ import 'package:test_sa/views/widgets/dialogs/dialog.dart'; import 'package:test_sa/views/widgets/sound/sound_player.dart'; import 'calling/audio_call_page.dart'; +import 'calling/video_call_page.dart'; import 'helper/chat_audio_player.dart'; import 'helper/chat_file_picker.dart'; import 'helper/chat_file_viewer.dart'; @@ -269,7 +269,7 @@ class _ChatPageState extends State { context.showConfirmDialog("To perform Video call, we need permission to access microphone & Camera.", title: "Permission Required", onTap: () async { Navigator.pop(context); videoPermission = await Provider.of(context, listen: false).requestVideoPermissions(); - if (videoPermission) { + if (!videoPermission) { bool openSetting = await showDialog( context: context, builder: (_) => const AAlertDialog( @@ -281,10 +281,25 @@ class _ChatPageState extends State { ); if (!openSetting) PermissionHandler.openAppSettings(); return; + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => VideoCallPage( + sender: Provider.of(context, listen: false).sender!, + recipient: Provider.of(context, listen: false).recipient!, + moduleId: widget.moduleId, + referenceID: widget.requestId, + ))); } }); + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => VideoCallPage( + sender: Provider.of(context, listen: false).sender!, + recipient: Provider.of(context, listen: false).recipient!, + moduleId: widget.moduleId, + referenceID: widget.requestId, + ))); } - Navigator.of(context).push(MaterialPageRoute(builder: (_) => CallPage(isAudio: false))); }, ), ], diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index 4f6a010b..da7a5f12 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -235,7 +235,30 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { // OPTIMIZATION: Wrap connection in try-catch to prevent leaks on error if (!readOnly) { try { - await buildHubConnection(chatParticipantModel!.id!.toString()); + // `chatParticipantModel` is normally populated by `getUserAutoLoginTokenSilent()`, + // which is fired (unawaited) from the previous screen's chat badge/icon. If the + // user opens this page before that finishes (slow network), or a previous + // session's model was reset/never matched this conversation, `chatParticipantModel` + // (or its `id`) can legitimately still be null here. + // + // This used to be `chatParticipantModel!.id!.toString()`, which threw an opaque + // "Null check operator used on a null value" and silently skipped building the + // SignalR hub connection - breaking real-time chat updates and the hub reuse + // that audio/video calls rely on (CallProvider.buildHubConnection(hubC: chatHubConnection)). + String? conversationId = chatParticipantModel?.id?.toString(); + if (conversationId == null || conversationId.isEmpty) { + if (kDebugMode) { + print('⚠️ chatParticipantModel/id missing before connectToHub - fetching participants now.'); + } + chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber); + conversationId = chatParticipantModel?.id?.toString(); + } + + if (conversationId == null || conversationId.isEmpty) { + throw StateError('Unable to resolve chat conversation id for module $moduleId / request $requestId.'); + } + + await buildHubConnection(conversationId); } catch (e) { if (kDebugMode) { print('⚠️ Failed to build hub connection: $e'); diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index d8adcc3d..9d90d8c5 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -19,6 +19,8 @@ import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/models/enums/user_types.dart'; import 'package:test_sa/modules/cx_module/chat/calling/audio_call_page.dart'; import 'package:test_sa/modules/cx_module/chat/calling/call_provider.dart'; +import 'package:test_sa/modules/cx_module/chat/calling/video_call_page.dart'; +import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; @@ -270,28 +272,68 @@ class _LandPageState extends State { } Future _handleCallDeclined(CallKitParams callKitParams) async { + FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); var calPro = Provider.of(context, listen: false); bool isConnected = await calPro.buildHubConnection(callKitParams.extra?["calleeEmployeeNumber"]); if (isConnected) { - await calPro.declineCall(callKitParams.extra?["calleeEmployeeNumber"], callKitParams.extra?["callerEmployeeNumber"], moduleId: int.parse(callKitParams.extra!["moduleId"])); + await calPro.declineCall(callKitParams.extra?["calleeEmployeeNumber"], callKitParams.extra?["callerEmployeeNumber"], + moduleId: int.tryParse(callKitParams.extra?["moduleId"]?.toString() ?? '') ?? 0); calPro.dispose(); } } Future _handleCallEnded(CallKitParams callKitParams) async { + FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); FlutterCallkitIncoming.endAllCalls(); } - void _handleCallAccepted(CallKitParams callKitParams) { + void _handleCallAccepted(CallKitParams callKitParams) async { + FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); FlutterCallkitIncoming.endAllCalls(); + + final extra = callKitParams.extra ?? {}; + final isVideoCallValue = extra["isVideoCall"]; + final isVideoCall = isVideoCallValue == true || isVideoCallValue?.toString().toLowerCase() == 'true'; + final moduleId = int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0; + final referenceID = int.tryParse(extra["referenceId"]?.toString() ?? ''); + final calleeEmployeeNumber = extra["calleeEmployeeNumber"]?.toString() ?? ''; + final callerEmployeeNumber = extra["callerEmployeeNumber"]?.toString() ?? ''; + final sender = Participants(employeeNumber: calleeEmployeeNumber); + final recipient = Participants(employeeNumber: callerEmployeeNumber); + if (!mounted) return; + final chatProvider = Provider.of(context, listen: false); + bool permissionGranted = isVideoCall ? await chatProvider.checkVideoPermissions() : await chatProvider.checkAudioPermissions(); + if (!permissionGranted) { + permissionGranted = isVideoCall ? await chatProvider.requestVideoPermissions() : await chatProvider.requestAudioPermissions(); + } + + if (!permissionGranted) { + try { + final calPro = Provider.of(context, listen: false); + final isConnected = await calPro.buildHubConnection(calleeEmployeeNumber); + if (isConnected) { + await calPro.declineCall(calleeEmployeeNumber, callerEmployeeNumber, moduleId: moduleId, reason: 'PERMISSION_DENIED'); + } + calPro.dispose(); + } catch (_) { + } + + if (mounted) { + context.showConfirmDialog( + isVideoCall ? "Camera & microphone permission is required to answer video calls." : "Microphone permission is required to answer calls.", + title: "Permission Required", + ); + } + return; + } + + if (!mounted) return; Navigator.of(context).push(MaterialPageRoute( - builder: (_) => AudioCallPage( - sender: Participants(employeeNumber: callKitParams.extra?["calleeEmployeeNumber"]), - recipient: Participants(employeeNumber: callKitParams.extra?["callerEmployeeNumber"]), - moduleId: int.parse(callKitParams.extra!["moduleId"]), - isAccept: true))); + builder: (_) => isVideoCall + ? VideoCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true) + : AudioCallPage(sender: sender, recipient: recipient, moduleId: moduleId, referenceID: referenceID, isAccept: true))); } }