webrtc implemented

main_production_audio_video_call
WaseemAbbasi22 1 week ago
parent f9274e070e
commit 5670408c5e

@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>multimedia / 5 - multimedia, audio, music, off, sound, sound off, speaker, volume icon</title>
<g id="Free-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(-1043.000000, -304.000000)" id="Group" stroke="#000000" stroke-width="2">
<g transform="translate(1041.000000, 302.000000)" id="Shape">
<path d="M17,16.9590334 L17,20.9958147 L16,21 L10,15 L8.00491634,15 C6.90034684,15 6.00491634,14.1045695 6.00491634,13 L6.00491634,11 C6.00491634,9.8954305 6.90034684,9 8.00491634,9 L9.05605361,9 L9.05605361,9">
</path>
<polyline points="12.584864 6.78440227 16 3 17 3 17 11.0419196">
</polyline>
<line x1="3.07751425" y1="3.04297451" x2="21" y2="21">
</line>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -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<void> 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<void> handleDataMessage(Map<String, dynamic> 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<void> handleDataMessage(Map<String, dynamic> 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);
}
}
}

@ -60,7 +60,7 @@ class _AudioCallPageState extends State<AudioCallPage> {
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<AudioCallPage> {
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<CallProvider, String>(
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<CallProvider, bool>(
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<AudioCallPage> {
callPro.endCall(widget.sender.employeeNumber ?? "", widget.recipient.employeeNumber ?? "", moduleId: widget.moduleId);
Navigator.pop(context);
}, isEndCall: true, size: 68),
callButton("calling/speaker_enable", () {}),
Selector<CallProvider, bool>(
selector: (_, provider) => provider.isSpeakerOn,
builder: (_, isSpeakerOn, __) => callButton(
isSpeakerOn ? "calling/speaker_enable" : "calling/speaker_disable", //need to replace with speaker_disable icon
() {
callPro.toggleSpeaker();
},
),
),
],
)
],

@ -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<void> _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<void> 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<void> _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<bool> 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<RTCPeerConnection> _createPeerConnection() async {
Map<String, dynamic> 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<String, dynamic> 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<void> _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<void> 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<String, dynamic> message) {
switch (message["callStatus"]) {
case 'connected':
{}
break;
case 'offer':
{}
break;
case 'accept':
{}
break;
case 'candidate':
{}
break;
case 'bye':
{}
break;
case 'leave':
{}
break;
Future<void> 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<void> switchCamera() async {
await _webRTCService.switchCamera();
}
//// Listeners Methods ////
Future<void> onCallAcceptedAsync(List<Object?>? 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<Object?>? 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<Object?>? params) {
print("onCallAcceptedAsync: $params");
Future<void> toggleSpeaker() async {
final newSpeakerState = !isSpeakerOn;
await _webRTCService.setSpeakerphoneEnabled(newSpeakerState);
isSpeakerOn = newSpeakerState;
notifyListeners();
}
void onOfferAsync(List<Object?>? params) {
print("onCallAcceptedAsync: $params");
print("onCallAcceptedAsync: $params");
Future<void> onIceCandidateAsync(List<Object?>? params) async {
if (params == null || params.length < 2) return;
try {
final String? candidateJson = params[0]?.toString();
if (candidateJson == null || candidateJson.isEmpty) return;
final Map<String, dynamic> candidateData = jsonDecode(candidateJson) as Map<String, dynamic>;
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<void> onOfferAsync(List<Object?>? 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<Object?>? params) {
print("onAnswerOffer: $params");
Future<void> onAnswerOffer(List<Object?>? 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<Object?>? params) {
print("onHangUpAsync: $params");
handleResponse(params);
}
void onCallDeclinedAsync(List<Object?>? 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<Object?>? params) {
print("onAudioToggle: $params");
isPeerMuted = !isPeerMuted;
notifyListeners();
}
void onCameraToggle(List<Object?>? params) {
print("onCameraToggle: $params");
isPeerCameraOn = !isPeerCameraOn;
notifyListeners();
}
//// Invoke Methods
@ -498,26 +612,31 @@ class CallProvider with ChangeNotifier, DiagnosticableTreeMixin {
Future<void> invoke({required String invokeMethod, required String currentUserID, required String targetUserID, bool isVideoCall = false, var data}) async {
List<Object> 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<Object?>? 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') {

@ -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<VideoCallPage> createState() => _VideoCallPageState();
}
class _VideoCallPageState extends State<VideoCallPage> {
late CallProvider callPro;
bool isDialogOpen = false;
bool isLocalVideoFullscreen = false;
@override
void initState() {
super.initState();
callPro = Provider.of<CallProvider>(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<CallProvider, (bool, bool)>(
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<CallProvider, (bool, bool)>(
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<CallProvider, (bool, bool)>(
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<CallProvider, (bool, bool)>(
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<CallProvider, String>(
selector: (_, p) => p.currentStatus == "Connected" ? p.formattedCallDuration : p.currentStatus,
builder: (_, status, __) => InfoTextLabelWidget(label: status, textStyle: AppTextStyles.heading6, color: Colors.white),
),
4.height,
Selector<CallProvider, bool>(
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<CallProvider, bool>(
selector: (_, p) => p.isMuted,
builder: (_, isMuted, __) => callButton(isMuted ? "calling/mic_disable" : "calling/mic_enable", () => callPro.toggleMute()),
),
Selector<CallProvider, bool>(
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);
}
}

@ -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<RTCIceCandidate> _iceCandidateQueue = [];
bool _remoteDescriptionSet = false;
Completer<void>? _initializationCompleter;
bool _isFullyInitialized = false;
bool _isVideoCall = false;
static bool _useTestMode = false;
bool get isFullyInitialized => _isFullyInitialized;
bool get isPeerConnectionInitialized => _peerConnection != null && _isFullyInitialized;
Future<void> waitForInitialization() async {
if (_isFullyInitialized) {
return;
}
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
}
}
static void enableTestMode() {
_useTestMode = true;
}
static void disableTestMode() {
_useTestMode = false;
}
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': false,
};
static const Map<String, dynamic> _videoConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': {'ideal': 1280},
'height': {'ideal': 720},
},
};
static const Map<String, dynamic> _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<String, dynamic> _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<void> initializeForAudioCall() async {
if (_isFullyInitialized) {
return;
}
_initializationCompleter = Completer<void>();
_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<void> initializeForVideoCall() async {
if (_isFullyInitialized) {
return;
}
_initializationCompleter = Completer<void>();
_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<RTCSessionDescription> 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<RTCSessionDescription> 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<void> 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<RTCSessionDescription> sendSDPAnswer(String? offerSdp) async {
if (offerSdp == null) {
throw Exception('Offer SDP is null');
}
return await createAnswer(offerSdp);
}
/// Add remote ICE candidate
Future<void> 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<void> _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<void> 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<void> 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<RTCSessionDescription?> 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<void> 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',
);
}
}
}

@ -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<ChatPage> {
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<ChatProvider>(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<ChatPage> {
);
if (!openSetting) PermissionHandler.openAppSettings();
return;
} else {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => VideoCallPage(
sender: Provider.of<ChatProvider>(context, listen: false).sender!,
recipient: Provider.of<ChatProvider>(context, listen: false).recipient!,
moduleId: widget.moduleId,
referenceID: widget.requestId,
)));
}
});
} else {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => VideoCallPage(
sender: Provider.of<ChatProvider>(context, listen: false).sender!,
recipient: Provider.of<ChatProvider>(context, listen: false).recipient!,
moduleId: widget.moduleId,
referenceID: widget.requestId,
)));
}
Navigator.of(context).push(MaterialPageRoute(builder: (_) => CallPage(isAudio: false)));
},
),
],

@ -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');

@ -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<LandPage> {
}
Future<void> _handleCallDeclined(CallKitParams callKitParams) async {
FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?);
FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]);
var calPro = Provider.of<CallProvider>(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<void> _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<ChatProvider>(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<CallProvider>(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)));
}
}

Loading…
Cancel
Save