working on background call handling

ui_ux_rollout_merge_audio_video_call
WaseemAbbasi22 3 months ago
parent 84fe851ec8
commit b8595cf796

@ -18,12 +18,15 @@ 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 ISOLATE] Handler started', 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');
@ -31,7 +34,7 @@ Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
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');
@ -41,20 +44,121 @@ Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
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?;
log(' - callId: $callId', name: 'FirebaseNotificationManger');
log(' - callerId: $callerId', name: 'FirebaseNotificationManger');
log(' - callerName: $callerName', name: 'FirebaseNotificationManger');
log(' - callerEmployeeNumber: $callerEmployeeNumber', name: 'FirebaseNotificationManger');
log(' - isVideoCall: $isVideoCall', name: 'FirebaseNotificationManger');
log(' - moduleId: $moduleId', name: 'FirebaseNotificationManger');
log(' - referenceId: $referenceId', name: 'FirebaseNotificationManger');
log(' - conversationId: $conversationId', name: 'FirebaseNotificationManger');
// CRITICAL FIX: DO NOT initialize any communication services in background isolate
// Only persist call data and show CallKit
log('💾 [BACKGROUND] Saving call data to SharedPreferences...', name: 'FirebaseNotificationManger');
await PendingCallStorage.savePendingCall(
callId: callId,
callerId: callerId,
callerName: callerName,
callerEmployeeNumber: callerEmployeeNumber,
isVideoCall: isVideoCall,
moduleId: moduleId,
referenceId: referenceId,
conversationId: conversationId,
);
log('✅ [BACKGROUND] Call data saved successfully', name: 'FirebaseNotificationManger');
// 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');
log('ℹ️ [BACKGROUND] Main isolate will initialize communication services when app resumes', name: 'FirebaseNotificationManger');
} else {
log('📱 [BACKGROUND] Non-call notification type', name: 'FirebaseNotificationManger');
}
@ -77,7 +181,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) {
@ -132,7 +236,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');
@ -161,12 +265,103 @@ class 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
// Extract call data (same as background handler)
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');
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('<EFBFBD><EFBFBD><EFBFBD>══════════════════════════════════════════', name: 'FirebaseNotificationManger');
return;
}
@ -308,11 +503,8 @@ class FirebaseNotificationManger {
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);
@ -339,11 +531,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 +540,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 +562,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;
}
@ -394,16 +583,18 @@ class 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 +631,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 +737,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 +758,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,104 @@
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})');
log('🔧 [DI] Setting up service locator (main isolate)...', name: 'ServiceLocator');
// Register all services with duplicate checks
await _registerServices();
log('✅ [DI] Service locator initialized', name: 'ServiceLocator');
}
/// Internal helper to register services with duplicate protection
Future<void> _registerServices() async {
// 1. SignalR Service
if (!getIt.isRegistered<SignalRService>()) {
getIt.registerLazySingleton<SignalRService>(() => SignalRService());
log(' ✅ SignalRService registered as singleton', name: 'ServiceLocator');
} else {
log(' ⚠️ SignalRService already registered, skipping', name: 'ServiceLocator');
}
// 2. WebRTC Service
if (!getIt.isRegistered<WebRTCService>()) {
getIt.registerLazySingleton<WebRTCService>(() => WebRTCService());
log(' ✅ WebRTCService registered as singleton', name: 'ServiceLocator');
} else {
log(' ⚠️ WebRTCService already registered, skipping', name: 'ServiceLocator');
}
// 3. CallManager (depends on SignalRService, WebRTCService)
if (!getIt.isRegistered<CallManager>()) {
getIt.registerLazySingleton<CallManager>(() => CallManager());
log(' ✅ CallManager registered as singleton', name: 'ServiceLocator');
} else {
log(' ⚠️ CallManager already registered, skipping', name: 'ServiceLocator');
}
// 4. CallNotificationService (depends on CallManager)
if (!getIt.isRegistered<CallNotificationService>()) {
getIt.registerLazySingleton<CallNotificationService>(() => CallNotificationService());
log(' ✅ CallNotificationService registered as singleton', name: 'ServiceLocator');
} 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,
];
log('🔍 [DI] Verifying dependencies...', name: 'ServiceLocator');
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) {
log('✅ [DI] All dependencies verified successfully', name: 'ServiceLocator');
} 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();
}
log('✅ [DI] All services reset', name: 'ServiceLocator');
} 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})';
}
}

@ -147,6 +147,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 +163,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(

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

@ -4,6 +4,7 @@ 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,17 +25,29 @@ 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 {
// CRITICAL: Prevent duplicate initialization
if (_isInitialized) {
if (kDebugMode) {
log('⚠️ [CallKit] Already initialized, skipping duplicate initialization', name: 'CallKitService');
}
return;
}
if (kDebugMode) {
log('📞 [CallKit] Initializing CallKit service', name: 'CallKitService');
}
// Listen for CallKit events
// Listen for CallKit events - ONLY ONCE
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_handleCallKitEvent);
_isInitialized = true;
if (kDebugMode) {
log('✅ [CallKit] Service initialized', name: 'CallKitService');
log('✅ [CallKit] Service initialized (singleton event listener registered)', name: 'CallKitService');
}
}
@ -49,15 +62,22 @@ class 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):
// 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');
}
// CRITICAL: Mark call as accepted for background/terminated app restoration
PendingCallStorage.markCallAcceptedFromBackground(callId);
if (kDebugMode) {
log('✅ [CallKit] Marked call as accepted in storage for main isolate', name: 'CallKitService');
}
onCallAccepted?.call(callId);
break;
case CallEventActionCallDecline(:final callKitParams):
// User declined the call via CallKit UI

@ -37,6 +37,29 @@ 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
/// Returns immediately if already initialized
Future<void> waitForInitialization() async {
if (_isFullyInitialized) {
log('✅ [WebRTC] Already initialized', name: 'WebRTCService');
return;
}
if (_initializationCompleter != null) {
log('⏳ [WebRTC] Waiting for initialization to complete...', name: 'WebRTCService');
await _initializationCompleter!.future;
log('✅ [WebRTC] Initialization wait completed', name: 'WebRTCService');
} else {
log('⚠️ [WebRTC] No initialization in progress', name: 'WebRTCService');
}
}
/// Enable test mode (use only Google STUN servers for debugging)
static void enableTestMode() {
_useTestMode = true;
@ -163,7 +186,7 @@ class WebRTCService {
}
} catch (e) {
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e);
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService');
throw Exception('Microphone access denied or unavailable');
}
@ -209,7 +232,7 @@ class WebRTCService {
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) {
@ -240,7 +263,7 @@ class WebRTCService {
await localRenderer!.initialize();
await remoteRenderer!.initialize();
} catch (e) {
log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e);
log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService');
throw Exception('Failed to initialize video renderers');
}
@ -259,7 +282,7 @@ class WebRTCService {
try {
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e);
log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService');
throw Exception('Camera or microphone access denied or unavailable');
}
@ -277,7 +300,7 @@ class WebRTCService {
log('✅ [WebRTC] Video call initialized', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
log('❌ [WebRTC] Video initialization error: $e\nStack: $stackTrace', name: 'WebRTCService');
// Clean up any partial initialization
await dispose();
rethrow;
@ -287,7 +310,7 @@ class WebRTCService {
/// 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');
@ -318,7 +341,7 @@ class WebRTCService {
if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService');
}
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
log('✅ [WebRTC] ICE connection CONNECTED!', name: 'WebRTCService');
}
@ -478,7 +501,7 @@ class 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;
}
}
@ -574,7 +597,7 @@ class 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;
}
}
@ -597,7 +620,7 @@ class WebRTCService {
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;
}
}
@ -750,18 +773,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,9 +782,6 @@ class WebRTCService {
return _peerConnection?.iceConnectionState;
}
/// Check if peer connection is initialized
bool get isPeerConnectionInitialized => _peerConnection != null;
/// Dispose and cleanup
Future<void> dispose() async {
try {

@ -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,28 @@ 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
log('🔄 [ChatProvider] Resetting chat state (preserving SignalR connection)', name: 'ChatProvider');
_chatHubConnection = null;
chatLoginTokenLoading = false;
chatParticipantLoading = false;
@ -146,21 +155,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();
}
@ -195,26 +207,44 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
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');
// CRITICAL FIX: Match participants by userId, not employeeNumber
// The employeeNumber field contains userName, not actual employee number
// 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()
);
log('✅ sender found: userId=${sender?.userId}, userName=${sender?.userName}, employeeNumber=${sender?.employeeNumber}');
} 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: Cache credentials in CallCoordinator for background calls
if (chatLoginResponse != null && chatParticipantModel != null && sender != null) {
log('💾 [ChatProvider] Caching credentials for future incoming calls...', name: 'ChatProvider');
final coordinator = CallCoordinator();
coordinator.cacheCredentials(
loginResponse: chatLoginResponse!,
participants: chatParticipantModel!,
myEmployeeNumber: myId,
);
log('✅ [ChatProvider] Credentials cached successfully', name: 'ChatProvider');
}
} catch (ex) {
if (kDebugMode) {
print('⚠️ Error in getUserAutoLoginTokenSilent: $ex');
@ -490,11 +520,19 @@ 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(' Recipient data:', name: 'ChatProvider');
log(' - userId: ${recipient.userId}', name: 'ChatProvider');
log(' - userName: ${recipient.userName}', name: 'ChatProvider');
log(' - employeeNumber: ${recipient.employeeNumber}', name: 'ChatProvider');
log(' Type: ${callType.name}', name: 'ChatProvider');
// Use employeeNumber from ChatParticipantModel for the call
final targetEmployeeNumber = recipient.employeeNumber ?? '';
log(' ✅ Calling with employeeNumber: $targetEmployeeNumber', name: 'ChatProvider');
await CallManager().startCall(
peerId: recipient.employeeNumber ?? '',
peerId: targetEmployeeNumber,
peerName: recipient.userName ?? 'Unknown',
callType: callType,
peerAvatar: recipient.image,
@ -569,7 +607,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');
}
}

@ -16,6 +16,8 @@ import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:uuid/uuid.dart';
import 'package:test_sa/core/di/service_locator.dart';
import 'package:test_sa/modules/cx_module/chat/services/pending_call_storage.dart';
import 'package:test_sa/modules/cx_module/chat/services/call_coordinator.dart';
class CallManager extends ChangeNotifier {
static final CallManager _instance = CallManager._internal();
@ -65,8 +67,14 @@ class CallManager extends ChangeNotifier {
static String? _cachedEmployeeNumber;
// CRITICAL: Pending call data for background acceptance
// This preserves the COMPLETE notification payload
Map<String, dynamic>? _pendingCallData;
// CRITICAL FIX: Navigation pending flag for background calls
// When app is in background and call is accepted, we set this flag
// The main UI will check this flag when it resumes and navigate
bool _navigationPending = false;
// CRITICAL FIX: WebRTC initialization synchronization
Completer<void>? _webrtcInitCompleter;
String? _pendingOfferSdp; // Queue for offer that arrives during initialization
@ -82,6 +90,9 @@ class CallManager extends ChangeNotifier {
bool get isPeerCameraOn => _isPeerCameraOn;
bool get isCallInProgress => _callStatus != CallStatus.idle;
// CRITICAL: Public getter for navigation pending flag
bool get isNavigationPending => _navigationPending;
/// Initialize CallManager with user credentials
Future<void> initialize({
required String userId,
@ -185,28 +196,36 @@ class CallManager extends ChangeNotifier {
log('✅ [CallManager] Initialized successfully', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [CallManager] Initialization error: $e',
name: 'CallManager', error: e, stackTrace: stackTrace);
log('❌ [CallManager] Initialization error: $e\nStack: $stackTrace',
name: 'CallManager');
rethrow;
}
}
/// Initialize CallKit service
Future<void> _initializeCallKit() async {
// CRITICAL: Prevent duplicate initialization
if (_callKitInitialized) {
log('⚠️ [CallKit] Already initialized, skipping', name: 'CallManager');
return;
}
try {
log('🔧 [CallKit] Initializing CallKit service...', name: 'CallManager');
await _callKitService.initialize();
// Setup CallKit callbacks
// Setup CallKit callbacks - only ONCE
_callKitService.onCallAccepted = _handleCallKitAccepted;
_callKitService.onCallDeclined = _handleCallKitDeclined;
_callKitService.onCallEnded = _handleCallKitEnded;
_callKitService.onCallTimeout = _handleCallKitTimeout;
_callKitInitialized = true;
log('✅ [CallKit] Initialized successfully (singleton callbacks set)', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [CallKit] Initialization error: $e',
name: 'CallManager', error: e, stackTrace: stackTrace);
log('❌ [CallKit] Initialization error: $e\nStack: $stackTrace',
name: 'CallManager');
}
}
@ -573,7 +592,8 @@ class CallManager extends ChangeNotifier {
Future<void> acceptCall() async {
try {
log('═══════════════════════════════════════════', name: 'CallManager');
log('✅ [ACCEPT] Accepting call...', name: 'CallManager');
log('✅ [ACCEPT] ▶️ ACCEPT CALL STARTED', name: 'CallManager');
log('✅ [ACCEPT] Timestamp: ${DateTime.now().toIso8601String()}', name: 'CallManager');
// CRITICAL: Handle background acceptance scenario
if (_currentCall == null && _pendingCallData != null) {
@ -631,14 +651,36 @@ class CallManager extends ChangeNotifier {
log(' Call Type: ${_currentCall!.type.name}', name: 'CallManager');
log(' Direction: ${_currentCall!.direction.name}', name: 'CallManager');
// Check permissions
log('🔍 [ACCEPT] Checking permissions...', name: 'CallManager');
if (!await _checkPermissions(_currentCall!.type)) {
log('❌ [ACCEPT] Permissions denied', name: 'CallManager');
await declineCall('permission_denied');
return;
// CRITICAL FIX: Check context availability
final hasContext = navigatorKey.currentContext != null;
log('📱 [ACCEPT] 🔍 Context Check:', name: 'CallManager');
log(' navigatorKey.currentContext != null: $hasContext', name: 'CallManager');
log(' navigatorKey.currentState != null: ${navigatorKey.currentState != null}', name: 'CallManager');
log(' App lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name ?? "NULL"}', name: 'CallManager');
if (hasContext) {
log(' Context available: TRUE', name: 'CallManager');
log(' Context type: ${navigatorKey.currentContext.runtimeType}', name: 'CallManager');
log(' Context widget: ${navigatorKey.currentContext!.widget.runtimeType}', name: 'CallManager');
} else {
log(' Context available: FALSE (background acceptance)', name: 'CallManager');
}
if (hasContext) {
// Foreground: Check permissions now
log('🔍 [ACCEPT] Checking permissions (foreground)...', name: 'CallManager');
if (!await _checkPermissions(_currentCall!.type)) {
log('❌ [ACCEPT] Permissions denied', name: 'CallManager');
await declineCall('permission_denied');
return;
}
log('✅ [ACCEPT] Permissions granted', name: 'CallManager');
} else {
// Background: Defer permission check until call screen is shown
log('⏭️ [ACCEPT] Skipping permission check (background call)', name: 'CallManager');
log(' CallKit will bring app to foreground', name: 'CallManager');
log(' Permissions will be requested when call screen is displayed', name: 'CallManager');
}
log('✅ [ACCEPT] Permissions granted', name: 'CallManager');
// CRITICAL: Ensure SignalR connected before accepting
log('🔌 [ACCEPT] Ensuring SignalR connection...', name: 'CallManager');
@ -695,19 +737,49 @@ class CallManager extends ChangeNotifier {
log(' Call type: ${_currentCall!.type.name}', name: 'CallManager');
await _initializeWebRTC();
log('✅ [ACCEPT] WebRTC initialized', name: 'CallManager');
log(' WebRTC negotiation can proceed in background', name: 'CallManager');
// Navigate to call screen
log('🧭 [ACCEPT] Navigating to call screen...', name: 'CallManager');
_navigateToCallScreen();
log('✅ [ACCEPT] Navigated to call screen', name: 'CallManager');
// CRITICAL FIX: Navigation logic with comprehensive logging
log('═══════════════════════════════════════════', name: 'CallManager');
log('🧭 [ACCEPT] ▶️ NAVIGATION PHASE STARTED', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
if (!hasContext) {
log('🔄 [NAV] App was in BACKGROUND - setting navigation pending flag', name: 'CallManager');
log(' Current time: ${DateTime.now().toIso8601String()}', name: 'CallManager');
log(' Lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name ?? "NULL"}', name: 'CallManager');
// CRITICAL FIX: Set navigation pending flag instead of trying to navigate from background isolate
_navigationPending = true;
log('✅ [NAV] Navigation pending flag SET', name: 'CallManager');
log(' Main UI will check this flag when app resumes', name: 'CallManager');
log(' Call handlePendingNavigation() from main UI lifecycle observer', name: 'CallManager');
} else {
// Foreground: Navigate immediately
log('🎯 [NAV] App IS in FOREGROUND - navigating immediately', name: 'CallManager');
log(' Current time: ${DateTime.now().toIso8601String()}', name: 'CallManager');
log(' Context available: YES', name: 'CallManager');
log(' Context type: ${navigatorKey.currentContext.runtimeType}', name: 'CallManager');
log('🚀 [NAV] Calling _navigateToCallScreen() [IMMEDIATE]...', name: 'CallManager');
log('✅ [ACCEPT] Call accepted successfully', name: 'CallManager');
log('ℹ️ [ACCEPT] Now waiting for caller to send offer via OnOfferAsync...', name: 'CallManager');
try {
_navigateToCallScreen();
log('✅ [NAV] _navigateToCallScreen() [IMMEDIATE] returned successfully', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [NAV] Navigation error (foreground): $e\nStack: $stackTrace', name: 'CallManager');
}
}
log('═══════════════════════════════════════════', name: 'CallManager');
log('✅ [ACCEPT] ▶️ ACCEPT CALL COMPLETED', name: 'CallManager');
log(' Call accepted successfully', name: 'CallManager');
log(' WebRTC initialized and ready', name: 'CallManager');
log(' Navigation scheduled/completed', name: 'CallManager');
log(' Now waiting for caller to send offer via OnOfferAsync...', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [ACCEPT] Error accepting call: $e',
name: 'CallManager', error: e, stackTrace: stackTrace);
log('❌ [ACCEPT] ❌ ERROR in acceptCall(): $e\nStack: $stackTrace', name: 'CallManager');
_cleanup();
final context = navigatorKey.currentContext;
@ -1006,7 +1078,7 @@ class CallManager extends ChangeNotifier {
log('📥 [WEBRTC] Processing pending offer that arrived during initialization...', name: 'CallManager');
final offerSdp = _pendingOfferSdp!;
_pendingOfferSdp = null;
// Process the offer now that WebRTC is ready
try {
log('🔧 [WEBRTC] Creating answer for pending offer...', name: 'CallManager');
@ -1021,18 +1093,18 @@ class CallManager extends ChangeNotifier {
]);
log('✅ [WEBRTC] Pending answer sent successfully', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [WEBRTC] Error processing pending offer: $e',
name: 'CallManager', error: e, stackTrace: stackTrace);
log('❌ [WEBRTC] Error processing pending offer: $e\nStack: $stackTrace',
name: 'CallManager');
}
}
} catch (e, stackTrace) {
log('❌ [WEBRTC] Initialization error: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
log('❌ [WEBRTC] Initialization error: $e\nStack: $stackTrace', name: 'CallManager');
// Complete the completer with error
if (!_webrtcInitCompleter!.isCompleted) {
_webrtcInitCompleter!.completeError(e, stackTrace);
}
rethrow;
}
}
@ -1099,21 +1171,60 @@ class CallManager extends ChangeNotifier {
}
void _navigateToCallScreen() {
log('═══════════════════════════════════════════', name: 'CallManager');
log('🧭 [NAV] ▶️ _navigateToCallScreen() CALLED', name: 'CallManager');
log(' Timestamp: ${DateTime.now().toIso8601String()}', name: 'CallManager');
final context = navigatorKey.currentContext;
log('🔍 [NAV] Checking preconditions...', name: 'CallManager');
log(' navigatorKey.currentContext: ${context != null ? "EXISTS" : "NULL"}', name: 'CallManager');
log(' _currentCall: ${_currentCall != null ? "EXISTS" : "NULL"}', name: 'CallManager');
if (context == null || _currentCall == null) {
log('⚠️ [NAV] Cannot navigate - context or call is null', name: 'CallManager');
log('❌ [NAV] Cannot navigate - missing requirements', name: 'CallManager');
log(' Context: ${context == null ? "NULL" : "EXISTS"}', name: 'CallManager');
log(' Current call: ${_currentCall == null ? "NULL" : "EXISTS (${_currentCall!.callId})"}', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
return;
}
log('🧭 [NAV] Navigating to ${_currentCall!.type.name} call screen', name: 'CallManager');
log('✅ [NAV] Preconditions satisfied', name: 'CallManager');
log(' Context type: ${context.runtimeType}', name: 'CallManager');
log(' Context mounted: ${context.mounted}', name: 'CallManager');
log(' Call ID: ${_currentCall!.callId}', name: 'CallManager');
log(' Call type: ${_currentCall!.type.name}', name: 'CallManager');
log(' Peer: ${_currentCall!.peerName}', name: 'CallManager');
final targetScreen = _currentCall!.type == CallType.audio ? 'AudioCallPage' : 'VideoCallPage';
log('🎯 [NAV] Target screen: $targetScreen', name: 'CallManager');
log('🚀 [NAV] Attempting Navigator.push...', name: 'CallManager');
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _currentCall!.type == CallType.audio
try {
final route = MaterialPageRoute(
builder: (context) => _currentCall?.type == CallType.audio
? const AudioCallPage()
: const VideoCallPage(),
),
);
);
log('✅ [NAV] MaterialPageRoute created successfully', name: 'CallManager');
log(' Route settings: ${route.settings}', name: 'CallManager');
log('📤 [NAV] Pushing route to navigator...', name: 'CallManager');
Navigator.of(context).push(route).then((result) {
log('✅ [NAV] Navigation completed!', name: 'CallManager');
log(' Result: $result', name: 'CallManager');
log(' Call screen should now be visible', name: 'CallManager');
}).catchError((error, stackTrace) {
log('❌ [NAV] Navigation future error: $error\nStack: $stackTrace', name: 'CallManager');
});
log('✅ [NAV] Navigator.push() called successfully (async)', name: 'CallManager');
log(' Navigation is in progress...', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [NAV] ❌ NAVIGATION EXCEPTION: $e\nStack: $stackTrace', name: 'CallManager');
log(' This indicates a problem with the Navigator itself', name: 'CallManager');
}
log('═══════════════════════════════════════════', name: 'CallManager');
}
void _updateCallStatus(CallStatus newStatus) {
@ -1180,7 +1291,7 @@ class CallManager extends ChangeNotifier {
// ==================== SignalR Event Handlers ====================
void _handleIncomingCall(List<Object?>? args) {
log('═══════════════════════════════════════════', name: 'CallManager');
log('══════════<EFBFBD><EFBFBD><EFBFBD>════════════════════════════════', name: 'CallManager');
log('📞 [EVENT] ⚠️ OnIncomingCallAsync received', name: 'CallManager');
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
log(' Args: $args', name: 'CallManager');
@ -1363,16 +1474,30 @@ class CallManager extends ChangeNotifier {
log(' Offer SDP length: ${offerSdp.length}', name: 'CallManager');
// CRITICAL FIX: Wait for WebRTC initialization to complete before processing offer
if (!_webrtcService.isFullyInitialized) {
log('⏳ [EVENT] Waiting for WebRTC Initialization', name: 'CallManager');
await _webrtcService.waitForInitialization();
log('✅ [EVENT] WebRTC Initialization Complete', name: 'CallManager');
// CRITICAL FIX: Check if WebRTC initialization is in progress
if (_webrtcInitCompleter != null && !_webrtcInitCompleter!.isCompleted) {
log('⏳ [EVENT] WebRTC initialization in progress - queuing offer', name: 'CallManager');
_pendingOfferSdp = offerSdp;
log('✅ [EVENT] Offer queued, will be processed after initialization completes', name: 'CallManager');
return;
}
// CRITICAL FIX: Wait for WebRTC initialization if it exists
if (_webrtcInitCompleter != null) {
log('⏳ [EVENT] Waiting for WebRTC initialization to complete...', name: 'CallManager');
try {
await _webrtcInitCompleter!.future;
log('✅ [EVENT] WebRTC initialization complete, proceeding with offer', name: 'CallManager');
} catch (e) {
log('❌ [EVENT] WebRTC initialization failed: $e', name: 'CallManager');
return;
}
}
// Verify peer connection is ready
if (!_webrtcService.isPeerConnectionInitialized) {
log('❌ [EVENT] Peer connection not initialized - cannot process offer', name: 'CallManager');
log(' This should not happen after waiting for completer!', name: 'CallManager');
return;
}
@ -1403,7 +1528,7 @@ class CallManager extends ChangeNotifier {
log('📞 [EVENT] 📥 OnAnswerOfferAsync received', name: 'CallManager');
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
log(' Current status: ${_callStatus.name}', name: 'CallManager');
log(' WebRTC initialized: ${_webrtcService != null}', name: 'CallManager');
log(' WebRTC initialized: ${_webrtcService.isPeerConnectionInitialized}', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
if (args == null || args.isEmpty) {
@ -1421,7 +1546,7 @@ class CallManager extends ChangeNotifier {
log(' Answer SDP length: ${answerSdp.length}', name: 'CallManager');
log('🔧 [EVENT] Setting remote answer...', name: 'CallManager');
await _webrtcService!.setRemoteAnswer(answerSdp);
await _webrtcService.setRemoteAnswer(answerSdp);
log('✅ [EVENT] Remote answer set successfully', name: 'CallManager');
log(' Now waiting for ICE connection to establish...', name: 'CallManager');
@ -1622,4 +1747,153 @@ class CallManager extends ChangeNotifier {
log('✅ [ICE] Connection recovered: $state', name: 'CallManager');
}
}
// ==================== PENDING CALL RESTORATION (Background/Terminated App) ====================
/// Check for pending calls on app resume
/// This is called when the app comes to foreground after background/terminated state
Future<void> checkForPendingCalls() async {
try {
log('═══════════════════════════════════════════', name: 'CallManager');
log('🔍 [MAIN ISOLATE] Checking for pending calls on app resume...', name: 'CallManager');
// Import the storage service
final pendingCallData = await PendingCallStorage.getPendingCall();
if (pendingCallData == null) {
log('ℹ️ [MAIN ISOLATE] No pending call found', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
return;
}
log('📞 [MAIN ISOLATE] Found pending call!', name: 'CallManager');
log(' Call ID: ${pendingCallData['callId']}', name: 'CallManager');
log(' Caller: ${pendingCallData['callerName']}', name: 'CallManager');
log(' Is Video: ${pendingCallData['isVideoCall']}', name: 'CallManager');
// Check if call was accepted from background
final acceptedCallId = await PendingCallStorage.getAcceptedCallId();
if (acceptedCallId == pendingCallData['callId']) {
log('✅ [MAIN ISOLATE] Call was ACCEPTED from background', name: 'CallManager');
log(' Restoring call session and initializing services...', name: 'CallManager');
// Restore call session
_currentCall = CallSession(
callId: pendingCallData['callId'] as String,
type: (pendingCallData['isVideoCall'] as bool) ? CallType.video : CallType.audio,
direction: CallDirection.incoming,
peerId: pendingCallData['callerId'] as String,
peerName: pendingCallData['callerName'] as String,
peerAvatar: null,
startTime: DateTime.now(),
);
// Store pending data for acceptCall to use
_pendingCallData = pendingCallData;
log('🔧 [MAIN ISOLATE] Initializing communication services via CallCoordinator...', name: 'CallManager');
// Use CallCoordinator to initialize SignalR and CallManager with stored credentials
final coordinator = CallCoordinator();
final initialized = await coordinator.initializeForIncomingCall(
callerEmployeeNumber: pendingCallData['callerEmployeeNumber'] as String? ?? pendingCallData['callerId'] as String,
conversationId: pendingCallData['conversationId'] as String?,
moduleId: pendingCallData['moduleId'] as String?,
referenceId: pendingCallData['referenceId'] as String?,
);
if (initialized) {
log('✅ [MAIN ISOLATE] Services initialized successfully', name: 'CallManager');
// Set navigation pending flag
_navigationPending = true;
log('✅ [MAIN ISOLATE] Call session restored, navigation pending', name: 'CallManager');
log(' Call handlePendingNavigation() when navigator is ready', name: 'CallManager');
} else {
log('❌ [MAIN ISOLATE] Service initialization failed - cannot restore call', name: 'CallManager');
await PendingCallStorage.clearPendingCall();
await PendingCallStorage.clearAcceptedCallMarker();
}
} else {
log('ℹ️ [MAIN ISOLATE] Call not accepted yet (still ringing or declined)', name: 'CallManager');
}
log('═══════════════════════════════════════════', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [MAIN ISOLATE] Error checking pending calls: $e\nStack: $stackTrace',
name: 'CallManager');
}
}
/// Handle pending navigation when app resumes
/// This is called by the app lifecycle observer when the app reaches resumed state
Future<void> handlePendingNavigation() async {
try {
log('═══════════════════════════════════════════', name: 'CallManager');
log('🧭 [NAVIGATION] Handling pending navigation...', name: 'CallManager');
log(' Navigation pending: $_navigationPending', name: 'CallManager');
log(' Current call: ${_currentCall?.callId}', name: 'CallManager');
log(' Call status: ${_callStatus.name}', name: 'CallManager');
log(' Lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name}', name: 'CallManager');
// Check if navigation is pending
if (!_navigationPending) {
log('ℹ️ [NAVIGATION] No pending navigation', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
return;
}
// Check if we have a current call
if (_currentCall == null) {
log('⚠️ [NAVIGATION] Navigation pending but no current call', name: 'CallManager');
_navigationPending = false;
log('═══════════════════════════════════════════', name: 'CallManager');
return;
}
// Check if navigator is ready
final hasContext = navigatorKey.currentContext != null;
final hasState = navigatorKey.currentState != null;
log('🔍 [NAVIGATION] Navigator readiness check:', name: 'CallManager');
log(' currentContext != null: $hasContext', name: 'CallManager');
log(' currentState != null: $hasState', name: 'CallManager');
if (!hasContext || !hasState) {
log('⚠️ [NAVIGATION] Navigator not ready yet - will retry', name: 'CallManager');
log(' This method will be called again when app fully resumes', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
return;
}
log('✅ [NAVIGATION] Navigator is ready', name: 'CallManager');
// Wait for first frame to be rendered
log('⏳ [NAVIGATION] Waiting for first frame...', name: 'CallManager');
await WidgetsBinding.instance.endOfFrame;
log('✅ [NAVIGATION] First frame rendered', name: 'CallManager');
// Clear navigation pending flag first to prevent duplicate navigation
_navigationPending = false;
// Navigate to call screen
log('🚀 [NAVIGATION] Navigating to call screen...', name: 'CallManager');
_navigateToCallScreen();
log('✅ [NAVIGATION] Navigation completed successfully', name: 'CallManager');
// Clear pending call data from storage
await PendingCallStorage.clearPendingCall();
await PendingCallStorage.clearAcceptedCallMarker();
log('🗑️ [NAVIGATION] Cleared pending call data from storage', name: 'CallManager');
log('═══════════════════════════════════════════', name: 'CallManager');
} catch (e, stackTrace) {
log('❌ [NAVIGATION] Error handling pending navigation: $e',
name: 'CallManager', error: e, stackTrace: stackTrace);
_navigationPending = false;
}
}
}

@ -0,0 +1,137 @@
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 {
log('═══════════════════════════════════════════', name: 'PendingCallStorage');
log('💾 [STORAGE] Saving pending call data', name: 'PendingCallStorage');
log(' Call ID: $callId', name: 'PendingCallStorage');
log(' Caller: $callerName ($callerEmployeeNumber)', name: 'PendingCallStorage');
log(' Is Video: $isVideoCall', name: 'PendingCallStorage');
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));
log('✅ [STORAGE] Pending call data saved successfully', name: 'PendingCallStorage');
log('═══════════════════════════════════════════', name: 'PendingCallStorage');
} 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) {
log('ℹ️ [STORAGE] No pending call data found', name: 'PendingCallStorage');
return null;
}
final callData = jsonDecode(dataString) as Map<String, dynamic>;
log('═══════════════════════════════════════════', name: 'PendingCallStorage');
log('📥 [STORAGE] Retrieved pending call data', name: 'PendingCallStorage');
log(' Call ID: ${callData['callId']}', name: 'PendingCallStorage');
log(' Caller: ${callData['callerName']} (${callData['callerEmployeeNumber']})', name: 'PendingCallStorage');
log(' Is Video: ${callData['isVideoCall']}', name: 'PendingCallStorage');
log(' Timestamp: ${callData['timestamp']}', name: 'PendingCallStorage');
log('═══════════════════════════════════════════', name: 'PendingCallStorage');
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);
log('🗑️ [STORAGE] Pending call data cleared', name: 'PendingCallStorage');
} 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);
log('✅ [STORAGE] Marked call $callId as accepted from background', name: 'PendingCallStorage');
} 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);
log('🗑️ [STORAGE] Cleared accepted call marker', name: 'PendingCallStorage');
} 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,6 +4,7 @@ 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)
@ -47,6 +48,57 @@ 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 {
log('═══════════════════════════════════════════', name: 'SignalRService');
log('🔄 [SIGNALR] Initializing from stored credentials...', name: 'SignalRService');
log(' Context: Background/Terminated app state', name: 'SignalRService');
try {
// Step 1: Read credentials from SharedPreferences
log('📖 [SIGNALR] Reading credentials from SharedPreferences...', name: 'SignalRService');
final credentials = await AuthStorage.getCredentials();
if (credentials == null) {
log('❌ [SIGNALR] No stored credentials found', name: 'SignalRService');
log(' Cannot initialize SignalR without credentials', name: 'SignalRService');
log(' User must be logged in first', name: 'SignalRService');
log('═══════════════════════════════════════════', name: 'SignalRService');
return false;
}
log('✅ [SIGNALR] Credentials retrieved from storage', name: 'SignalRService');
log(' User ID: ${credentials.userId}', name: 'SignalRService');
log(' Token length: ${credentials.accessToken.length} chars', name: 'SignalRService');
// Step 2: Initialize SignalR using stored credentials
log('🔌 [SIGNALR] Initializing connection with stored credentials...', name: 'SignalRService');
final success = await initialize(
userId: credentials.userId,
authToken: credentials.accessToken,
conversationId: null, // No conversation for background calls
);
if (success) {
log('✅ [SIGNALR] Successfully initialized from storage', name: 'SignalRService');
log(' Connection ID: ${_hubConnection?.connectionId}', name: 'SignalRService');
} else {
log('❌ [SIGNALR] Failed to initialize from storage', name: 'SignalRService');
}
log('═══════════════════════════════════════════', name: 'SignalRService');
return success;
} catch (e, stackTrace) {
log('❌ [SIGNALR] Error initializing from storage: $e',
name: 'SignalRService', error: e, stackTrace: stackTrace);
log('═══════════════════════════════════════════', name: 'SignalRService');
return false;
}
}
/// Initialize SignalR connection with authentication
/// Thread-safe: Multiple callers will wait for the same connection task
Future<bool> initialize({

@ -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';
@ -259,6 +260,22 @@ 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('✅ [LAND PAGE] Credentials saved successfully', name: 'LandPage');
dev.log(' Background calls will now work', name: 'LandPage');
} else {
dev.log('⚠️ [LAND PAGE] Failed to save credentials', name: 'LandPage');
dev.log(' Background calls may not work', name: 'LandPage');
}
// Step 2: Initialize SignalR Service (SINGLETON - only once)
dev.log('🔌 [LAND PAGE] Initializing SignalR Service...', name: 'LandPage');
final signalRService = SignalRService();
@ -317,9 +334,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