diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 536e2368..b909df67 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -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 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 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 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 messageData) { + static Future handleMessage(context, Map 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('���══════════════════════════════════════════', 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); diff --git a/lib/core/di/service_locator.dart b/lib/core/di/service_locator.dart index 455b2ea1..743ae269 100644 --- a/lib/core/di/service_locator.dart +++ b/lib/core/di/service_locator.dart @@ -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 setupServiceLocator() async { - // SignalR Service - ONLY ONE instance for the entire app - getIt.registerLazySingleton( - () => SignalRService(), - ); - - // WebRTC Service - ONLY ONE instance for the entire app - getIt.registerLazySingleton( - () => WebRTCService(), - ); - - print('✅ [DI] Service locator initialized'); - print(' - SignalRService registered as singleton (hashCode: ${getIt().hashCode})'); - print(' - WebRTCService registered as singleton (hashCode: ${getIt().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 _registerServices() async { + // 1. SignalR Service + if (!getIt.isRegistered()) { + getIt.registerLazySingleton(() => SignalRService()); + log(' ✅ SignalRService registered as singleton', name: 'ServiceLocator'); + } else { + log(' ⚠️ SignalRService already registered, skipping', name: 'ServiceLocator'); + } + + // 2. WebRTC Service + if (!getIt.isRegistered()) { + getIt.registerLazySingleton(() => 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()) { + getIt.registerLazySingleton(() => CallManager()); + log(' ✅ CallManager registered as singleton', name: 'ServiceLocator'); + } else { + log(' ⚠️ CallManager already registered, skipping', name: 'ServiceLocator'); + } + + // 4. CallNotificationService (depends on CallManager) + if (!getIt.isRegistered()) { + getIt.registerLazySingleton(() => 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 = [ + 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 resetServices() async { - await getIt().reset(); - print('✅ [DI] All services reset'); + try { + if (getIt.isRegistered()) { + await getIt().reset(); + } + if (getIt.isRegistered()) { + await getIt().reset(); + } + log('✅ [DI] All services reset', name: 'ServiceLocator'); + } catch (e) { + log('❌ [DI] Error resetting services: $e', name: 'ServiceLocator'); + } } diff --git a/lib/core/storage/auth_storage.dart b/lib/core/storage/auth_storage.dart new file mode 100644 index 00000000..d51b8c3d --- /dev/null +++ b/lib/core/storage/auth_storage.dart @@ -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 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 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 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 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 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 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 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})'; + } +} + diff --git a/lib/main.dart b/lib/main.dart index a29f1b0a..e0b81ce6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( diff --git a/PRODUCTION_READY_CALLING_COMPLETE.md b/lib/modules/cx_module/chat/call/services/app_lifecycle_service.dart similarity index 100% rename from PRODUCTION_READY_CALLING_COMPLETE.md rename to lib/modules/cx_module/chat/call/services/app_lifecycle_service.dart diff --git a/lib/modules/cx_module/chat/call/services/call_notification_service.dart b/lib/modules/cx_module/chat/call/services/call_notification_service.dart index 2a50051b..c6018cd3 100644 --- a/lib/modules/cx_module/chat/call/services/call_notification_service.dart +++ b/lib/modules/cx_module/chat/call/services/call_notification_service.dart @@ -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(); + final Set _processedCallIds = {}; - Map? _pendingCallData; + + /// Handle incoming call notification in the MAIN ISOLATE ONLY + /// This assumes SignalR and CallManager are already initialized Future handleIncomingCallNotification({ required Map 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? 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'); } } diff --git a/lib/modules/cx_module/chat/call/services/callkit_service.dart b/lib/modules/cx_module/chat/call/services/callkit_service.dart index 7b44560c..60c3b6e1 100644 --- a/lib/modules/cx_module/chat/call/services/callkit_service.dart +++ b/lib/modules/cx_module/chat/call/services/callkit_service.dart @@ -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 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 diff --git a/lib/modules/cx_module/chat/call/services/webrtc_service.dart b/lib/modules/cx_module/chat/call/services/webrtc_service.dart index c3b8676b..45317fd8 100644 --- a/lib/modules/cx_module/chat/call/services/webrtc_service.dart +++ b/lib/modules/cx_module/chat/call/services/webrtc_service.dart @@ -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 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 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 dispose() async { try { diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index 7a7417da..02da8ec9 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -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(); + + 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 _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 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'); } } diff --git a/lib/modules/cx_module/chat/services/call_manager.dart b/lib/modules/cx_module/chat/services/call_manager.dart index fd496d6c..74efa141 100644 --- a/lib/modules/cx_module/chat/services/call_manager.dart +++ b/lib/modules/cx_module/chat/services/call_manager.dart @@ -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? _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? _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 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 _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 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? args) { - log('═══════════════════════════════════════════', name: 'CallManager'); + log('══════════���════════════════════════════════', 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 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 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; + } + } } diff --git a/lib/modules/cx_module/chat/services/pending_call_storage.dart b/lib/modules/cx_module/chat/services/pending_call_storage.dart new file mode 100644 index 00000000..6942f8ca --- /dev/null +++ b/lib/modules/cx_module/chat/services/pending_call_storage.dart @@ -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 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?> 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; + + 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 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 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 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 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 hasPendingCall() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.containsKey(_pendingCallKey); + } catch (e) { + return false; + } + } +} + diff --git a/lib/modules/cx_module/chat/services/signalr_service.dart b/lib/modules/cx_module/chat/services/signalr_service.dart index 26e50d4c..a73ae217 100644 --- a/lib/modules/cx_module/chat/services/signalr_service.dart +++ b/lib/modules/cx_module/chat/services/signalr_service.dart @@ -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 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 initialize({ diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index d22e5952..a840a9b7 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -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 { 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 { // 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'); } } -} +} \ No newline at end of file diff --git a/lib/services/app_lifecycle_observer.dart b/lib/services/app_lifecycle_observer.dart new file mode 100644 index 00000000..67efe18f --- /dev/null +++ b/lib/services/app_lifecycle_observer.dart @@ -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 _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()) { + final callManager = getIt(); + 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 _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()) { + final callManager = getIt(); + await callManager.checkForPendingCalls(); + _hasCheckedPendingCall = true; + } + } + + // Handle pending navigation (if call was accepted while app was launching) + if (getIt.isRegistered()) { + final callManager = getIt(); + 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'); + } +} + + diff --git a/NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md b/lib/services/app_lifecycle_service.dart similarity index 100% rename from NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md rename to lib/services/app_lifecycle_service.dart diff --git a/pubspec.lock b/pubspec.lock index 62dcbadc..7933dec6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1868,7 +1868,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: "direct main" + dependency: transitive description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"