Compare commits

..

3 Commits

Author SHA1 Message Date
WaseemAbbasi22 012314616a calling basic workflow complete 3 weeks ago
WaseemAbbasi22 d5a21a05ca working on background call handling 3 weeks ago
WaseemAbbasi22 b8595cf796 working on background call handling 3 weeks ago

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
@ -17,9 +18,10 @@
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- CallKit/ConnectionService permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
@ -141,5 +143,15 @@
</intent-filter>
</receiver>
<!-- Override flutter_callkit_incoming service to fix Android 14+ crash -->
<service
android:name="com.hiennv.flutter_callkit_incoming.CallkitNotificationService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="phoneCall|microphone|camera"
android:permission="${applicationId}.PERMISSION_CALL"
android:stopWithTask="false"
tools:replace="android:foregroundServiceType" />
</application>
</manifest>

@ -46,6 +46,11 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tel</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
@ -53,6 +58,9 @@
<string>Recognize Speech</string>
<key>NSMicrophoneUsageDescription</key>
<string>To Record Audio </string>
<key>NSCameraUsageDescription</key>
<string>Camera access is required for video calls.</string>
<key>NSCalendarsUsageDescription</key>
<string>This app requires access to your calendar to help you manage events and schedule appointments.</string>
<key>NSContactsUsageDescription</key>

@ -18,43 +18,123 @@ import 'package:test_sa/modules/tm_module/tasks/task_request_detail_view.dart';
import 'package:test_sa/modules/tm_module/gas_refill/gas_refill_details.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
import 'package:test_sa/modules/cx_module/chat/call/services/call_notification_service.dart';
import 'package:test_sa/modules/cx_module/chat/services/pending_call_storage.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
try {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Push notification received', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Message ID: ${message.messageId}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Data payload: ${message.data}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Notification title: ${message.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Notification body: ${message.notification?.body}', name: 'FirebaseNotificationManger');
final notificationType = message.data['notificationType'] as String? ??
message.data['type'] as String?; // Backend uses 'type'
message.data['type'] as String?;
final transactionType = message.data['transactionType'] as String?;
log('📱 [BACKGROUND] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
log('📱 [BACKGROUND] Transaction Type: $transactionType', name: 'FirebaseNotificationManger');
// Handle incoming call notifications
if (notificationType == 'incoming_call' || transactionType == 'call') {
log('📞 [BACKGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger');
log('📞 [BACKGROUND] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${message.data['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${message.data['callerId']}', name: 'FirebaseNotificationManger');
log(' - calleeEmployeeNumber: ${message.data['calleeEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${message.data['callerName']}', name: 'FirebaseNotificationManger');
log(' - callerUserName: ${message.data['callerUserName']}', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: ${message.data['callerEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - isVideoCall: ${message.data['isVideoCall']}', name: 'FirebaseNotificationManger');
log(' - callType: ${message.data['callType']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${message.data['applicationId']}', name: 'FirebaseNotificationManger');
await CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
// Extract call data
final callId = message.data['callId'] as String? ??
message.data['sessionId'] as String? ??
DateTime.now().millisecondsSinceEpoch.toString();
final callerId = message.data['callerId'] as String? ??
message.data['callerEmployeeNumber'] as String? ??
message.data['sourceUserId'] as String? ??
'unknown';
final callerName = message.data['callerName'] as String? ??
message.data['callerUserName'] as String? ??
message.data['userName'] as String? ??
'Unknown Caller';
final callerEmployeeNumber = message.data['callerEmployeeNumber'] as String? ?? callerId;
final isVideoCall = () {
final value = message.data['isVideoCall'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
if (message.data['callType'] == 'video') return true;
return false;
}();
final moduleId = message.data['moduleId'] as String? ??
message.data['applicationId']?.toString();
final referenceId = message.data['referenceId'] as String?;
final conversationId = message.data['conversationId'] as String?;
// CRITICAL FIX: DO NOT initialize any communication services in background isolate
// Only persist call data and show CallKit
///TODO need to find other solution to this after testing
await PendingCallStorage.savePendingCall(
callId: callId,
callerId: callerId,
callerName: callerName,
callerEmployeeNumber: callerEmployeeNumber,
isVideoCall: isVideoCall,
moduleId: moduleId,
referenceId: referenceId,
conversationId: conversationId,
);
// Show CallKit incoming call UI
log('📱 [BACKGROUND] Displaying CallKit UI...', name: 'FirebaseNotificationManger');
final callKitParams = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'ATOMS',
avatar: '',
handle: callerEmployeeNumber,
type: isVideoCall ? 1 : 0,
duration: 30000,
missedCallNotification: const NotificationParams(
showNotification: true,
isShowCallback: true,
subtitle: 'Missed call',
callbackText: 'Call back',
),
extra: {
'callId': callId,
'callerId': callerId,
'callerEmployeeNumber': callerEmployeeNumber,
'callerName': callerName,
'moduleId': moduleId ?? '',
'referenceId': referenceId ?? '',
'conversationId': conversationId ?? '',
'isVideoCall': isVideoCall,
'timestamp': DateTime.now().toIso8601String(),
},
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'default',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: false,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
log('✅ [BACKGROUND] CallKit UI displayed', name: 'FirebaseNotificationManger');
} else {
log('📱 [BACKGROUND] Non-call notification type', name: 'FirebaseNotificationManger');
}
@ -77,7 +157,7 @@ class FirebaseNotificationManger {
try {
if (!(await isGoogleServicesAvailable())) {
h_push.Push.enableLogger();
final result = await h_push.Push.setAutoInitEnabled(true);
await h_push.Push.setAutoInitEnabled(true);
h_push.Push.onMessageReceivedStream.listen(_onMessageReceived, onError: _onMessageReceiveError);
h_push.Push.getTokenStream.listen((hToken) {
@ -109,10 +189,6 @@ class FirebaseNotificationManger {
}
static void _onMessageReceived(h_push.RemoteMessage remoteMessage) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI] Message received', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI] Full message data: ${remoteMessage.toMap()}', name: 'FirebaseNotificationManger');
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
}
static void _onMessageReceiveError(Object error) {
@ -132,7 +208,7 @@ class FirebaseNotificationManger {
}
}
static void handleMessage(context, Map<String, dynamic> messageData) {
static Future<void> handleMessage(context, Map<String, dynamic> messageData) async {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Processing notification', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Full message data: $messageData', name: 'FirebaseNotificationManger');
@ -143,37 +219,106 @@ class FirebaseNotificationManger {
final transactionType = messageData['transactionType'] as String?;
log('📱 [HANDLE_MESSAGE] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Transaction Type: $transactionType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call' || transactionType == 'call') {
log('📞 [FOREGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger');
log('📞 [FOREGROUND] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${messageData['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${messageData['callerId']}', name: 'FirebaseNotificationManger');
log(' - calleeEmployeeNumber: ${messageData['calleeEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${messageData['callerName']}', name: 'FirebaseNotificationManger');
log(' - callerUserName: ${messageData['callerUserName']}', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: ${messageData['callerEmployeeNumber']}', name: 'FirebaseNotificationManger');
log(' - isVideoCall: ${messageData['isVideoCall']}', name: 'FirebaseNotificationManger');
log(' - callType: ${messageData['callType']}', name: 'FirebaseNotificationManger');
log(' - moduleId: ${messageData['moduleId']}', name: 'FirebaseNotificationManger');
log(' - referenceId: ${messageData['referenceId']}', name: 'FirebaseNotificationManger');
log(' - conversationId: ${messageData['conversationId']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${messageData['applicationId']}', name: 'FirebaseNotificationManger');
// Let CallNotificationService handle it
CallNotificationService().handleIncomingCallNotification(
notificationData: messageData,
source: 'push',
);
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
// CRITICAL FIX: Show CallKit UI in foreground just like background
final callId = messageData['callId'] as String? ??
messageData['sessionId'] as String? ??
DateTime.now().millisecondsSinceEpoch.toString();
final callerId = messageData['callerId'] as String? ??
messageData['callerEmployeeNumber'] as String? ??
messageData['sourceUserId'] as String? ??
'unknown';
final callerName = messageData['callerName'] as String? ??
messageData['callerUserName'] as String? ??
messageData['userName'] as String? ??
'Unknown Caller';
final callerEmployeeNumber = messageData['callerEmployeeNumber'] as String? ?? callerId;
final isVideoCall = () {
final value = messageData['isVideoCall'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
if (messageData['callType'] == 'video') return true;
return false;
}();
final moduleId = messageData['moduleId'] as String? ??
messageData['applicationId']?.toString();
final referenceId = messageData['referenceId'] as String?;
final conversationId = messageData['conversationId'] as String?;
log('📱 [FOREGROUND] Showing CallKit UI...', name: 'FirebaseNotificationManger');
// Show CallKit UI using CallKitService
try {
final callKitParams = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'ATOMS',
avatar: '',
handle: callerEmployeeNumber,
type: isVideoCall ? 1 : 0,
duration: 30000,
missedCallNotification: const NotificationParams(
showNotification: true,
isShowCallback: true,
subtitle: 'Missed call',
callbackText: 'Call back',
),
extra: {
'callId': callId,
'callerId': callerId,
'callerEmployeeNumber': callerEmployeeNumber,
'callerName': callerName,
'moduleId': moduleId ?? '',
'referenceId': referenceId ?? '',
'conversationId': conversationId ?? '',
'isVideoCall': isVideoCall,
'timestamp': DateTime.now().toIso8601String(),
},
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'default',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: false,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
log('✅ [FOREGROUND] CallKit UI displayed successfully', name: 'FirebaseNotificationManger');
} catch (e, stackTrace) {
log('❌ [FOREGROUND] Failed to show CallKit UI: $e\nStack: $stackTrace', name: 'FirebaseNotificationManger');
}
log('<EFBFBD><EFBFBD><EFBFBD>══════════════════════════════════════════', name: 'FirebaseNotificationManger');
return;
}
log('📱 [HANDLE_MESSAGE] Non-call notification - processing normally', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Request Type: ${messageData["requestType"]}', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Request Number: ${messageData["requestNumber"]}', name: 'FirebaseNotificationManger');
log('📱 [HANDLE_MESSAGE] Module ID: ${messageData["moduleId"]}', name: 'FirebaseNotificationManger');
if (messageData["requestType"] != null && messageData["requestNumber"] != null) {
Widget? serviceClass;
@ -287,32 +432,22 @@ class FirebaseNotificationManger {
}
static initialized(BuildContext context) async {
log('🔧 [INIT] Initializing FirebaseNotificationManger', name: 'FirebaseNotificationManger');
//TOD0 add platform check here also
if (!(await isGoogleServicesAvailable()) && Platform.isAndroid) {
log('📱 [INIT] Using Huawei Push Services', name: 'FirebaseNotificationManger');
// NEW: Handle Huawei initial notification
var initialNotification = await h_push.Push.getInitialNotification();
if (initialNotification != null) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_INITIAL] Initial notification found', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_INITIAL] Full data: $initialNotification', name: 'FirebaseNotificationManger');
Map<String, dynamic> remoteData = Map<String, dynamic>.from(initialNotification["extras"] as Map);
log('📱 [HUAWEI_INITIAL] Extras data: $remoteData', name: 'FirebaseNotificationManger');
// Check if it's a call notification
final notificationType = remoteData['notificationType'] as String?;
log('📱 [HUAWEI_INITIAL] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [HUAWEI_INITIAL] Processing call notification', name: 'FirebaseNotificationManger');
await CallNotificationService().handleIncomingCallNotification(
notificationData: remoteData,
source: 'push',
);
log('📞 [HUAWEI_INITIAL] Call notification - will be handled by SignalR', name: 'FirebaseNotificationManger');
// In foreground, SignalR will handle via OnReceiveCall event
} else {
log('📱 [HUAWEI_INITIAL] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, remoteData);
@ -324,11 +459,6 @@ class FirebaseNotificationManger {
h_push.Push.onNotificationOpenedApp.listen((message) {
try {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Notification tapped', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Message type: ${message.runtimeType}', name: 'FirebaseNotificationManger');
log('📱 [HUAWEI_TAP] Full message: $message', name: 'FirebaseNotificationManger');
if (message is Map<String, dynamic>) {
Map<String, dynamic> remoteData = message;
remoteData = remoteData["extras"];
@ -339,11 +469,8 @@ class FirebaseNotificationManger {
log('📱 [HUAWEI_TAP] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [HUAWEI_TAP] Processing call notification', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: remoteData,
source: 'push',
);
log('📞 [HUAWEI_TAP] Call notification - will be handled by SignalR', name: 'FirebaseNotificationManger');
// In foreground, SignalR will handle via OnReceiveCall event
} else {
log('📱 [HUAWEI_TAP] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, remoteData);
@ -351,7 +478,7 @@ class FirebaseNotificationManger {
}
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
} catch (ex) {
log('❌ [HUAWEI_TAP] Parsing error: $ex', name: 'FirebaseNotificationManger', error: ex);
log('❌ [HUAWEI_TAP] Parsing error: $ex', name: 'FirebaseNotificationManger');
}
}, onError: (e) => log('❌ [HUAWEI_TAP] Error: ${e.toString()}', name: 'FirebaseNotificationManger'));
return;
@ -373,7 +500,7 @@ class FirebaseNotificationManger {
);
log('✅ [INIT] Notification permissions granted: ${settings.authorizationStatus}', name: 'FirebaseNotificationManger');
} catch (error) {
log('❌ [INIT] Permission request failed: $error', name: 'FirebaseNotificationManger', error: error);
log('❌ [INIT] Permission request failed: $error', name: 'FirebaseNotificationManger');
return;
}
@ -387,23 +514,18 @@ class FirebaseNotificationManger {
FirebaseMessaging.instance.getInitialMessage().then((initialMessage) {
if (initialMessage != null) {
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Initial message found', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Message ID: ${initialMessage.messageId}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Data: ${initialMessage.data}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Title: ${initialMessage.notification?.title}', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Body: ${initialMessage.notification?.body}', name: 'FirebaseNotificationManger');
// NEW: Check if it's a call notification
final notificationType = initialMessage.data['notificationType'] as String?;
// Check if it's a call notification
final notificationType = initialMessage.data['notificationType'] as String? ??
initialMessage.data['type'] as String?;
final transactionType = initialMessage.data['transactionType'] as String?;
log('📱 [FCM_INITIAL] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
log('📱 [FCM_INITIAL] Transaction Type: $transactionType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [FCM_INITIAL] Processing call notification', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: initialMessage.data,
source: 'push',
);
if (notificationType == 'incoming_call' || transactionType == 'call') {
log('📞 [FCM_INITIAL] Call notification detected', name: 'FirebaseNotificationManger');
log(' Background handler already saved this call to SharedPreferences', name: 'FirebaseNotificationManger');
log(' AppLifecycleObserver will restore the call when app initializes', name: 'FirebaseNotificationManger');
log(' Skipping duplicate processing', name: 'FirebaseNotificationManger');
} else {
log('📱 [FCM_INITIAL] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, initialMessage.data);
@ -440,11 +562,101 @@ class FirebaseNotificationManger {
log(' - callType: ${message.data['callType']}', name: 'FirebaseNotificationManger');
log(' - applicationId: ${message.data['applicationId']}', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
);
log('═══════════════════════════════════════════', name: 'FirebaseNotificationManger');
// CRITICAL FIX: Show CallKit UI in foreground
final callId = message.data['callId'] as String? ??
message.data['sessionId'] as String? ??
DateTime.now().millisecondsSinceEpoch.toString();
final callerId = message.data['callerId'] as String? ??
message.data['callerEmployeeNumber'] as String? ??
message.data['sourceUserId'] as String? ??
'unknown';
final callerName = message.data['callerName'] as String? ??
message.data['callerUserName'] as String? ??
message.data['userName'] as String? ??
'Unknown Caller';
final callerEmployeeNumber = message.data['callerEmployeeNumber'] as String? ?? callerId;
final isVideoCall = () {
final value = message.data['isVideoCall'];
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
if (message.data['callType'] == 'video') return true;
return false;
}();
final moduleId = message.data['moduleId'] as String? ??
message.data['applicationId']?.toString();
final referenceId = message.data['referenceId'] as String?;
final conversationId = message.data['conversationId'] as String?;
log('📱 [FOREGROUND] Showing CallKit UI...', name: 'FirebaseNotificationManger');
try {
final callKitParams = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'ATOMS',
avatar: '',
handle: callerEmployeeNumber,
type: isVideoCall ? 1 : 0,
duration: 30000,
missedCallNotification: const NotificationParams(
showNotification: true,
isShowCallback: true,
subtitle: 'Missed call',
callbackText: 'Call back',
),
extra: {
'callId': callId,
'callerId': callerId,
'callerEmployeeNumber': callerEmployeeNumber,
'callerName': callerName,
'moduleId': moduleId ?? '',
'referenceId': referenceId ?? '',
'conversationId': conversationId ?? '',
'isVideoCall': isVideoCall,
'timestamp': DateTime.now().toIso8601String(),
},
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'default',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: false,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
FlutterCallkitIncoming.showCallkitIncoming(callKitParams);
log('✅ [FOREGROUND] CallKit UI displayed successfully', name: 'FirebaseNotificationManger');
log(' [FOREGROUND] SignalR OnIncomingCallAsync will handle call state in CallManager', name: 'FirebaseNotificationManger');
} catch (e, stackTrace) {
log('❌ [FOREGROUND] Failed to show CallKit UI: $e\nStack: $stackTrace', name: 'FirebaseNotificationManger');
}
log('══════════════════════════════════════════', name: 'FirebaseNotificationManger');
return;
}
@ -456,8 +668,8 @@ class FirebaseNotificationManger {
NotificationManger.showNotification(
title: message.notification?.title ?? "",
subtext: message.notification?.body ?? "",
hashcode: int.tryParse("1234" ?? "") ?? 1,
payload: json.encode(message.data),
hashcode: int.tryParse("1234") ?? 1,
payload: json.encode(message.data),
context: context);
}
}
@ -477,16 +689,8 @@ class FirebaseNotificationManger {
log('📱 [FCM_TAP] Notification Type: $notificationType', name: 'FirebaseNotificationManger');
if (notificationType == 'incoming_call') {
log('📞 [NOTIFICATION TAP] Incoming call notification tapped', name: 'FirebaseNotificationManger');
log('📞 [FCM_TAP] Call data:', name: 'FirebaseNotificationManger');
log(' - callId: ${message.data['callId']}', name: 'FirebaseNotificationManger');
log(' - callerId: ${message.data['callerId']}', name: 'FirebaseNotificationManger');
log(' - callerName: ${message.data['callerName']}', name: 'FirebaseNotificationManger');
CallNotificationService().handleIncomingCallNotification(
notificationData: message.data,
source: 'push',
);
log('📞 [NOTIFICATION TAP] Call notification tapped - will be handled by SignalR', name: 'FirebaseNotificationManger');
// In foreground, SignalR will handle via OnReceiveCall event
} else {
log('📱 [FCM_TAP] Processing regular notification', name: 'FirebaseNotificationManger');
handleMessage(context, message.data);

@ -1,28 +1,92 @@
import 'package:get_it/get_it.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/cx_module/chat/call/services/webrtc_service.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/call/services/call_notification_service.dart';
import 'dart:developer';
/// Global service locator instance
final getIt = GetIt.instance;
/// Setup dependency injection
/// This is called ONCE during app startup in the MAIN ISOLATE ONLY
/// NEVER call this from background isolate
Future<void> setupServiceLocator() async {
// SignalR Service - ONLY ONE instance for the entire app
getIt.registerLazySingleton<SignalRService>(
() => SignalRService(),
);
// WebRTC Service - ONLY ONE instance for the entire app
getIt.registerLazySingleton<WebRTCService>(
() => WebRTCService(),
);
print('✅ [DI] Service locator initialized');
print(' - SignalRService registered as singleton (hashCode: ${getIt<SignalRService>().hashCode})');
print(' - WebRTCService registered as singleton (hashCode: ${getIt<WebRTCService>().hashCode})');
await _registerServices();
}
/// Internal helper to register services with duplicate protection
Future<void> _registerServices() async {
// 1. SignalR Service
if (!getIt.isRegistered<SignalRService>()) {
getIt.registerLazySingleton<SignalRService>(() => SignalRService());
} else {
log(' ⚠️ SignalRService already registered, skipping', name: 'ServiceLocator');
}
// 2. WebRTC Service
if (!getIt.isRegistered<WebRTCService>()) {
getIt.registerLazySingleton<WebRTCService>(() => WebRTCService());
} else {
log(' ⚠️ WebRTCService already registered, skipping', name: 'ServiceLocator');
}
// 3. CallManager (depends on SignalRService, WebRTCService)
if (!getIt.isRegistered<CallManager>()) {
getIt.registerLazySingleton<CallManager>(() => CallManager());
} else {
log(' ⚠️ CallManager already registered, skipping', name: 'ServiceLocator');
}
// 4. CallNotificationService (depends on CallManager)
if (!getIt.isRegistered<CallNotificationService>()) {
getIt.registerLazySingleton<CallNotificationService>(() => CallNotificationService());
} else {
log(' ⚠️ CallNotificationService already registered, skipping', name: 'ServiceLocator');
}
// Verify all critical dependencies are available
_verifyDependencies();
}
/// Verify that all required dependencies are registered
void _verifyDependencies() {
final dependencies = <Type>[
SignalRService,
WebRTCService,
CallManager,
CallNotificationService,
];
bool allRegistered = true;
for (final type in dependencies) {
if (getIt.isRegistered(type: type)) {
log('$type is registered', name: 'ServiceLocator');
} else {
log('$type is NOT registered', name: 'ServiceLocator');
allRegistered = false;
}
}
if (allRegistered) {
} else {
log('❌ [DI] Some dependencies are missing!', name: 'ServiceLocator');
throw StateError('Required dependencies are not registered in GetIt');
}
}
/// Reset all communication services
/// Used for logout or testing
Future<void> resetServices() async {
await getIt<SignalRService>().reset();
print('✅ [DI] All services reset');
try {
if (getIt.isRegistered<CallManager>()) {
await getIt<CallManager>().reset();
}
if (getIt.isRegistered<SignalRService>()) {
await getIt<SignalRService>().reset();
}
} catch (e) {
log('❌ [DI] Error resetting services: $e', name: 'ServiceLocator');
}
}

@ -0,0 +1,207 @@
import 'dart:developer';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistent storage for authentication credentials
/// Used to restore SignalR connection in background/terminated app states
class AuthStorage {
// Storage keys
static const String _keyAccessToken = 'signalr_access_token';
static const String _keyUserId = 'signalr_user_id';
static const String _keyEmployeeNumber = 'signalr_employee_number';
/// Save SignalR authentication credentials
/// Called after successful login or token refresh
static Future<bool> saveCredentials({
required String accessToken,
required String userId,
String? employeeNumber,
}) async {
try {
log('═══════════════════════════════════════════', name: 'AuthStorage');
log('💾 [AUTH_STORAGE] Saving SignalR credentials...', name: 'AuthStorage');
log(' User ID: $userId', name: 'AuthStorage');
log(' Employee Number: ${employeeNumber ?? "none"}', name: 'AuthStorage');
log(' Token length: ${accessToken.length} chars', name: 'AuthStorage');
final prefs = await SharedPreferences.getInstance();
final results = await Future.wait([
prefs.setString(_keyAccessToken, accessToken),
prefs.setString(_keyUserId, userId),
if (employeeNumber != null)
prefs.setString(_keyEmployeeNumber, employeeNumber),
]);
final success = results.every((result) => result == true);
if (success) {
log('✅ [AUTH_STORAGE] Credentials saved successfully', name: 'AuthStorage');
} else {
log('❌ [AUTH_STORAGE] Failed to save some credentials', name: 'AuthStorage');
}
log('═══════════════════════════════════════════', name: 'AuthStorage');
return success;
} catch (e, stackTrace) {
log('❌ [AUTH_STORAGE] Error saving credentials: $e',
name: 'AuthStorage', error: e, stackTrace: stackTrace);
return false;
}
}
/// Get stored access token
static Future<String?> getAccessToken() async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_keyAccessToken);
if (token != null) {
log('✅ [AUTH_STORAGE] Access token retrieved (${token.length} chars)',
name: 'AuthStorage');
} else {
log('⚠️ [AUTH_STORAGE] No access token found', name: 'AuthStorage');
}
return token;
} catch (e) {
log('❌ [AUTH_STORAGE] Error reading access token: $e', name: 'AuthStorage');
return null;
}
}
/// Get stored user ID
static Future<String?> getUserId() async {
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString(_keyUserId);
if (userId != null) {
log('✅ [AUTH_STORAGE] User ID retrieved: $userId', name: 'AuthStorage');
} else {
log('⚠️ [AUTH_STORAGE] No user ID found', name: 'AuthStorage');
}
return userId;
} catch (e) {
log('❌ [AUTH_STORAGE] Error reading user ID: $e', name: 'AuthStorage');
return null;
}
}
/// Get stored employee number
static Future<String?> getEmployeeNumber() async {
try {
final prefs = await SharedPreferences.getInstance();
final employeeNumber = prefs.getString(_keyEmployeeNumber);
if (employeeNumber != null) {
log('✅ [AUTH_STORAGE] Employee number retrieved: $employeeNumber',
name: 'AuthStorage');
} else {
log('⚠️ [AUTH_STORAGE] No employee number found', name: 'AuthStorage');
}
return employeeNumber;
} catch (e) {
log('❌ [AUTH_STORAGE] Error reading employee number: $e', name: 'AuthStorage');
return null;
}
}
/// Get all stored credentials at once
/// Returns null if any required credential is missing
static Future<StoredCredentials?> getCredentials() async {
try {
log('═══════════════════════════════════════════', name: 'AuthStorage');
log('🔍 [AUTH_STORAGE] Reading stored credentials...', name: 'AuthStorage');
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_keyAccessToken);
final userId = prefs.getString(_keyUserId);
final employeeNumber = prefs.getString(_keyEmployeeNumber);
if (accessToken == null || userId == null) {
log('❌ [AUTH_STORAGE] Missing required credentials', name: 'AuthStorage');
log(' Access Token: ${accessToken != null ? "" : ""}', name: 'AuthStorage');
log(' User ID: ${userId != null ? "" : ""}', name: 'AuthStorage');
log('═══════════════════════════════════════════', name: 'AuthStorage');
return null;
}
log('✅ [AUTH_STORAGE] All credentials retrieved successfully', name: 'AuthStorage');
log(' User ID: $userId', name: 'AuthStorage');
log(' Employee Number: ${employeeNumber ?? "none"}', name: 'AuthStorage');
log(' Token length: ${accessToken.length} chars', name: 'AuthStorage');
log('═══════════════════════════════════════════', name: 'AuthStorage');
return StoredCredentials(
accessToken: accessToken,
userId: userId,
employeeNumber: employeeNumber,
);
} catch (e, stackTrace) {
log('❌ [AUTH_STORAGE] Error reading credentials: $e',
name: 'AuthStorage', error: e, stackTrace: stackTrace);
log('═══════════════════════════════════════════', name: 'AuthStorage');
return null;
}
}
/// Clear all stored authentication credentials
/// Called during logout
static Future<bool> clearCredentials() async {
try {
log('═══════════════════════════════════════════', name: 'AuthStorage');
log('🗑️ [AUTH_STORAGE] Clearing stored credentials...', name: 'AuthStorage');
final prefs = await SharedPreferences.getInstance();
await Future.wait([
prefs.remove(_keyAccessToken),
prefs.remove(_keyUserId),
prefs.remove(_keyEmployeeNumber),
]);
log('✅ [AUTH_STORAGE] Credentials cleared successfully', name: 'AuthStorage');
log('═══════════════════════════════════════════', name: 'AuthStorage');
return true;
} catch (e, stackTrace) {
log('❌ [AUTH_STORAGE] Error clearing credentials: $e',
name: 'AuthStorage', error: e, stackTrace: stackTrace);
return false;
}
}
/// Check if credentials are stored
static Future<bool> hasStoredCredentials() async {
try {
final prefs = await SharedPreferences.getInstance();
final hasToken = prefs.containsKey(_keyAccessToken);
final hasUserId = prefs.containsKey(_keyUserId);
return hasToken && hasUserId;
} catch (e) {
log('❌ [AUTH_STORAGE] Error checking credentials: $e', name: 'AuthStorage');
return false;
}
}
}
/// Data class for stored credentials
class StoredCredentials {
final String accessToken;
final String userId;
final String? employeeNumber;
StoredCredentials({
required this.accessToken,
required this.userId,
this.employeeNumber,
});
@override
String toString() {
return 'StoredCredentials(userId: $userId, employeeNumber: $employeeNumber, tokenLength: ${accessToken.length})';
}
}

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:localization/localization.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/controllers/providers/api/all_requests_provider.dart';
import 'package:test_sa/controllers/providers/api/comments_provider.dart';
@ -147,6 +148,7 @@ import 'providers/service_request_providers/reject_reason_provider.dart';
import 'modules/cleaner_module/pages/cleaner_search_form_view.dart';
import 'modules/cleaner_module/provider/cleaner_provider.dart';
import 'package:test_sa/core/di/service_locator.dart';
import 'package:test_sa/services/app_lifecycle_observer.dart';
// class MyHttpOverrides extends HttpOverrides {
// @override
@ -162,6 +164,10 @@ void main() async {
_configureLocalTimeZone();
NotificationManger.initialisation((notificationDetails) {}, (id, title, body, payload) async {});
await setupServiceLocator();
// Initialize app lifecycle observer to check for pending calls
AppLifecycleObserver().initialize();
if (Platform.isIOS) {
await Firebase.initializeApp(
options: const FirebaseOptions(
@ -183,7 +189,8 @@ void main() async {
/// only portrait mode
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
///todo remove this after testing call functionality
await requestCallPermissions();
runApp(ChangeNotifierProvider(create: (_) => SettingProvider(), child: const MyApp()));
}
@ -200,6 +207,13 @@ Future<void> _configureLocalTimeZone() async {
return;
}
}
///Temporary function to request camera and microphone permissions for call functionality testing
Future<void> requestCallPermissions() async {
await [
Permission.camera,
Permission.microphone,
].request();
}
final navigatorKey = GlobalKey<NavigatorState>();

@ -49,23 +49,6 @@ class _CMDetailPageState extends State<CMDetailPage> {
super.initState();
_requestProvider = Provider.of<CMDetailProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
// CRITICAL FIX: NEVER reset ChatProvider/SignalR connection
// CallManager already initialized it from LandPage and is using it for calls
// Resetting will disconnect SignalR and break incoming calls
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
final signalRService = getIt<SignalRService>();
log('📋 [CMDetailPage] Checking chat state...', name: 'CMDetailPage');
log(' SignalR connected: ${signalRService.isConnected}', name: 'CMDetailPage');
log(' Chat logged in: ${chatProvider.chatLoginResponse != null}', name: 'CMDetailPage');
log(' Current reference: ${chatProvider.referenceID}', name: 'CMDetailPage');
log(' New reference: ${widget.requestId}', name: 'CMDetailPage');
// DON'T RESET - just let ChatWidget handle its own initialization
// ChatWidget will check credentials and update conversation if needed
log('✅ [CMDetailPage] Skipping reset - ChatWidget will handle initialization', name: 'CMDetailPage');
getInitialData();
});
}

@ -1,16 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:test_sa/extensions/context_extension.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/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/call/utils/permission_helper.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/app_style/app_themes.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
class AudioCallPage extends StatefulWidget {
@ -29,6 +25,30 @@ class _AudioCallPageState extends State<AudioCallPage> {
_callManager = CallManager();
// Listen for call end and navigate back
_callManager.addListener(_checkCallStatus);
// CRITICAL: Check permissions after first frame
// Permissions must be requested in UI layer, not service layer
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissions();
});
}
/// Request audio call permissions using production-ready helper
Future<void> _checkPermissions() async {
if (!mounted) return;
// Request all audio call permissions (microphone)
final granted = await CallPermissionHelper.requestAudioCallPermissions(context);
if (!granted && mounted) {
// Permission denied - end the call gracefully
await CallPermissionHelper.handlePermissionDeniedDuringCall(
context,
'Microphone',
onEndCall: () => _callManager.declineCall('permission_denied'),
delaySeconds: 2,
);
}
}
void _checkCallStatus() {

@ -2,58 +2,70 @@ import 'dart:developer';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/core/di/service_locator.dart';
/// Service to handle incoming call notifications
/// IMPORTANT: This service should ONLY be used in the main isolate (foreground)
/// Background isolate should NEVER call this - it should only persist call data
class CallNotificationService {
static final CallNotificationService _instance = CallNotificationService._internal();
factory CallNotificationService() => _instance;
CallNotificationService._internal();
// Dependencies from DI - ONLY used in main isolate
CallManager get _callManager => getIt<CallManager>();
final Set<String> _processedCallIds = {};
Map<String, dynamic>? _pendingCallData;
/// Handle incoming call notification in the MAIN ISOLATE ONLY
/// This assumes SignalR and CallManager are already initialized
Future<void> handleIncomingCallNotification({
required Map<String, dynamic> notificationData,
required String source, // 'push' or 'signalr'
required String source, // 'signalr' only - FCM should be handled by background isolate
}) async {
try {
log('═══════════════════════════════════════════', name: 'CallNotificationService');
log('📞 [INCOMING CALL] Raw data: $notificationData', name: 'CallNotificationService');
log('📞 [MAIN ISOLATE] Incoming call notification', name: 'CallNotificationService');
log('📞 [MAIN ISOLATE] Source: $source', name: 'CallNotificationService');
log('📞 [MAIN ISOLATE] Data: $notificationData', name: 'CallNotificationService');
if (source != 'signalr') {
log('⚠️ [MAIN ISOLATE] Only SignalR calls should use this service', name: 'CallNotificationService');
log(' FCM calls are handled by background isolate + pending call restoration', name: 'CallNotificationService');
return;
}
final notificationType = notificationData['notificationType'] as String? ??
notificationData['type'] as String?; // Backend uses 'type'
notificationData['type'] as String?;
final transactionType = notificationData['transactionType'] as String?;
log('📞 [INCOMING CALL] Notification Type: $notificationType', name: 'CallNotificationService');
// Verify this is a call notification
if (notificationType != 'incoming_call' && transactionType != 'call') {
log(' Got: notificationType=$notificationType, transactionType=$transactionType', name: 'CallNotificationService');
log('⚠️ [MAIN ISOLATE] Not a call notification', name: 'CallNotificationService');
return;
}
// FIXED: Extract fields matching actual backend format
// Extract call data
final callId = notificationData['callId'] as String? ??
notificationData['sessionId'] as String? ??
DateTime.now().millisecondsSinceEpoch.toString(); // Generate if missing
DateTime.now().millisecondsSinceEpoch.toString();
// CRITICAL FIX: Use callerEmployeeNumber (the person calling), NOT calleeEmployeeNumber (the receiver)
final callerId = notificationData['callerId'] as String? ??
notificationData['callerEmployeeNumber'] as String? ?? // The actual caller
notificationData['callerEmployeeNumber'] as String? ??
notificationData['sourceUserId'] as String?;
final callerName = notificationData['callerName'] as String? ??
notificationData['callerUserName'] as String? ?? // Backend field
notificationData['callerUserName'] as String? ??
notificationData['userName'] as String? ??
'Unknown Caller';
final callerEmployeeNumber = notificationData['callerEmployeeNumber'] as String?;
// FIXED: Handle isVideoCall as String from backend
final isVideoCall = () {
final value = notificationData['isVideoCall'];
if (value is bool) {
return value;
} else if (value is String) {
return value.toLowerCase() == 'true';
} else if (notificationData['callType'] == 'video') {
return true;
}
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
if (notificationData['callType'] == 'video') return true;
return false;
}();
@ -61,15 +73,15 @@ class CallNotificationService {
notificationData['applicationId']?.toString();
final referenceId = notificationData['referenceId'] as String?;
final conversationId = notificationData['conversationId'] as String?;
log(' Caller Employee Number: $callerEmployeeNumber', name: 'CallNotificationService');
if (callerId == null) {
log('❌ [INCOMING CALL] Missing callerId/calleeEmployeeNumber', name: 'CallNotificationService');
log('❌ [MAIN ISOLATE] Missing callerId', name: 'CallNotificationService');
return;
}
// CRITICAL: Prevent duplicate processing
// Prevent duplicate processing
if (_processedCallIds.contains(callId)) {
log('⚠️ [INCOMING CALL] Already processed call $callId, ignoring duplicate', name: 'CallNotificationService');
log('⚠️ [MAIN ISOLATE] Already processed call $callId, ignoring duplicate', name: 'CallNotificationService');
return;
}
_processedCallIds.add(callId);
@ -80,10 +92,13 @@ class CallNotificationService {
_processedCallIds.removeAll(oldIds);
}
log('✅ [INCOMING CALL] Valid call notification', name: 'CallNotificationService');
log('✅ [MAIN ISOLATE] Valid call notification', name: 'CallNotificationService');
log(' Call ID: $callId', name: 'CallNotificationService');
log(' Caller: $callerName ($callerEmployeeNumber)', name: 'CallNotificationService');
log(' Is Video: $isVideoCall', name: 'CallNotificationService');
// Store pending call data
_pendingCallData = {
// Build extra data
final extraData = {
'callId': callId,
'callerId': callerId,
'callerName': callerName,
@ -95,33 +110,26 @@ class CallNotificationService {
'timestamp': DateTime.now().toIso8601String(),
};
// Delegate to CallManager - it will show CallKit UI once
log('📞 [INCOMING CALL] Delegating to CallManager...', name: 'CallNotificationService');
await CallManager().handleIncomingCallNotification(
// Delegate to CallManager
// CallManager will ensure SignalR is connected and handle the call
log('📞 [MAIN ISOLATE] Delegating to CallManager...', name: 'CallNotificationService');
await _callManager.handleIncomingCallNotification(
callId: callId,
callerId: callerId,
callerName: callerName,
isVideoCall: isVideoCall,
extraData: _pendingCallData,
extraData: extraData,
);
log('✅ [INCOMING CALL] Call handled successfully', name: 'CallNotificationService');
log('✅ [MAIN ISOLATE] Call handled successfully', name: 'CallNotificationService');
log('═══════════════════════════════════════════', name: 'CallNotificationService');
} catch (e, stackTrace) {
log('❌ [INCOMING CALL] Error handling notification: $e',
name: 'CallNotificationService', error: e, stackTrace: stackTrace);
log('❌ [MAIN ISOLATE] Error handling notification: $e\nStack: $stackTrace',
name: 'CallNotificationService');
}
}
/// Get pending call data (used when app wakes up)
Map<String, dynamic>? get pendingCallData => _pendingCallData;
/// Clear pending call data
void clearPendingCallData() {
_pendingCallData = null;
}
/// Mark a call as processed (for external use)
void markCallAsProcessed(String callId) {
_processedCallIds.add(callId);
@ -135,7 +143,6 @@ class CallNotificationService {
/// Reset the service (for testing or logout)
void reset() {
_processedCallIds.clear();
_pendingCallData = null;
log('🔄 [RESET] CallNotificationService reset', name: 'CallNotificationService');
}
}

@ -1,9 +1,8 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:uuid/uuid.dart';
import 'package:test_sa/modules/cx_module/chat/services/pending_call_storage.dart';
/// Service to handle CallKit (iOS) and ConnectionService (Android) integration
/// Provides native call UI experience on both platforms
@ -24,151 +23,56 @@ class CallKitService {
// Current active call UUID
String? _currentCallId;
// CRITICAL: Track initialization to prevent duplicate listeners
bool _isInitialized = false;
/// Initialize CallKit service and listen for events
Future<void> initialize() async {
if (kDebugMode) {
log('📞 [CallKit] Initializing CallKit service', name: 'CallKitService');
if (_isInitialized) {
return;
}
// Listen for CallKit events
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_handleCallKitEvent);
if (kDebugMode) {
log('✅ [CallKit] Service initialized', name: 'CallKitService');
}
_isInitialized = true;
}
/// Handle CallKit events from native side
void _handleCallKitEvent(CallEvent? event) {
if (event == null) return;
if (kDebugMode) {
log('📞 [CallKit] Event received: ${event.toString()}', name: 'CallKitService');
log('📞 [CallKit] Event type: ${event.eventName}', name: 'CallKitService');
}
// Handle different event types using pattern matching
switch (event) {
case CallEventActionCallAccept(:final callKitParams):
// User accepted the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('✅ [CallKit] Call ACCEPTED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallAccepted?.call(callId);
break;
case CallEventActionCallAccept(:final callKitParams):
final callId = callKitParams.id;
PendingCallStorage.markCallAcceptedFromBackground(callId);
onCallAccepted?.call(callId);
break;
case CallEventActionCallDecline(:final callKitParams):
// User declined the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('❌ [CallKit] Call DECLINED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallDeclined?.call(callId);
break;
case CallEventActionCallEnded(:final callKitParams):
// User ended the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('🔴 [CallKit] Call ENDED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallEnded?.call(callId);
break;
case CallEventActionCallTimeout(:final id):
// Call timed out (no answer)
if (kDebugMode) {
log('⏱️ [CallKit] Call TIMEOUT - calling callback', name: 'CallKitService');
log(' Call ID: $id', name: 'CallKitService');
}
onCallTimeout?.call(id);
break;
case CallEventActionCallToggleMute(:final id, :final isMuted):
// User toggled mute via CallKit UI
if (kDebugMode) {
log('🔇 [CallKit] Mute toggled: $isMuted for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleHold(:final id, :final isOnHold):
// User toggled hold via CallKit UI
if (kDebugMode) {
log('⏸️ [CallKit] Hold toggled: $isOnHold for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallIncoming(:final callKitParams):
// Incoming call notification shown
if (kDebugMode) {
log('📲 [CallKit] Incoming call notification for: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallStart(:final callKitParams):
// Call started
if (kDebugMode) {
log('📞 [CallKit] Call started: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallCallback(:final id):
// Callback event
if (kDebugMode) {
log('📞 [CallKit] Callback event for call: $id', name: 'CallKitService');
}
break;
case CallEventActionCallConnected(:final id):
// Call connected
if (kDebugMode) {
log('✅ [CallKit] Call connected: $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleMute():
case CallEventActionCallToggleHold():
case CallEventActionCallIncoming():
case CallEventActionCallStart():
case CallEventActionCallCallback():
case CallEventActionCallConnected():
case CallEventActionDidUpdateDevicePushTokenVoip():
// Push token updated
if (kDebugMode) {
log('🔔 [CallKit] Device push token updated', name: 'CallKitService');
}
break;
case CallEventActionCallToggleDmtf(:final id, :final digits, :final type):
// DTMF toggled
if (kDebugMode) {
log('🔢 [CallKit] DTMF toggled: $digits for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleGroup(:final id, :final callUUIDToGroupWith):
// Call group toggled
if (kDebugMode) {
log('👥 [CallKit] Group toggled for call $id with $callUUIDToGroupWith', name: 'CallKitService');
}
break;
case CallEventActionCallToggleAudioSession(:final isActive):
// Audio session toggled
if (kDebugMode) {
log('🔊 [CallKit] Audio session toggled: $isActive', name: 'CallKitService');
}
break;
case CallEventActionCallCustom(:final body):
// Custom event
if (kDebugMode) {
log('🔧 [CallKit] Custom event: $body', name: 'CallKitService');
}
case CallEventActionCallToggleDmtf():
case CallEventActionCallToggleGroup():
case CallEventActionCallToggleAudioSession():
case CallEventActionCallCustom():
break;
default:
if (kDebugMode) {
log(' [CallKit] Unhandled event type: ${event.eventName}', name: 'CallKitService');
}
}
}
@ -182,24 +86,16 @@ class CallKitService {
Map<String, dynamic>? extra,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Showing incoming call UI', name: 'CallKitService');
log(' Caller: $callerName ($callerNumber)', name: 'CallKitService');
log(' Video: $isVideo', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
_currentCallId = callId;
// Configure call parameters
final params = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'Atoms SA',
avatar: callerAvatar,
handle: callerNumber,
type: isVideo ? 1 : 0, // 0 = audio, 1 = video
duration: 30000, // 30 seconds timeout
type: isVideo ? 1 : 0,
duration: 30000,
extra: extra ?? {},
headers: <String, dynamic>{'platform': 'flutter'},
android: AndroidParams(
@ -231,12 +127,7 @@ class CallKitService {
),
);
// Show the incoming call UI
await FlutterCallkitIncoming.showCallkitIncoming(params);
if (kDebugMode) {
log('✅ [CallKit] Incoming call UI displayed', name: 'CallKitService');
}
} catch (e, stackTrace) {
log('❌ [CallKit] Error showing incoming call: $e',
name: 'CallKitService', error: e, stackTrace: stackTrace);
@ -253,10 +144,6 @@ class CallKitService {
required bool isVideo,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Starting outgoing call', name: 'CallKitService');
}
_currentCallId = callId;
final params = CallKitParams(
@ -276,10 +163,6 @@ class CallKitService {
);
await FlutterCallkitIncoming.startCall(params);
if (kDebugMode) {
log('✅ [CallKit] Outgoing call started', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error starting outgoing call: $e', name: 'CallKitService');
}
@ -288,16 +171,7 @@ class CallKitService {
/// Mark call as connected (when peer accepts)
Future<void> setCallConnected(String callId) async {
try {
if (kDebugMode) {
log('✅ [CallKit] Marking call as connected: $callId', name: 'CallKitService');
}
// Update call to connected state - this will update the native UI
await FlutterCallkitIncoming.setCallConnected(callId);
if (kDebugMode) {
log('✅ [CallKit] Call marked as connected', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error setting call connected: $e', name: 'CallKitService');
}
@ -306,19 +180,11 @@ class CallKitService {
/// End the current call
Future<void> endCall(String callId) async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending call: $callId', name: 'CallKitService');
}
await FlutterCallkitIncoming.endCall(callId);
if (_currentCallId == callId) {
_currentCallId = null;
}
if (kDebugMode) {
log('✅ [CallKit] Call ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending call: $e', name: 'CallKitService');
}
@ -327,16 +193,8 @@ class CallKitService {
/// End all active calls
Future<void> endAllCalls() async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending all calls', name: 'CallKitService');
}
await FlutterCallkitIncoming.endAllCalls();
_currentCallId = null;
if (kDebugMode) {
log('✅ [CallKit] All calls ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending all calls: $e', name: 'CallKitService');
}
@ -346,9 +204,6 @@ class CallKitService {
Future<List<dynamic>> getActiveCalls() async {
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (kDebugMode) {
log('📋 [CallKit] Active calls: ${calls.length}', name: 'CallKitService');
}
return calls;
} catch (e) {
log('❌ [CallKit] Error getting active calls: $e', name: 'CallKitService');
@ -359,26 +214,15 @@ class CallKitService {
/// Dispose and cleanup
Future<void> dispose() async {
try {
if (kDebugMode) {
log('🧹 [CallKit] Disposing service', name: 'CallKitService');
}
// Cancel event subscription
await _eventSubscription?.cancel();
_eventSubscription = null;
// End all active calls
await endAllCalls();
// Clear callbacks
onCallAccepted = null;
onCallDeclined = null;
onCallEnded = null;
onCallTimeout = null;
if (kDebugMode) {
log('✅ [CallKit] Service disposed', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error disposing service: $e', name: 'CallKitService');
}

@ -37,20 +37,31 @@ class WebRTCService {
// Track if we're using test mode (Google STUN only)
static bool _useTestMode = false;
/// Check if WebRTC is fully initialized
bool get isFullyInitialized => _isFullyInitialized;
/// Check if peer connection is ready
bool get isPeerConnectionInitialized => _peerConnection != null && _isFullyInitialized;
/// Wait for WebRTC initialization to complete
Future<void> waitForInitialization() async {
if (_isFullyInitialized) {
return;
}
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
}
}
/// Enable test mode (use only Google STUN servers for debugging)
static void enableTestMode() {
_useTestMode = true;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode enabled', name: 'WebRTCService');
}
}
/// Disable test mode (use backend TURN servers)
static void disableTestMode() {
_useTestMode = false;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode disabled', name: 'WebRTCService');
}
}
// Configuration
@ -127,33 +138,17 @@ class WebRTCService {
/// Initialize WebRTC for audio call
Future<void> initializeForAudioCall() async {
// CRITICAL FIX: Create completer and mark as not initialized
_initializationCompleter = Completer<void>();
_isFullyInitialized = false;
try {
log('🔧 [WebRTC] Initialization Started', name: 'WebRTCService');
_isVideoCall = false;
// Get local audio stream
try {
log('🎤 [WebRTC] Requesting microphone access...', name: 'WebRTCService');
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
// CRITICAL: Verify audio tracks were captured
final audioTracks = _localStream!.getAudioTracks();
log('✅ [WebRTC] Local audio stream captured', name: 'WebRTCService');
log(' Audio tracks count: ${audioTracks.length}', name: 'WebRTCService');
for (var i = 0; i < audioTracks.length; i++) {
final track = audioTracks[i];
log(' Track $i: ${track.kind} - ID: ${track.id}', name: 'WebRTCService');
log(' Track $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
// CRITICAL FIX: Ensure track is enabled
if (!track.enabled) {
log('⚠️ [WebRTC] Track $i was disabled, enabling it', name: 'WebRTCService');
track.enabled = true;
}
}
@ -163,211 +158,167 @@ class WebRTCService {
}
} catch (e) {
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e);
throw Exception('Microphone access denied or unavailable');
}
// Create peer connection
log('🔧 [WebRTC] Creating peer connection...', name: 'WebRTCService');
_peerConnection = await createPeerConnection(_configuration);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
log('✅ [WebRTC] Peer connection created', name: 'WebRTCService');
// Add local stream tracks to peer connection
log('📤 [WebRTC] Adding local tracks to peer connection...', name: 'WebRTCService');
final tracks = _localStream!.getTracks();
for (var i = 0; i < tracks.length; i++) {
final track = tracks[i];
log(' Adding track $i: ${track.kind} (ID: ${track.id})', name: 'WebRTCService');
await _peerConnection!.addTrack(track, _localStream!);
log(' ✅ Track $i added successfully', name: 'WebRTCService');
}
log('✅ [WebRTC] All local tracks added to peer connection', name: 'WebRTCService');
// Setup peer connection event handlers
_setupPeerConnectionListeners();
// CRITICAL FIX: Set speakerphone OFF by default (use earpiece)
log('🔇 [WebRTC] Setting speakerphone OFF by default (earpiece mode)...', name: 'WebRTCService');
try {
await Helper.setSpeakerphoneOn(false);
log('✅ [WebRTC] Speakerphone disabled (earpiece mode)', name: 'WebRTCService');
} catch (e) {
log('⚠️ [WebRTC] Failed to disable speakerphone: $e', name: 'WebRTCService');
// Ignore speakerphone errors
}
// CRITICAL FIX: Mark as fully initialized AFTER everything is ready
_isFullyInitialized = true;
if (!_initializationCompleter!.isCompleted) {
_initializationCompleter!.complete();
}
log('✅ [WebRTC] Initialization Completed - PeerConnection Ready', name: 'WebRTCService');
} catch (e, stackTrace) {
log('❌ [WebRTC] Initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
log('❌ [WebRTC] Initialization error: $e\nStack: $stackTrace', name: 'WebRTCService');
// Complete with error
if (_initializationCompleter != null && !_initializationCompleter!.isCompleted) {
_initializationCompleter!.completeError(e, stackTrace);
}
_isFullyInitialized = false;
// Clean up any partial initialization
await dispose();
rethrow;
}
}
/// Initialize WebRTC for video call
/// Initialize WebRTC for video call
Future<void> initializeForVideoCall() async {
try {
if (kDebugMode) {
log('🔧 [WebRTC] Initializing video call', name: 'WebRTCService');
}
_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 {
await localRenderer!.initialize();
await remoteRenderer!.initialize();
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e);
throw Exception('Failed to initialize video renderers');
log(
'❌ [WebRTC] Failed to get video stream: $e',
name: 'WebRTCService',
);
throw Exception('Camera or microphone access denied or unavailable');
}
// Create peer connection FIRST (before getting media)
if (_localStream == null) {
throw Exception('Local video stream is null');
}
// 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 peer connection listeners immediately
// Setup listeners
_setupPeerConnectionListeners();
// Get local audio + video stream
try {
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e);
throw Exception('Camera or microphone access denied or unavailable');
// Add local tracks
final tracks = _localStream!.getTracks();
if (tracks.isEmpty) {
throw Exception('No media tracks found');
}
// Set local stream to renderer
if (localRenderer != null) {
localRenderer!.srcObject = _localStream;
for (final track in tracks) {
await _peerConnection!.addTrack(
track,
_localStream!,
);
}
// Add local stream tracks to peer connection
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
_isFullyInitialized = true;
if (kDebugMode) {
log('✅ [WebRTC] Video call initialized', name: 'WebRTCService');
if (!_initializationCompleter!.isCompleted) {
_initializationCompleter!.complete();
}
log(
'✅ [WebRTC] Video initialization completed',
name: 'WebRTCService',
);
} catch (e, stackTrace) {
log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
// Clean up any partial initialization
log(
'❌ [WebRTC] Video initialization error: $e\nStack: $stackTrace',
name: 'WebRTCService',
error: e,
stackTrace: stackTrace,
);
if (_initializationCompleter != null &&
!_initializationCompleter!.isCompleted) {
_initializationCompleter!.completeError(
e,
stackTrace,
);
}
_isFullyInitialized = false;
await dispose();
rethrow;
}
}
/// Setup peer connection event listeners
void _setupPeerConnectionListeners() {
log('🔧 [WebRTC] Setting up peer connection listeners...', name: 'WebRTCService');
// Handle ICE candidates
_peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
log('🧊 [WebRTC] onIceCandidate triggered', name: 'WebRTCService');
if (onIceCandidate != null) {
onIceCandidate!(candidate);
} else {
log('⚠️ [WebRTC] onIceCandidate callback is NULL!', name: 'WebRTCService');
}
onIceCandidate?.call(candidate);
};
// Handle ICE gathering state changes
_peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {
log('📊 [WebRTC] ICE gathering state: ${state.toString()}', name: 'WebRTCService');
if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) {
log('✅ [WebRTC] ICE gathering completed', name: 'WebRTCService');
}
// Ice gathering state changed
};
// Handle ICE connection state changes
_peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
log('═══════════════════════════════════════════', name: 'WebRTCService');
log('🔗 [WebRTC] onIceConnectionState TRIGGERED', name: 'WebRTCService');
log(' State: ${state.toString()}', name: 'WebRTCService');
log(' Callback is null?: ${onIceConnectionStateChange == null}', name: 'WebRTCService');
log('═══════════════════════════════════════════', name: 'WebRTCService');
// Log critical failures
if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService');
}
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
log('✅ [WebRTC] ICE connection CONNECTED!', name: 'WebRTCService');
}
if (onIceConnectionStateChange != null) {
log('📤 [WebRTC] Forwarding ICE state to callback...', name: 'WebRTCService');
onIceConnectionStateChange!(state);
log('✅ [WebRTC] ICE state forwarded to callback', name: 'WebRTCService');
} else {
log('❌ [WebRTC] CRITICAL: onIceConnectionStateChange callback is NULL!', name: 'WebRTCService');
log(' This means CallManager did not set up callbacks properly!', name: 'WebRTCService');
}
onIceConnectionStateChange?.call(state);
};
// Handle remote stream
_peerConnection!.onTrack = (RTCTrackEvent event) {
log('═══════════════════════════════════════════', name: 'WebRTCService');
log('📡 [WebRTC] onTrack event received!', name: 'WebRTCService');
log(' Track kind: ${event.track.kind}', name: 'WebRTCService');
log(' Track ID: ${event.track.id}', name: 'WebRTCService');
log(' Track enabled: ${event.track.enabled}', name: 'WebRTCService');
log(' Track muted: ${event.track.muted}', name: 'WebRTCService');
// log(' Track readyState: ${event.track.readyState}', name: 'WebRTCService');
log(' Streams count: ${event.streams.length}', name: 'WebRTCService');
if (event.streams.isNotEmpty) {
final stream = event.streams[0];
log(' Stream ID: ${stream.id}', name: 'WebRTCService');
log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'WebRTCService');
log(' Video tracks: ${stream.getVideoTracks().length}', name: 'WebRTCService');
// Log all tracks in the stream
final allTracks = stream.getTracks();
for (var i = 0; i < allTracks.length; i++) {
final track = allTracks[i];
log(' Track $i: ${track.kind} - enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
}
// If this is the first remote stream or a different stream, update it
if (_remoteStream == null || _remoteStream!.id != stream.id) {
log('✅ [WebRTC] Setting new remote stream', name: 'WebRTCService');
_remoteStream = stream;
// For video calls, assign stream to renderer
if (remoteRenderer != null && _isVideoCall) {
try {
remoteRenderer!.srcObject = _remoteStream;
// Retry assignment after delays to ensure it sticks
Future.delayed(const Duration(milliseconds: 100), () {
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
remoteRenderer!.srcObject = _remoteStream;
@ -380,40 +331,27 @@ class WebRTCService {
}
});
} catch (e) {
log('⚠️ [WebRTC] Failed to assign remote stream: $e', name: 'WebRTCService');
// Ignore errors
}
}
// Notify callback
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
log('✅ [WebRTC] Remote stream set and callback notified', name: 'WebRTCService');
onRemoteStream?.call(_remoteStream!);
} else {
log(' [WebRTC] Additional track on existing stream', name: 'WebRTCService');
// Additional track on existing stream
if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) {
try {
remoteRenderer!.srcObject = _remoteStream;
} catch (e) {
log('⚠️ [WebRTC] Failed to assign stream (additional track): $e', name: 'WebRTCService');
// Ignore errors
}
}
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
onRemoteStream?.call(_remoteStream!);
}
} else {
log('⚠️ [WebRTC] No streams in onTrack event!', name: 'WebRTCService');
}
log('═══════════════════════════════════════════', name: 'WebRTCService');
};
// Handle connection state changes
_peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
if (kDebugMode || state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
log('🔌 [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService');
}
};
@ -426,22 +364,6 @@ class WebRTCService {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP offer...', name: 'WebRTCService');
// CRITICAL: Verify local tracks before creating offer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
@ -450,35 +372,9 @@ class WebRTCService {
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] SDP offer created', name: 'WebRTCService');
log(' Offer type: ${offer.type}', name: 'WebRTCService');
log(' Offer SDP length: ${offer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (offer.sdp != null) {
final sdpLines = offer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP offer!', name: 'WebRTCService');
}
}
}
return offer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating offer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
log('❌ [WebRTC] Error creating offer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
@ -490,55 +386,12 @@ class WebRTCService {
throw Exception('Peer connection not initialized');
}
log('🔧 [WebRTC] Creating SDP answer...', name: 'WebRTCService');
log(' Received offer SDP length: ${offerSdp.length}', name: 'WebRTCService');
// CRITICAL: Log received offer to verify it has audio
if (kDebugMode) {
final offerLines = offerSdp.split('\n');
final audioLines = offerLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] Received Offer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!offerSdp.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in received offer!', name: 'WebRTCService');
}
}
// CRITICAL: Verify local tracks before creating answer
final senders = await _peerConnection!.getSenders();
log('📊 [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService');
for (var i = 0; i < senders.length; i++) {
final sender = senders[i];
final track = sender.track;
if (track != null) {
log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService');
log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService');
} else {
log(' Sender $i: NO TRACK!', name: 'WebRTCService');
}
}
// Set remote description (offer from caller)
final offer = RTCSessionDescription(offerSdp, 'offer');
log('🔧 [WebRTC] Setting remote description (offer)...', name: 'WebRTCService');
await _peerConnection!.setRemoteDescription(offer);
_remoteDescriptionSet = true;
log('✅ [WebRTC] Remote description set', name: 'WebRTCService');
// Flush queued ICE candidates
await _flushIceCandidateQueue();
// Create answer
log('🔧 [WebRTC] Creating answer...', name: 'WebRTCService');
final answer = await _peerConnection!.createAnswer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
@ -546,35 +399,9 @@ class WebRTCService {
await _peerConnection!.setLocalDescription(answer);
if (kDebugMode) {
log('✅ [WebRTC] SDP answer created', name: 'WebRTCService');
log(' Answer type: ${answer.type}', name: 'WebRTCService');
log(' Answer SDP length: ${answer.sdp?.length ?? 0}', name: 'WebRTCService');
// CRITICAL: Log SDP to verify audio track is included
if (answer.sdp != null) {
final sdpLines = answer.sdp!.split('\n');
final audioLines = sdpLines.where((line) =>
line.contains('m=audio') ||
line.contains('a=sendrecv') ||
line.contains('a=sendonly') ||
line.contains('a=recvonly')
).toList();
log('📋 [WebRTC] SDP Answer Audio Configuration:', name: 'WebRTCService');
for (final line in audioLines) {
log(' $line', name: 'WebRTCService');
}
if (!answer.sdp!.contains('m=audio')) {
log('❌ [WebRTC] WARNING: No audio media line in SDP answer!', name: 'WebRTCService');
}
}
}
return answer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
log('❌ [WebRTC] Error creating answer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
@ -590,14 +417,10 @@ class WebRTCService {
await _peerConnection!.setRemoteDescription(answer);
_remoteDescriptionSet = true;
// Flush queued ICE candidates
await _flushIceCandidateQueue();
if (kDebugMode) {
log('✅ [WebRTC] Remote answer set', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Error setting remote answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
log('❌ [WebRTC] Error setting remote answer: $e\nStack: $stackTrace', name: 'WebRTCService');
rethrow;
}
}
@ -638,10 +461,6 @@ class WebRTCService {
return;
}
if (kDebugMode) {
log('🔄 [WebRTC] Flushing ${_iceCandidateQueue.length} ICE candidates', name: 'WebRTCService');
}
for (final candidate in _iceCandidateQueue) {
try {
await _peerConnection!.addCandidate(candidate);
@ -680,9 +499,6 @@ class WebRTCService {
if (videoTracks.isNotEmpty) {
try {
await Helper.switchCamera(videoTracks.first);
if (kDebugMode) {
log('✅ [WebRTC] Camera switched', name: 'WebRTCService');
}
} catch (e) {
log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService');
}
@ -716,9 +532,6 @@ class WebRTCService {
if (_remoteStream != null && remoteRenderer != null && _isVideoCall) {
if (remoteRenderer!.srcObject == null) {
remoteRenderer!.srcObject = _remoteStream;
if (kDebugMode) {
log('✅ [WebRTC] Remote renderer refreshed', name: 'WebRTCService');
}
}
}
}
@ -738,10 +551,6 @@ class WebRTCService {
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] ICE restart initiated', name: 'WebRTCService');
}
return offer;
} catch (e) {
log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService');
@ -750,18 +559,6 @@ class WebRTCService {
}
/// Get peer connection state
/// CRITICAL FIX: Check if WebRTC is FULLY initialized (ready to process signaling)
bool get isFullyInitialized => _isFullyInitialized;
/// CRITICAL FIX: Wait for WebRTC initialization to complete
Future<void> waitForInitialization() async {
if (_initializationCompleter != null && !_initializationCompleter!.isCompleted) {
log('⏳ [WebRTC] Waiting for initialization to complete...', name: 'WebRTCService');
await _initializationCompleter!.future;
log('✅ [WebRTC] Initialization wait complete', name: 'WebRTCService');
}
}
RTCPeerConnectionState? getPeerConnectionState() {
return _peerConnection?.connectionState;
}
@ -771,13 +568,9 @@ class WebRTCService {
return _peerConnection?.iceConnectionState;
}
/// Check if peer connection is initialized
bool get isPeerConnectionInitialized => _peerConnection != null;
/// Dispose and cleanup
Future<void> dispose() async {
try {
// Stop local stream tracks
if (_localStream != null) {
_localStream!.getTracks().forEach((track) {
track.stop();
@ -786,7 +579,6 @@ class WebRTCService {
_localStream = null;
}
// Stop remote stream tracks
if (_remoteStream != null) {
_remoteStream!.getTracks().forEach((track) {
track.stop();
@ -795,7 +587,6 @@ class WebRTCService {
_remoteStream = null;
}
// Dispose renderers
if (localRenderer != null) {
await localRenderer!.dispose();
localRenderer = null;
@ -806,20 +597,15 @@ class WebRTCService {
remoteRenderer = null;
}
// Close peer connection
if (_peerConnection != null) {
await _peerConnection!.close();
await _peerConnection!.dispose();
_peerConnection = null;
}
// Clear ICE candidate queue
_iceCandidateQueue.clear();
_remoteDescriptionSet = false;
if (kDebugMode) {
log('✅ [WebRTC] Service disposed', name: 'WebRTCService');
}
} catch (e) {
log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService');
}

@ -0,0 +1,264 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:test_sa/modules/cx_module/chat/call/call_error_handler.dart';
/// Production-ready permission helper for audio/video calls
/// Handles all permission requests with proper error handling and user feedback
class CallPermissionHelper {
CallPermissionHelper._();
/// Check and request microphone permission for audio calls
/// Returns true if permission is granted, false otherwise
static Future<bool> requestMicrophonePermission(BuildContext context) async {
try {
log('🎤 [PERMISSION] Checking microphone permission...', name: 'CallPermissionHelper');
// Check current status
final status = await Permission.microphone.status;
log(' Current status: ${status.name}', name: 'CallPermissionHelper');
if (status.isGranted) {
log('✅ [PERMISSION] Microphone already granted', name: 'CallPermissionHelper');
return true;
}
// If denied permanently, show settings dialog
if (status.isPermanentlyDenied) {
log('⚠️ [PERMISSION] Microphone permanently denied - showing settings dialog', name: 'CallPermissionHelper');
if (context.mounted) {
await _showPermissionDeniedDialog(
context,
'Microphone Permission Required',
'This app needs microphone access to make calls. Please enable it in settings.',
Permission.microphone,
);
}
return false;
}
// Request permission
log('📤 [PERMISSION] Requesting microphone permission...', name: 'CallPermissionHelper');
final result = await Permission.microphone.request();
log(' Result: ${result.name}', name: 'CallPermissionHelper');
if (result.isGranted) {
log('✅ [PERMISSION] Microphone permission granted', name: 'CallPermissionHelper');
return true;
}
if (result.isPermanentlyDenied) {
log('❌ [PERMISSION] Microphone permanently denied', name: 'CallPermissionHelper');
if (context.mounted) {
await _showPermissionDeniedDialog(
context,
'Microphone Permission Required',
'This app needs microphone access to make calls. Please enable it in settings.',
Permission.microphone,
);
}
return false;
}
// User denied
log('❌ [PERMISSION] Microphone permission denied by user', name: 'CallPermissionHelper');
if (context.mounted) {
CallErrorHandler.showMicrophonePermissionDenied(context);
}
return false;
} catch (e, stackTrace) {
log('❌ [PERMISSION] Error requesting microphone permission: $e\nStack: $stackTrace',
name: 'CallPermissionHelper');
if (context.mounted) {
CallErrorHandler.showGenericError(context, 'Failed to request microphone permission');
}
return false;
}
}
/// Check and request camera permission for video calls
/// Returns true if permission is granted, false otherwise
static Future<bool> requestCameraPermission(BuildContext context) async {
try {
log('📹 [PERMISSION] Checking camera permission...', name: 'CallPermissionHelper');
// Check current status
final status = await Permission.camera.status;
log(' Current status: ${status.name}', name: 'CallPermissionHelper');
if (status.isGranted) {
log('✅ [PERMISSION] Camera already granted', name: 'CallPermissionHelper');
return true;
}
// If denied permanently, show settings dialog
if (status.isPermanentlyDenied) {
log('⚠️ [PERMISSION] Camera permanently denied - showing settings dialog', name: 'CallPermissionHelper');
if (context.mounted) {
await _showPermissionDeniedDialog(
context,
'Camera Permission Required',
'This app needs camera access for video calls. Please enable it in settings.',
Permission.camera,
);
}
return false;
}
// Request permission
log('📤 [PERMISSION] Requesting camera permission...', name: 'CallPermissionHelper');
final result = await Permission.camera.request();
log(' Result: ${result.name}', name: 'CallPermissionHelper');
if (result.isGranted) {
log('✅ [PERMISSION] Camera permission granted', name: 'CallPermissionHelper');
return true;
}
if (result.isPermanentlyDenied) {
log('❌ [PERMISSION] Camera permanently denied', name: 'CallPermissionHelper');
if (context.mounted) {
await _showPermissionDeniedDialog(
context,
'Camera Permission Required',
'This app needs camera access for video calls. Please enable it in settings.',
Permission.camera,
);
}
return false;
}
// User denied
log('❌ [PERMISSION] Camera permission denied by user', name: 'CallPermissionHelper');
if (context.mounted) {
CallErrorHandler.showCameraPermissionDenied(context);
}
return false;
} catch (e, stackTrace) {
log('❌ [PERMISSION] Error requesting camera permission: $e\nStack: $stackTrace',
name: 'CallPermissionHelper');
if (context.mounted) {
CallErrorHandler.showGenericError(context, 'Failed to request camera permission');
}
return false;
}
}
/// Request all permissions required for audio calls (microphone only)
/// Returns true if all permissions are granted, false otherwise
static Future<bool> requestAudioCallPermissions(BuildContext context) async {
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
log('🎵 [PERMISSION] Requesting audio call permissions...', name: 'CallPermissionHelper');
final micGranted = await requestMicrophonePermission(context);
if (!micGranted) {
log('❌ [PERMISSION] Audio call permissions denied', name: 'CallPermissionHelper');
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
return false;
}
log('✅ [PERMISSION] All audio call permissions granted', name: 'CallPermissionHelper');
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
return true;
}
/// Request all permissions required for video calls (microphone + camera)
/// Returns true if all permissions are granted, false otherwise
static Future<bool> requestVideoCallPermissions(BuildContext context) async {
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
log('📹 [PERMISSION] Requesting video call permissions...', name: 'CallPermissionHelper');
// Request both permissions
final micGranted = await requestMicrophonePermission(context);
if (!micGranted) {
log('❌ [PERMISSION] Video call permissions denied (microphone)', name: 'CallPermissionHelper');
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
return false;
}
final cameraGranted = await requestCameraPermission(context);
if (!cameraGranted) {
log('❌ [PERMISSION] Video call permissions denied (camera)', name: 'CallPermissionHelper');
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
return false;
}
log('✅ [PERMISSION] All video call permissions granted', name: 'CallPermissionHelper');
log('═══════════════════════════════════════════', name: 'CallPermissionHelper');
return true;
}
/// Check if microphone permission is granted (without requesting)
static Future<bool> isMicrophoneGranted() async {
final status = await Permission.microphone.status;
return status.isGranted;
}
/// Check if camera permission is granted (without requesting)
static Future<bool> isCameraGranted() async {
final status = await Permission.camera.status;
return status.isGranted;
}
/// Show dialog when permission is permanently denied, with option to open settings
static Future<void> _showPermissionDeniedDialog(
BuildContext context,
String title,
String message,
Permission permission,
) async {
if (!context.mounted) return;
return showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await openAppSettings();
},
child: const Text('Open Settings'),
),
],
);
},
);
}
/// Handle permission denial during an active call
/// Shows error and optionally ends the call
static Future<void> handlePermissionDeniedDuringCall(
BuildContext context,
String permissionName, {
required Function() onEndCall,
int delaySeconds = 2,
}) async {
if (!context.mounted) return;
log('❌ [PERMISSION] $permissionName denied during active call', name: 'CallPermissionHelper');
log(' Showing error and ending call in ${delaySeconds}s...', name: 'CallPermissionHelper');
CallErrorHandler.showGenericError(
context,
'$permissionName permission is required for this call.',
);
// Give user time to see the error
await Future.delayed(Duration(seconds: delaySeconds));
if (context.mounted) {
onEndCall();
}
}
}

@ -3,13 +3,14 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:test_sa/core/di/service_locator.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/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/call/utils/permission_helper.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/app_style/app_themes.dart';
class VideoCallPage extends StatefulWidget {
const VideoCallPage({Key? key}) : super(key: key);
@ -26,13 +27,38 @@ class _VideoCallPageState extends State<VideoCallPage> {
@override
void initState() {
super.initState();
_callManager = CallManager();
_callManager = getIt<CallManager>();
// _callManager = CallManager();
// Listen for call end
_callManager.addListener(_checkCallStatus);
// Start periodic check to ensure remote stream gets assigned
_startStreamCheckTimer();
// CRITICAL: Check permissions after first frame
// Permissions must be requested in UI layer, not service layer
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissions();
});
}
/// Request video call permissions using production-ready helper
Future<void> _checkPermissions() async {
if (!mounted) return;
// Request all video call permissions (microphone + camera)
final granted = await CallPermissionHelper.requestVideoCallPermissions(context);
if (!granted && mounted) {
// Permission denied - end the call gracefully
await CallPermissionHelper.handlePermissionDeniedDuringCall(
context,
'Camera or Microphone',
onEndCall: () => _callManager.declineCall('permission_denied'),
delaySeconds: 2,
);
}
}
void _startStreamCheckTimer() {

@ -190,8 +190,8 @@ class _ChatPageState extends State<ChatPage> {
@override
void dispose() {
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
chatProvider.chatHubConnection?.stop();
// final chatProvider = Provider.of<ChatProvider>(context, listen: false);
// chatProvider.chatHubConnection?.stop();
playerController.dispose();
recorderController.dispose();
super.dispose();

@ -45,9 +45,14 @@ import 'call/call_error_handler.dart';
import 'call/services/call_notification_service.dart';
import 'services/call_manager.dart';
import 'services/signalr_service.dart';
import 'services/call_coordinator.dart';
import 'package:test_sa/core/di/service_locator.dart';
class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
// ==================== CHAT-ONLY STATE ====================
// ChatProvider now ONLY manages chat-related state
// All call state is managed by CallManager
bool isTyping = false;
bool chatLoginTokenLoading = false;
@ -86,23 +91,26 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
_chatHubConnection = connection;
}
// === CALL STATE - DELEGATED TO CallManager ===
// These getters delegate to CallManager for backwards compatibility
CallStatus get callStatus => CallManager().callStatus;
CallSession? get currentCall => CallManager().currentCall;
Duration get callDuration => CallManager().callDuration;
bool get isMuted => CallManager().isMuted;
bool get isSpeakerOn => CallManager().isSpeakerOn;
bool get isCameraOn => CallManager().isCameraOn;
bool get isPeerMuted => CallManager().isPeerMuted;
bool get isPeerCameraOn => CallManager().isPeerCameraOn;
bool get isCallInProgress => CallManager().isCallInProgress;
WebRTCService? get webrtcService => CallManager().webrtcService;
// === CALL STATE - DELEGATED TO CallManager (READ-ONLY) ===
// These getters provide read-only access to call state for UI
// All call operations should go through CallManager directly
CallManager get _callManager => getIt<CallManager>();
CallStatus get callStatus => _callManager.callStatus;
CallSession? get currentCall => _callManager.currentCall;
Duration get callDuration => _callManager.callDuration;
bool get isMuted => _callManager.isMuted;
bool get isSpeakerOn => _callManager.isSpeakerOn;
bool get isCameraOn => _callManager.isCameraOn;
bool get isPeerMuted => _callManager.isPeerMuted;
bool get isPeerCameraOn => _callManager.isPeerCameraOn;
bool get isCallInProgress => _callManager.isCallInProgress;
WebRTCService? get webrtcService => _callManager.webrtcService;
// For backwards compatibility with UI components
bool get areCallHandlersRegistered => true; // Always true since CallManager handles it
// Private state for legacy ringtone support
// Private state for legacy ringtone support (chat-related)
final JustAudio.AudioPlayer _ringingPlayer = JustAudio.AudioPlayer();
bool _isRingingPlaying = false;
@ -115,27 +123,27 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
/// OPTIMIZATION: Improved connection disposal to prevent memory leaks
/// This properly handles errors and ensures connection is always cleaned up
Future<void> _disposeConnection() async {
try {
if (chatHubConnection != null) {
await chatHubConnection!.stop();
if (kDebugMode) {
print('🔌 SignalR connection closed successfully');
}
}
} catch (e) {
if (kDebugMode) {
print('⚠️ Error closing SignalR connection: $e');
}
// Don't rethrow - we still want to clean up
} finally {
chatHubConnection = null;
// CRITICAL FIX: DO NOT close the SignalR connection here
// SignalR is now a SINGLETON managed by SignalRService
// It's shared between ChatProvider and CallManager
// Closing it here would break ongoing calls and call setup
// Just clear the local reference
_chatHubConnection = null;
if (kDebugMode) {
print('🔌 [ChatProvider] Cleared local SignalR reference (connection remains active)');
}
}
/// Reset provider state and properly dispose SignalR connection
Future<void> reset() async {
// OPTIMIZATION: Use async/await for proper cleanup
await _disposeConnection();
// CRITICAL FIX: DO NOT dispose the SignalR connection
// Just clear ChatProvider's local state
// The SignalRService singleton will manage the connection lifecycle
_chatHubConnection = null;
chatLoginTokenLoading = false;
chatParticipantLoading = false;
@ -146,21 +154,24 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
sender = null;
recipient = null;
ChatApiClient().chatLoginResponse = null;
log('✅ [ChatProvider] Chat state reset (SignalR connection preserved)', name: 'ChatProvider');
}
/// OPTIMIZATION: Override dispose to ensure SignalR connection cleanup
/// This prevents connection leaks when provider is removed from widget tree
@override
void dispose() {
_disposeConnection().then((_) {
if (kDebugMode) {
print('✅ ChatProvider disposed');
}
}).catchError((error) {
if (kDebugMode) {
print('⚠️ Error during ChatProvider disposal: $error');
}
});
// CRITICAL FIX: DO NOT close SignalR connection on dispose
// The connection is a singleton and may be used by other parts of the app
// Only clear local state
_chatHubConnection = null;
if (kDebugMode) {
print('✅ ChatProvider disposed (SignalR connection preserved)');
}
super.dispose();
}
@ -187,34 +198,39 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
notifyListeners();
}
try {
log('i am called ...');
chatLoginResponse = await ChatApiClient().getChatLoginToken(moduleId, requestId, title, myId, assigneeEmployeeNumber);
log('✅ Got chatLoginResponse');
chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber);
log('✅ Got chatParticipantModel: ${chatParticipantModel?.toJson()}');
// Log available participants for debugging
log('📋 Available participants: ${chatParticipantModel?.participants?.map((p) => '${p.employeeNumber} (${p.userName})').toList()}');
log('🔍 Looking for myId: $myId');
log('🔍 Looking for assigneeEmployeeNumber: $assigneeEmployeeNumber');
// Use case-insensitive matching and handle not found gracefully
try {
sender = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == myId.toLowerCase());
log('✅ sender i got is ${sender?.toJson()}');
sender = chatParticipantModel?.participants?.firstWhere(
(participant) => participant.userId?.toLowerCase() == myId.toLowerCase()
);
} catch (e) {
log('⚠️ Sender NOT FOUND for myId: $myId. Error: $e');
sender = null;
}
try {
recipient = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == assigneeEmployeeNumber.toLowerCase());
log('✅ recipient i got is ${recipient?.toJson()}');
recipient = chatParticipantModel?.participants?.firstWhere(
(participant) => participant.userId?.toLowerCase() == assigneeEmployeeNumber.toLowerCase()
);
log('✅ recipient found: userId=${recipient?.userId}, userName=${recipient?.userName}, employeeNumber=${recipient?.employeeNumber}');
} catch (e) {
log('⚠️ Recipient NOT FOUND for assigneeEmployeeNumber: $assigneeEmployeeNumber. Error: $e');
recipient = null;
}
/// CRITICAL:need to remove this after testing Cache credentials in CallCoordinator for background calls
if (chatLoginResponse != null && chatParticipantModel != null && sender != null) {
final coordinator = CallCoordinator();
coordinator.cacheCredentials(
loginResponse: chatLoginResponse!,
participants: chatParticipantModel!,
myEmployeeNumber: myId,
);
}
} catch (ex) {
if (kDebugMode) {
print('⚠️ Error in getUserAutoLoginTokenSilent: $ex');
@ -344,23 +360,9 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
/// Prevents connection leaks if initialization fails
Future<void> buildHubConnection(String conversationID) async {
try {
log('═══════════════════════════════════════════', name: 'ChatProvider');
log('🔌 [ChatProvider] buildHubConnection called', name: 'ChatProvider');
log('💬 Conversation ID: $conversationID', name: 'ChatProvider');
// CRITICAL FIX: Use the SINGLETON SignalR service from DI
// DO NOT create a new connection - reuse the existing one
final signalRService = _signalRService;
log('🔍 [ChatProvider] SignalR service instance: ${signalRService.hashCode}', name: 'ChatProvider');
log(' Is connected: ${signalRService.isConnected}', name: 'ChatProvider');
log(' Connection state: ${signalRService.connectionState}', name: 'ChatProvider');
// CRITICAL FIX: Only initialize if not connected at all
// If already connected (from CallManager), just join the new conversation
if (!signalRService.isConnected) {
log('🔌 [ChatProvider] SignalR not connected, initializing...', name: 'ChatProvider');
final connected = await signalRService.initialize(
userId: chatLoginResponse!.userId.toString(),
authToken: chatLoginResponse!.token ?? '',
@ -370,23 +372,11 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
if (!connected) {
throw Exception('Failed to initialize SignalR connection');
}
log('✅ [ChatProvider] SignalR initialized', name: 'ChatProvider');
log(' Connection ID: ${signalRService.connectionId}', name: 'ChatProvider');
} else {
log('✅ [ChatProvider] SignalR already connected - reusing existing connection', name: 'ChatProvider');
log(' Connection ID: ${signalRService.connectionId}', name: 'ChatProvider');
// CRITICAL FIX: Just update the conversation context
// Do NOT call initialize() again - it will dispose the connection!
// The SignalRService.initialize() method already handles conversation switching
if (conversationID.isNotEmpty) {
try {
await signalRService.invoke("JoinConversation", args: [conversationID]);
log('✅ [ChatProvider] Joined conversation: $conversationID', name: 'ChatProvider');
} catch (e) {
log('⚠️ [ChatProvider] Error joining conversation (will retry): $e', name: 'ChatProvider');
// If join fails, try to reconnect with the conversation
await signalRService.initialize(
userId: chatLoginResponse!.userId.toString(),
authToken: chatLoginResponse!.token ?? '',
@ -398,23 +388,12 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
// CRITICAL FIX: Reference the singleton connection, don't create a new one
chatHubConnection = signalRService.hubConnection;
// Log user details
log('👤 User ID: ${chatLoginResponse!.userId}', name: 'ChatProvider');
log('📞 My Employee Number: ${sender?.employeeNumber ?? "NOT SET"}', name: 'ChatProvider');
log('═══════════════════════════════════════════', name: 'ChatProvider');
// Register chat event handlers
_registerChatEventHandlers();
} catch (e, stackTrace) {
log('❌ [ChatProvider] Error building SignalR connection: $e',
name: 'ChatProvider', error: e, stackTrace: stackTrace);
if (kDebugMode) {
print('⚠️ Error building SignalR connection: $e');
}
// CRITICAL FIX: Don't dispose on error - just log and continue
// The connection might still be usable for calls
rethrow;
}
}
@ -425,10 +404,6 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
log('⚠️ Cannot register event handlers - connection is null', name: 'ChatProvider');
return;
}
log('🔧 [EVENT HANDLERS] Registering chat event handlers...', name: 'ChatProvider');
// Register chat event handlers via SignalRService
_signalRService.on("ReceiveMessage", onMsgReceived1);
_signalRService.on("OnMessageReceivedAsync", onMsgReceived);
_signalRService.on("OnSubmitChatAsync", onSubmitChatAsync);
@ -441,9 +416,6 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
log('✅ [EVENT HANDLERS] Chat event handlers registered successfully', name: 'ChatProvider');
}
// ==================== CALL INFRASTRUCTURE ====================
// All call operations now delegate to CallManager
/// Start a new audio or video call - delegates to CallManager
Future<void> startCall(Participants recipient, CallType callType) async {
log('📞 [ChatProvider] startCall() - delegating to CallManager', name: 'ChatProvider');
@ -457,12 +429,6 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
return;
}
// CRITICAL: Ensure CallManager is properly initialized before starting call
log('🔧 [ChatProvider] Initializing CallManager before starting call...', name: 'ChatProvider');
log(' User ID: ${chatLoginResponse?.userId}', name: 'ChatProvider');
log(' Employee Number: ${sender?.employeeNumber}', name: 'ChatProvider');
log(' Conversation ID: ${chatParticipantModel?.id}', name: 'ChatProvider');
try {
await CallManager().initialize(
userId: chatLoginResponse!.userId.toString(),
@ -490,11 +456,12 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
// Delegate to CallManager
log('📤 [ChatProvider] Delegating to CallManager.startCall()', name: 'ChatProvider');
log(' To: ${recipient.employeeNumber} (${recipient.userName})', name: 'ChatProvider');
log(' Type: ${callType.name}', name: 'ChatProvider');
// Use employeeNumber from ChatParticipantModel for the call
final targetEmployeeNumber = recipient.employeeNumber ?? '';
await CallManager().startCall(
peerId: recipient.employeeNumber ?? '',
peerId: targetEmployeeNumber,
peerName: recipient.userName ?? 'Unknown',
callType: callType,
peerAvatar: recipient.image,
@ -544,17 +511,9 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
notifyListeners();
}
/// Register call event handlers - kept for backwards compatibility
void _registerCallHandlers() {
// Call handlers are now managed by CallManager
// This method is kept for backwards compatibility but does nothing
log(' [ChatProvider] Call handlers are managed by CallManager', name: 'ChatProvider');
}
/// Handle call history update
void _onCallHistoryUpdated(List<Object?>? args) async {
log('📞 [CALL HISTORY] OnCallHistoryUpdated received', name: 'ChatProvider');
try {
if (sender != null && recipient != null) {
userChatHistory = await ChatApiClient().loadChatHistory(
@ -569,7 +528,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
notifyListeners();
}
} catch (e, stackTrace) {
log('❌ [CALL HISTORY] Error reloading chat history: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
log('❌ [CALL HISTORY] Error reloading chat history: $e\nStack: $stackTrace', name: 'ChatProvider');
}
}

@ -0,0 +1,152 @@
import 'dart:developer';
import 'package:test_sa/core/di/service_locator.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
import 'package:test_sa/modules/cx_module/chat/model/chat_login_response_model.dart';
import 'package:test_sa/modules/cx_module/chat/model/chat_participant_model.dart';
import 'package:test_sa/core/storage/auth_storage.dart';
class CallCoordinator {
static final CallCoordinator _instance = CallCoordinator._internal();
factory CallCoordinator() => _instance;
CallCoordinator._internal();
// Services from DI
CallManager get _callManager => getIt<CallManager>();
SignalRService get _signalRService => getIt<SignalRService>();
bool _isInitializing = false;
// Cache for silent login (used for incoming calls in background)
static ChatLoginResponse? _cachedLoginResponse;
static ChatParticipantModel? _cachedParticipants;
static String? _cachedMyEmployeeNumber;
Future<bool> initializeForOutgoingCall({
required ChatProvider chatProvider,
required String conversationId,
required String moduleId,
required String referenceId,
}) async {
try {
if (chatProvider.chatLoginResponse == null) {
return false;
}
if (chatProvider.sender == null || chatProvider.recipient == null) {
return false;
}
final userId = chatProvider.chatLoginResponse!.userId.toString();
final authToken = chatProvider.chatLoginResponse!.token ?? '';
final employeeNumber = chatProvider.sender?.employeeNumber;
await _callManager.initialize(
userId: userId,
authToken: authToken,
conversationId: conversationId,
moduleId: moduleId,
referenceId: referenceId,
employeeNumber: employeeNumber,
);
return true;
} catch (e, stackTrace) {
log('[CallCoordinator] ❌ initializeForOutgoingCall failed: $e',
error: e, stackTrace: stackTrace);
return false;
}
}
Future<bool> initializeForIncomingCall({
required String callerEmployeeNumber,
String? conversationId,
String? moduleId,
String? referenceId,
}) async {
if (_isInitializing) {
await Future.delayed(const Duration(milliseconds: 500));
return _signalRService.isConnected;
}
_isInitializing = true;
try {
String? userId;
String? authToken;
String? myEmployeeNumber;
final storedCredentials = await AuthStorage.getCredentials();
if (storedCredentials != null) {
userId = storedCredentials.userId;
authToken = storedCredentials.accessToken;
myEmployeeNumber = storedCredentials.employeeNumber;
} else if (_cachedLoginResponse != null && _cachedMyEmployeeNumber != null) {
userId = _cachedLoginResponse!.userId.toString();
authToken = _cachedLoginResponse!.token ?? '';
myEmployeeNumber = _cachedMyEmployeeNumber;
} else {
return false;
}
if (!_signalRService.isConnected) {
final signalRConnected = await _signalRService.initialize(
userId: userId!,
authToken: authToken!,
conversationId: conversationId,
);
if (!signalRConnected) {
return false;
}
}
await _callManager.initialize(
userId: userId!,
authToken: authToken!,
conversationId: conversationId,
moduleId: moduleId ?? '1002',
referenceId: referenceId ?? '0',
employeeNumber: myEmployeeNumber,
);
return true;
} catch (e, stackTrace) {
log('[CallCoordinator] ❌ initializeForIncomingCall failed: $e',
error: e, stackTrace: stackTrace);
return false;
} finally {
_isInitializing = false;
}
}
/// Cache chat credentials for future incoming calls
/// Should be called after successful chat login (from ChatProvider)
void cacheCredentials({
required ChatLoginResponse loginResponse,
required ChatParticipantModel participants,
required String myEmployeeNumber,
}) {
_cachedLoginResponse = loginResponse;
_cachedParticipants = participants;
_cachedMyEmployeeNumber = myEmployeeNumber;
}
/// Ensure communication is ready
/// Used before any call operation
bool isCommunicationReady() {
return _signalRService.isConnected;
}
/// Reset communication (for logout)
Future<void> reset() async {
_isInitializing = false;
_cachedLoginResponse = null;
_cachedParticipants = null;
_cachedMyEmployeeNumber = null;
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:developer';
import 'package:shared_preferences/shared_preferences.dart';
/// Storage service for pending call data
/// Used when app is in background/terminated and needs to restore call state
class PendingCallStorage {
static const String _pendingCallKey = 'pending_incoming_call';
static const String _callAcceptedKey = 'call_accepted_from_background';
/// Save pending incoming call data
static Future<void> savePendingCall({
required String callId,
required String callerId,
required String callerName,
required String callerEmployeeNumber,
required bool isVideoCall,
String? moduleId,
String? referenceId,
String? conversationId,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final callData = {
'callId': callId,
'callerId': callerId,
'callerName': callerName,
'callerEmployeeNumber': callerEmployeeNumber,
'isVideoCall': isVideoCall,
'moduleId': moduleId,
'referenceId': referenceId,
'conversationId': conversationId,
'timestamp': DateTime.now().toIso8601String(),
};
await prefs.setString(_pendingCallKey, jsonEncode(callData));
} catch (e, stackTrace) {
log('❌ [STORAGE] Failed to save pending call: $e',
name: 'PendingCallStorage', error: e, stackTrace: stackTrace);
}
}
/// Get pending incoming call data
static Future<Map<String, dynamic>?> getPendingCall() async {
try {
final prefs = await SharedPreferences.getInstance();
final dataString = prefs.getString(_pendingCallKey);
if (dataString == null) {
return null;
}
final callData = jsonDecode(dataString) as Map<String, dynamic>;
return callData;
} catch (e, stackTrace) {
log('❌ [STORAGE] Failed to get pending call: $e',
name: 'PendingCallStorage', error: e, stackTrace: stackTrace);
return null;
}
}
/// Clear pending call data
static Future<void> clearPendingCall() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingCallKey);
} catch (e, stackTrace) {
log('❌ [STORAGE] Failed to clear pending call: $e',
name: 'PendingCallStorage', error: e, stackTrace: stackTrace);
}
}
/// Mark that call was accepted from background (via CallKit)
static Future<void> markCallAcceptedFromBackground(String callId) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_callAcceptedKey, callId);
} catch (e) {
log('❌ [STORAGE] Failed to mark call as accepted: $e', name: 'PendingCallStorage');
}
}
/// Check if call was accepted from background
static Future<String?> getAcceptedCallId() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_callAcceptedKey);
} catch (e) {
log('❌ [STORAGE] Failed to get accepted call ID: $e', name: 'PendingCallStorage');
return null;
}
}
/// Clear accepted call marker
static Future<void> clearAcceptedCallMarker() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_callAcceptedKey);
} catch (e) {
log('❌ [STORAGE] Failed to clear accepted marker: $e', name: 'PendingCallStorage');
}
}
/// Check if pending call data exists
static Future<bool> hasPendingCall() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.containsKey(_pendingCallKey);
} catch (e) {
return false;
}
}
}

@ -4,14 +4,13 @@ import 'package:flutter/foundation.dart';
import 'package:signalr_netcore/hub_connection.dart';
import 'package:signalr_netcore/signalr_client.dart';
import 'package:test_sa/controllers/api_routes/urls.dart';
import 'package:test_sa/core/storage/auth_storage.dart';
class SignalRService {
// Singleton instance - DO NOT instantiate directly, use DI (Provider)
static final SignalRService _instance = SignalRService._internal();
factory SignalRService() => _instance;
SignalRService._internal() {
log('🏗️ [SIGNALR] SignalRService singleton created (hashCode: ${hashCode})', name: 'SignalRService');
}
SignalRService._internal();
// Single HubConnection instance - ONLY created and managed by this service
HubConnection? _hubConnection;
@ -47,6 +46,32 @@ class SignalRService {
/// Get connection ID (for debugging and verification)
String? get connectionId => _hubConnection?.connectionId;
/// Initialize SignalR connection from stored credentials
/// Used for background/terminated app states when credentials aren't in memory
/// Returns true if successfully connected, false otherwise
Future<bool> initializeFromStorage() async {
try {
final credentials = await AuthStorage.getCredentials();
if (credentials == null) {
return false;
}
final success = await initialize(
userId: credentials.userId,
authToken: credentials.accessToken,
conversationId: null,
);
return success;
} catch (e, stackTrace) {
log('❌ [SIGNALR] Error initializing from storage: $e',
name: 'SignalRService', error: e, stackTrace: stackTrace);
return false;
}
}
/// Initialize SignalR connection with authentication
/// Thread-safe: Multiple callers will wait for the same connection task
Future<bool> initialize({
@ -54,54 +79,33 @@ class SignalRService {
required String authToken,
String? conversationId,
}) async {
log('═══════════════════════════════════════════', name: 'SignalRService');
log('🔌 [SIGNALR] initialize() called', name: 'SignalRService');
log(' Instance hashCode: ${hashCode}', name: 'SignalRService');
log(' User ID: $userId', name: 'SignalRService');
log(' Conversation ID: ${conversationId ?? "none"}', name: 'SignalRService');
// THREAD SAFETY: If already initializing, wait for current initialization
if (_isInitializing && _initializationCompleter != null) {
log('⏳ [SIGNALR] Already initializing, waiting for completion...', name: 'SignalRService');
return await _initializationCompleter!.future;
}
// If already connected with same credentials, reuse connection
if (isConnected && _userId == userId && _authToken == authToken) {
log('✅ [SIGNALR] Already connected with same credentials', name: 'SignalRService');
log(' Connection ID: ${_hubConnection?.connectionId}', name: 'SignalRService');
// Join new conversation if provided and different
if (conversationId != null && conversationId != _currentConversationId) {
await _joinConversation(conversationId);
}
log('═══════════════════════════════════════════', name: 'SignalRService');
return true;
}
// Start new initialization
_isInitializing = true;
_initializationCompleter = Completer<bool>();
try {
// Store credentials
_userId = userId;
_authToken = authToken;
_currentConversationId = conversationId;
// Dispose existing connection if any
await _disposeConnection();
// Create new connection
log('🔧 [SIGNALR] Creating new HubConnection...', name: 'SignalRService');
log(' URL: ${URLs.chatHubUrlChat}', name: 'SignalRService');
final httpOp = HttpConnectionOptions(
skipNegotiation: false,
logMessageContent: kDebugMode,
transport: HttpTransportType.WebSockets, // Use WebSockets
requestTimeout: 30000, // 30 seconds timeout
transport: HttpTransportType.WebSockets,
requestTimeout: 30000,
);
_hubConnection = HubConnectionBuilder()
@ -112,14 +116,8 @@ class SignalRService {
.withAutomaticReconnect(retryDelays: <int>[2000, 5000, 10000, 20000])
.build();
log('✅ [SIGNALR] HubConnection created (hashCode: ${_hubConnection.hashCode})', name: 'SignalRService');
// Setup reconnection handlers BEFORE starting connection
_setupReconnectionHandlers();
// Start connection with timeout
log('🔌 [SIGNALR] Starting connection...', name: 'SignalRService');
try {
final startFuture = _hubConnection!.start();
if (startFuture != null) {
@ -129,49 +127,34 @@ class SignalRService {
throw TimeoutException('SignalR connection timeout after 30 seconds');
},
);
} else {
log('⚠️ [SIGNALR] start() returned null, checking state...', name: 'SignalRService');
}
} catch (e) {
log('❌ [SIGNALR] Connection start failed: $e', name: 'SignalRService');
rethrow;
}
// Verify connection state
if (_hubConnection!.state != HubConnectionState.Connected) {
throw Exception('SignalR failed to connect. State: ${_hubConnection!.state}');
}
log('✅ [SIGNALR] Connection established', name: 'SignalRService');
log(' Connection ID: ${_hubConnection!.connectionId}', name: 'SignalRService');
log(' Connection State: ${_hubConnection!.state}', name: 'SignalRService');
// Emit connected state
_connectionStateController.add(HubConnectionState.Connected);
// Join conversation if provided
if (conversationId != null) {
await _joinConversation(conversationId);
}
// Re-register all event handlers
_reregisterAllHandlers();
_isInitializing = false;
_initializationCompleter?.complete(true);
log('═══════════════════════════════════════════', name: 'SignalRService');
return true;
} catch (e, stackTrace) {
log('❌ [SIGNALR] Error initializing connection: $e',
name: 'SignalRService', error: e, stackTrace: stackTrace);
log(' User ID: $userId', name: 'SignalRService');
log(' URL: ${URLs.chatHubUrlChat}', name: 'SignalRService');
_isInitializing = false;
_initializationCompleter?.complete(false);
log('═══════════════════════════════════════════', name: 'SignalRService');
return false;
}
@ -182,28 +165,21 @@ class SignalRService {
if (_hubConnection == null) return;
_hubConnection!.onclose(({Exception? error}) {
log('🔴 [SIGNALR] Connection closed: $error', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Disconnected);
});
_hubConnection!.onreconnecting(({Exception? error}) {
log('🟡 [SIGNALR] Reconnecting: $error', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Reconnecting);
});
_hubConnection!.onreconnected(({String? connectionId}) async {
log('🟢 [SIGNALR] Reconnected: $connectionId', name: 'SignalRService');
_connectionStateController.add(HubConnectionState.Connected);
// Rejoin conversation if we had one
if (_currentConversationId != null) {
await _joinConversation(_currentConversationId!);
}
// Re-register all event handlers
_reregisterAllHandlers();
log('✅ [SIGNALR] Reconnection complete', name: 'SignalRService');
});
}
@ -211,13 +187,11 @@ class SignalRService {
Future<void> _joinConversation(String conversationId) async {
try {
if (_hubConnection?.state != HubConnectionState.Connected) {
log('⚠️ [SIGNALR] Cannot join conversation - not connected', name: 'SignalRService');
return;
}
await _hubConnection!.invoke("JoinConversation", args: [conversationId]);
_currentConversationId = conversationId;
log('✅ [SIGNALR] Joined conversation: $conversationId', name: 'SignalRService');
} catch (e) {
log('❌ [SIGNALR] Error joining conversation: $e', name: 'SignalRService');
}
@ -226,34 +200,23 @@ class SignalRService {
/// Register an event handler
/// Events are stored and automatically re-registered after reconnect
void on(String eventName, Function(List<Object?>?) handler) {
log('🔧 [SIGNALR] Registering handler for: $eventName', name: 'SignalRService');
// Store handler for re-registration after reconnect
if (!_eventHandlers.containsKey(eventName)) {
_eventHandlers[eventName] = [];
}
// Check for duplicate handler
if (_eventHandlers[eventName]!.contains(handler)) {
log('⚠️ [SIGNALR] Handler already registered for: $eventName', name: 'SignalRService');
return;
}
_eventHandlers[eventName]!.add(handler);
// Register with SignalR if connected
if (_hubConnection != null) {
_hubConnection!.on(eventName, handler);
log('✅ [SIGNALR] Handler registered for: $eventName', name: 'SignalRService');
} else {
log('⚠️ [SIGNALR] Handler stored but not registered (not connected): $eventName', name: 'SignalRService');
}
}
/// Unregister an event handler
void off(String eventName, [Function(List<Object?>?)? handler]) {
log('🔧 [SIGNALR] Unregistering handler for: $eventName', name: 'SignalRService');
if (handler != null) {
_eventHandlers[eventName]?.remove(handler);
if (_eventHandlers[eventName]?.isEmpty ?? false) {
@ -263,7 +226,6 @@ class SignalRService {
_eventHandlers.remove(eventName);
}
// Unregister from SignalR if connected
if (_hubConnection != null) {
_hubConnection!.off(eventName, method: handler);
}
@ -273,20 +235,14 @@ class SignalRService {
void _reregisterAllHandlers() {
if (_hubConnection == null) return;
log('🔄 [SIGNALR] Re-registering ${_eventHandlers.length} event types...', name: 'SignalRService');
int totalHandlers = 0;
for (final entry in _eventHandlers.entries) {
final eventName = entry.key;
final handlers = entry.value;
for (final handler in handlers) {
_hubConnection!.on(eventName, handler);
totalHandlers++;
}
}
log('✅ [SIGNALR] $totalHandlers event handlers re-registered', name: 'SignalRService');
}
/// Invoke a SignalR method
@ -295,49 +251,47 @@ class SignalRService {
throw Exception('SignalR not connected. Current state: ${_hubConnection?.state}');
}
log('📤 [SIGNALR] Invoking: $methodName', name: 'SignalRService');
if (kDebugMode && args != null && args.isNotEmpty) {
log(' Args: ${args.take(3)}${args.length > 3 ? "..." : ""}', name: 'SignalRService');
}
try {
final result = await _hubConnection!.invoke(methodName, args: args);
return result;
} catch (e, stackTrace) {
log('❌ [SIGNALR] Error invoking $methodName', name: 'SignalRService');
log(' Error type: ${e.runtimeType}', name: 'SignalRService');
log(' Error message: $e', name: 'SignalRService');
log(' Connection state: ${_hubConnection?.state}', name: 'SignalRService');
log(' Connection ID: ${_hubConnection?.connectionId}', name: 'SignalRService');
if (args != null && args.isNotEmpty) {
log(' Args that failed: $args', name: 'SignalRService');
}
log(' Stack trace: $stackTrace', name: 'SignalRService');
return await _hubConnection!.invoke(methodName, args: args);
throw Exception('SignalR invoke failed: $methodName - $e');
}
}
/// Ensure connection is ready (connect if needed)
/// Thread-safe: Multiple callers will wait for the same connection task
Future<bool> ensureConnected() async {
// If already connected, we're good
if (isConnected) {
return true;
}
// CRITICAL FIX: If currently reconnecting, wait for it to complete
// Don't dispose the connection while it's reconnecting!
if (_hubConnection?.state == HubConnectionState.Reconnecting) {
log('⏳ [SIGNALR] Already reconnecting, waiting for completion...', name: 'SignalRService');
// Wait up to 10 seconds for reconnection to complete
final startTime = DateTime.now();
while (_hubConnection?.state == HubConnectionState.Reconnecting) {
await Future.delayed(const Duration(milliseconds: 500));
// Timeout after 10 seconds
if (DateTime.now().difference(startTime).inSeconds > 10) {
log('⏱️ [SIGNALR] Reconnection timeout, forcing new connection', name: 'SignalRService');
break;
}
}
// Check if reconnection succeeded
if (isConnected) {
log('✅ [SIGNALR] Reconnection completed successfully', name: 'SignalRService');
return true;
}
}
// If we have credentials, try to reconnect
if (_userId != null && _authToken != null) {
log('🔄 [SIGNALR] Not connected, attempting to reconnect...', name: 'SignalRService');
return await initialize(
userId: _userId!,
authToken: _authToken!,
@ -345,7 +299,6 @@ class SignalRService {
);
}
log('❌ [SIGNALR] Cannot reconnect - no credentials stored', name: 'SignalRService');
return false;
}
@ -353,10 +306,8 @@ class SignalRService {
Future<void> _disposeConnection() async {
try {
if (_hubConnection != null) {
log('🔌 [SIGNALR] Disposing existing connection...', name: 'SignalRService');
await _hubConnection!.stop();
_hubConnection = null;
log('✅ [SIGNALR] Connection disposed', name: 'SignalRService');
}
} catch (e) {
log('⚠️ [SIGNALR] Error disposing connection: $e', name: 'SignalRService');
@ -365,9 +316,6 @@ class SignalRService {
/// Reset service (for logout)
Future<void> reset() async {
log('═══════════════════════════════════════════', name: 'SignalRService');
log('🔄 [SIGNALR] Resetting service...', name: 'SignalRService');
await _disposeConnection();
_eventHandlers.clear();
@ -376,9 +324,6 @@ class SignalRService {
_currentConversationId = null;
_isInitializing = false;
_initializationCompleter = null;
log('✅ [SIGNALR] Service reset complete', name: 'SignalRService');
log('═══════════════════════════════════════════', name: 'SignalRService');
}
/// Disconnect from SignalR (alias for reset)

@ -27,6 +27,7 @@ import 'package:test_sa/views/widgets/equipment/my_assets_page.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
import 'package:test_sa/modules/cx_module/chat/chat_api_client.dart';
import 'package:test_sa/core/storage/auth_storage.dart';
import '../../../controllers/providers/settings/setting_provider.dart';
import '../../../views/widgets/dialogs/dialog.dart';
@ -233,14 +234,7 @@ class _LandPageState extends State<LandPage> {
final myEmployeeNumber = user!.username!; // Use username as employee number
final userId = user.userID?.toString() ?? user.username!;
dev.log('═══════════════════════════════════════════', name: 'LandPage');
dev.log('🚀 [LAND PAGE] Initializing calling system...', name: 'LandPage');
dev.log(' User: ${user.username}', name: 'LandPage');
dev.log(' User ID: $userId', name: 'LandPage');
dev.log(' Employee Number: $myEmployeeNumber', name: 'LandPage');
// Step 1: Get chat credentials
dev.log('🔑 [LAND PAGE] Fetching chat credentials...', name: 'LandPage');
final chatLoginResponse = await ChatApiClient().getChatLoginToken(
1002, // moduleId - using default
0, // referenceId - using default for app-level init
@ -259,6 +253,20 @@ class _LandPageState extends State<LandPage> {
dev.log(' User ID: ${chatLoginResponse.userId}', name: 'LandPage');
dev.log(' Token: ${chatLoginResponse.token!.substring(0, 20)}...', name: 'LandPage');
// CRITICAL: Save credentials to SharedPreferences for background/terminated app states
dev.log('💾 [LAND PAGE] Saving credentials to SharedPreferences...', name: 'LandPage');
final savedCredentials = await AuthStorage.saveCredentials(
accessToken: chatLoginResponse.token!,
userId: chatLoginResponse.userId.toString(),
employeeNumber: myEmployeeNumber,
);
if (savedCredentials) {
dev.log(' Background calls will now work', name: 'LandPage');
} else {
dev.log('⚠️ [LAND PAGE] Failed to save credentials', name: 'LandPage');
}
// Step 2: Initialize SignalR Service (SINGLETON - only once)
dev.log('🔌 [LAND PAGE] Initializing SignalR Service...', name: 'LandPage');
final signalRService = SignalRService();
@ -275,11 +283,6 @@ class _LandPageState extends State<LandPage> {
}
dev.log('✅ [LAND PAGE] SignalR Service initialized', name: 'LandPage');
dev.log(' Connection State: ${signalRService.connectionState}', name: 'LandPage');
dev.log(' Connection ID: ${signalRService.connectionId ?? "NULL"}', name: 'LandPage');
// Step 3: Initialize CallManager (registers call event handlers)
dev.log('📞 [LAND PAGE] Initializing CallManager...', name: 'LandPage');
await CallManager().initialize(
userId: chatLoginResponse.userId.toString(),
authToken: chatLoginResponse.token!,
@ -290,13 +293,6 @@ class _LandPageState extends State<LandPage> {
);
dev.log('✅ [LAND PAGE] CallManager initialized', name: 'LandPage');
dev.log('═══════════════════════════════════════════', name: 'LandPage');
dev.log('✅ [LAND PAGE] Calling system ready!', name: 'LandPage');
dev.log(' ✅ SignalR: Connected', name: 'LandPage');
dev.log(' ✅ CallManager: Ready', name: 'LandPage');
dev.log(' ✅ WebRTC: Will initialize on first call', name: 'LandPage');
dev.log(' ✅ Calls work from ANY screen now', name: 'LandPage');
dev.log('═══════════════════════════════════════════', name: 'LandPage');
} catch (e, stackTrace) {
dev.log('❌ [LAND PAGE] Error initializing calling system: $e',
@ -317,9 +313,13 @@ class _LandPageState extends State<LandPage> {
// Disconnect SignalR
await SignalRService().disconnect();
// CRITICAL: Clear stored credentials from SharedPreferences
dev.log('🗑️ [LAND PAGE] Clearing stored credentials...', name: 'LandPage');
await AuthStorage.clearCredentials();
dev.log('✅ [LAND PAGE] Calling system cleaned up', name: 'LandPage');
} catch (e) {
dev.log('⚠️ [LAND PAGE] Error during cleanup: $e', name: 'LandPage');
}
}
}
}

@ -0,0 +1,122 @@
import 'dart:developer';
import 'package:flutter/widgets.dart';
import 'package:test_sa/core/di/service_locator.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
/// App lifecycle observer to handle pending calls when app resumes
class AppLifecycleObserver with WidgetsBindingObserver {
static final AppLifecycleObserver _instance = AppLifecycleObserver._internal();
factory AppLifecycleObserver() => _instance;
AppLifecycleObserver._internal();
bool _isInitialized = false;
bool _hasCheckedPendingCall = false;
/// Initialize the lifecycle observer
void initialize() {
if (_isInitialized) {
log('⚠️ [LIFECYCLE] Already initialized', name: 'AppLifecycleObserver');
return;
}
log('🔧 [LIFECYCLE] Initializing app lifecycle observer', name: 'AppLifecycleObserver');
WidgetsBinding.instance.addObserver(this);
_isInitialized = true;
log('✅ [LIFECYCLE] Observer initialized', name: 'AppLifecycleObserver');
// Check for pending calls immediately on app start
_checkPendingCallsOnStart();
}
/// Dispose the observer
void dispose() {
if (_isInitialized) {
log('🧹 [LIFECYCLE] Disposing lifecycle observer', name: 'AppLifecycleObserver');
WidgetsBinding.instance.removeObserver(this);
_isInitialized = false;
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
log('═══════════════════════════════════════════', name: 'AppLifecycleObserver');
log('🔄 [LIFECYCLE] App state changed: ${state.name}', name: 'AppLifecycleObserver');
switch (state) {
case AppLifecycleState.resumed:
log('✅ [LIFECYCLE] App resumed - checking for pending calls', name: 'AppLifecycleObserver');
_onAppResumed();
break;
case AppLifecycleState.inactive:
log('⏸️ [LIFECYCLE] App inactive', name: 'AppLifecycleObserver');
break;
case AppLifecycleState.paused:
log('⏸️ [LIFECYCLE] App paused', name: 'AppLifecycleObserver');
break;
case AppLifecycleState.detached:
log('🔴 [LIFECYCLE] App detached', name: 'AppLifecycleObserver');
break;
case AppLifecycleState.hidden:
log('🔴 [LIFECYCLE] App hidden', name: 'AppLifecycleObserver');
break;
}
log('═══════════════════════════════════════════', name: 'AppLifecycleObserver');
}
/// Check for pending calls when app starts
Future<void> _checkPendingCallsOnStart() async {
try {
// Wait a bit for app to fully initialize
await Future.delayed(const Duration(milliseconds: 500));
log('═══════════════════════════════════════════', name: 'AppLifecycleObserver');
log('🔍 [LIFECYCLE] Checking for pending calls on app start...', name: 'AppLifecycleObserver');
if (getIt.isRegistered<CallManager>()) {
final callManager = getIt<CallManager>();
await callManager.checkForPendingCalls();
_hasCheckedPendingCall = true;
log('✅ [LIFECYCLE] Pending call check complete', name: 'AppLifecycleObserver');
} else {
log('⚠️ [LIFECYCLE] CallManager not registered yet', name: 'AppLifecycleObserver');
}
log('═══════════════════════════════════════════', name: 'AppLifecycleObserver');
} catch (e, stackTrace) {
log('❌ [LIFECYCLE] Error checking pending calls on start: $e\nStack: $stackTrace',
name: 'AppLifecycleObserver');
}
}
/// Handle app resumed state
Future<void> _onAppResumed() async {
try {
// Check for pending calls if we haven't already
if (!_hasCheckedPendingCall) {
log('🔍 [LIFECYCLE] First resume - checking for pending calls', name: 'AppLifecycleObserver');
if (getIt.isRegistered<CallManager>()) {
final callManager = getIt<CallManager>();
await callManager.checkForPendingCalls();
_hasCheckedPendingCall = true;
}
}
// Handle pending navigation (if call was accepted while app was launching)
if (getIt.isRegistered<CallManager>()) {
final callManager = getIt<CallManager>();
await callManager.handlePendingNavigation();
}
} catch (e, stackTrace) {
log('❌ [LIFECYCLE] Error handling app resumed: $e\nStack: $stackTrace',
name: 'AppLifecycleObserver');
}
}
/// Reset state (for testing)
void reset() {
_hasCheckedPendingCall = false;
log('🔄 [LIFECYCLE] State reset', name: 'AppLifecycleObserver');
}
}

@ -1868,7 +1868,7 @@ packages:
source: hosted
version: "3.1.5"
uuid:
dependency: "direct main"
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"

Loading…
Cancel
Save