From 79b6d4fa2bcc77d92e694bb663be1d241c5e97d2 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:47:12 +0300 Subject: [PATCH] improvements --- NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md | 1 + .../firebase_notification_manger.dart | 107 +- .../services/call_notification_service.dart | 202 +++ lib/modules/cx_module/chat/chat_provider.dart | 1417 +---------------- .../cx_module/chat/services/call_manager.dart | 995 ++++++++++++ .../chat/services/signalr_service.dart | 277 ++++ 6 files changed, 1649 insertions(+), 1350 deletions(-) create mode 100644 NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md create mode 100644 lib/modules/cx_module/chat/call/services/call_notification_service.dart create mode 100644 lib/modules/cx_module/chat/services/call_manager.dart create mode 100644 lib/modules/cx_module/chat/services/signalr_service.dart diff --git a/NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md b/NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/NOTIFICATION_DRIVEN_CALLING_IMPLEMENTATION.md @@ -0,0 +1 @@ + diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 9866be33..ca6bc6b2 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -18,9 +18,30 @@ 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'; @pragma('vm:entry-point') -Future firebaseMessagingBackgroundHandler(RemoteMessage message) async {} +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + try { + log('๐Ÿ“ฑ [BACKGROUND] Push notification received', name: 'FirebaseNotificationManger'); + log('๐Ÿ“ฑ [BACKGROUND] Data: ${message.data}', name: 'FirebaseNotificationManger'); + + final notificationType = message.data['notificationType'] as String?; + final transactionType = message.data['transactionType'] as String?; + + // Handle incoming call notifications + if (notificationType == 'incoming_call' || transactionType == 'call') { + log('๐Ÿ“ž [BACKGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger'); + await CallNotificationService().handleIncomingCallNotification( + notificationData: message.data, + source: 'push', + ); + } + } catch (e, stackTrace) { + log('โŒ [BACKGROUND] Error handling background message: $e', + name: 'FirebaseNotificationManger', error: e, stackTrace: stackTrace); + } +} class FirebaseNotificationManger { static FirebaseMessaging messaging = FirebaseMessaging.instance; @@ -86,6 +107,21 @@ class FirebaseNotificationManger { } static void handleMessage(context, Map messageData) { + // NEW: Check if this is a call notification first + final notificationType = messageData['notificationType'] as String?; + final transactionType = messageData['transactionType'] as String?; + + if (notificationType == 'incoming_call' || transactionType == 'call') { + log('๐Ÿ“ž [FOREGROUND] Incoming call notification detected', name: 'FirebaseNotificationManger'); + + // Let CallNotificationService handle it + CallNotificationService().handleIncomingCallNotification( + notificationData: messageData, + source: 'push', + ); + return; + } + if (messageData["requestType"] != null && messageData["requestNumber"] != null) { Widget? serviceClass; @@ -199,10 +235,21 @@ class FirebaseNotificationManger { static initialized(BuildContext context) async { //TOD0 add platform check here also if (!(await isGoogleServicesAvailable()) && Platform.isAndroid) { + // NEW: Handle Huawei initial notification var initialNotification = await h_push.Push.getInitialNotification(); if (initialNotification != null) { Map remoteData = Map.from(initialNotification["extras"] as Map); - handleMessage(context, remoteData); + + // Check if it's a call notification + final notificationType = remoteData['notificationType'] as String?; + if (notificationType == 'incoming_call') { + await CallNotificationService().handleIncomingCallNotification( + notificationData: remoteData, + source: 'push', + ); + } else { + handleMessage(context, remoteData); + } } h_push.Push.onNotificationOpenedApp.listen((message) { @@ -211,7 +258,16 @@ class FirebaseNotificationManger { Map remoteData = message; remoteData = remoteData["extras"]; - handleMessage(context, remoteData); + // Check if it's a call notification + final notificationType = remoteData['notificationType'] as String?; + if (notificationType == 'incoming_call') { + CallNotificationService().handleIncomingCallNotification( + notificationData: remoteData, + source: 'push', + ); + } else { + handleMessage(context, remoteData); + } } } catch (ex) { print("parsingError:$ex"); @@ -245,22 +301,61 @@ class FirebaseNotificationManger { FirebaseMessaging.instance.getInitialMessage().then((initialMessage) { if (initialMessage != null) { - handleMessage(context, initialMessage.data); + // NEW: Check if it's a call notification + final notificationType = initialMessage.data['notificationType'] as String?; + if (notificationType == 'incoming_call') { + CallNotificationService().handleIncomingCallNotification( + notificationData: initialMessage.data, + source: 'push', + ); + } else { + handleMessage(context, initialMessage.data); + } } }); FirebaseMessaging.onMessage.listen((RemoteMessage message) { + // NEW: Check if it's a call notification + final notificationType = message.data['notificationType'] as String?; + + if (notificationType == 'incoming_call') { + log('๐Ÿ“ž [FOREGROUND] Incoming call via FCM', name: 'FirebaseNotificationManger'); + CallNotificationService().handleIncomingCallNotification( + notificationData: message.data, + source: 'push', + ); + return; + } + + // ...existing code... if (Platform.isAndroid) { if (message.data["notificationType"] != 'NurseConfirmArrive') { NotificationManger.showNotification( - title: message.notification?.title ?? "", subtext: message.notification?.body ?? "", hashcode: int.tryParse("1234" ?? "") ?? 1, payload: json.encode(message.data), context: context); + title: message.notification?.title ?? "", + subtext: message.notification?.body ?? "", + hashcode: int.tryParse("1234" ?? "") ?? 1, + payload: json.encode(message.data), + context: context); } } return; }); + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { - handleMessage(context, message.data); + // NEW: Check if it's a call notification + final notificationType = message.data['notificationType'] as String?; + + if (notificationType == 'incoming_call') { + log('๐Ÿ“ž [NOTIFICATION TAP] Incoming call notification tapped', name: 'FirebaseNotificationManger'); + CallNotificationService().handleIncomingCallNotification( + notificationData: message.data, + source: 'push', + ); + } else { + handleMessage(context, message.data); + } }); + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); } } 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 new file mode 100644 index 00000000..a782be3b --- /dev/null +++ b/lib/modules/cx_module/chat/call/services/call_notification_service.dart @@ -0,0 +1,202 @@ +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'; + +/// Service to handle incoming call notifications across all app states +/// Integrates with existing Firebase notification system +class CallNotificationService { + static final CallNotificationService _instance = CallNotificationService._internal(); + factory CallNotificationService() => _instance; + CallNotificationService._internal(); + + // Track processed call IDs to prevent duplicates + final Set _processedCallIds = {}; + + // Store pending call data for restoration after app wake + Map? _pendingCallData; + + /// Handle incoming call notification from push (FCM/Huawei) or SignalR + /// This is the SINGLE entry point for all incoming calls regardless of app state + Future handleIncomingCallNotification({ + required Map notificationData, + required String source, // 'push' or 'signalr' + }) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallNotificationService'); + log('๐Ÿ“ž [INCOMING CALL] Notification received from: $source', name: 'CallNotificationService'); + log('๐Ÿ“ž [INCOMING CALL] Data: $notificationData', name: 'CallNotificationService'); + + // Extract call information + final notificationType = notificationData['notificationType'] as String?; + final transactionType = notificationData['transactionType'] as String?; + + // Verify this is a call notification + if (notificationType != 'incoming_call' && transactionType != 'call') { + log('โš ๏ธ [INCOMING CALL] Not a call notification, ignoring', name: 'CallNotificationService'); + return; + } + + final callId = notificationData['callId'] as String? ?? + notificationData['sessionId'] as String?; + final callerId = notificationData['callerId'] as String? ?? + notificationData['sourceUserId'] as String?; + final callerName = notificationData['callerName'] as String? ?? + notificationData['userName'] as String? ?? + 'Unknown Caller'; + final isVideoCall = notificationData['isVideoCall'] as bool? ?? + (notificationData['callType'] == 'video'); + final moduleId = notificationData['moduleId'] as String?; + final referenceId = notificationData['referenceId'] as String?; + final conversationId = notificationData['conversationId'] as String?; + + if (callId == null || callerId == null) { + log('โŒ [INCOMING CALL] Missing required data (callId or callerId)', name: 'CallNotificationService'); + return; + } + + // CRITICAL: Prevent duplicate processing + if (_processedCallIds.contains(callId)) { + log('โš ๏ธ [INCOMING CALL] Already processed call $callId, ignoring duplicate', name: 'CallNotificationService'); + return; + } + _processedCallIds.add(callId); + + // Clean up old processed IDs (keep last 50) + if (_processedCallIds.length > 50) { + final oldIds = _processedCallIds.take(_processedCallIds.length - 50).toList(); + _processedCallIds.removeAll(oldIds); + } + + log('โœ… [INCOMING CALL] Valid call notification', name: 'CallNotificationService'); + log(' Call ID: $callId', name: 'CallNotificationService'); + log(' Caller: $callerName ($callerId)', name: 'CallNotificationService'); + log(' Video: $isVideoCall', name: 'CallNotificationService'); + + // Store pending call data + _pendingCallData = { + 'callId': callId, + 'callerId': callerId, + 'callerName': callerName, + 'isVideoCall': isVideoCall, + 'moduleId': moduleId, + 'referenceId': referenceId, + 'conversationId': conversationId, + 'timestamp': DateTime.now().toIso8601String(), + }; + + // Show CallKit/ConnectionService immediately + await _showNativeIncomingCallUI( + callId: callId, + callerName: callerName, + callerNumber: callerId, + isVideo: isVideoCall, + extra: _pendingCallData!, + ); + log('โœ… [INCOMING CALL] CallKit UI displayed', name: 'CallNotificationService'); + + // Use CallManager to handle the incoming call + log('๐Ÿ“ž [INCOMING CALL] Delegating to CallManager...', name: 'CallNotificationService'); + await CallManager().handleIncomingCallNotification( + callId: callId, + callerId: callerId, + callerName: callerName, + isVideoCall: isVideoCall, + extraData: _pendingCallData, + ); + + log('โœ… [INCOMING CALL] Call handled successfully', name: 'CallNotificationService'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallNotificationService'); + + } catch (e, stackTrace) { + log('โŒ [INCOMING CALL] Error handling notification: $e', + name: 'CallNotificationService', error: e, stackTrace: stackTrace); + } + } + + /// Show native incoming call UI using CallKit/ConnectionService + Future _showNativeIncomingCallUI({ + required String callId, + required String callerName, + required String callerNumber, + required bool isVideo, + required Map extra, + }) async { + try { + log('๐Ÿ“ฑ [CALLKIT] Showing native incoming call UI...', name: 'CallNotificationService'); + log(' Call ID: $callId', name: 'CallNotificationService'); + log(' Caller: $callerName', name: 'CallNotificationService'); + log(' Video: $isVideo', name: 'CallNotificationService'); + + final params = CallKitParams( + id: callId, + nameCaller: callerName, + appName: 'Atoms SA', + handle: callerNumber, + type: isVideo ? 1 : 0, // 0 = audio, 1 = video + duration: 30000, // 30 seconds timeout + extra: extra, + headers: {'platform': 'flutter'}, + android: AndroidParams( + isCustomNotification: true, + isShowLogo: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0955fa', + backgroundUrl: '', + actionColor: '#4CAF50', + incomingCallNotificationChannelName: 'Incoming Calls', + missedCallNotificationChannelName: 'Missed Calls', + ), + ios: IOSParams( + iconName: 'CallKitLogo', + handleType: 'generic', + supportsVideo: true, + maximumCallGroups: 2, + maximumCallsPerCallGroup: 1, + audioSessionMode: 'videoChat', + audioSessionActive: true, + audioSessionPreferredSampleRate: 44100.0, + audioSessionPreferredIOBufferDuration: 0.005, + supportsDTMF: true, + supportsHolding: true, + supportsGrouping: false, + supportsUngrouping: false, + ringtonePath: 'system_ringtone_default', + ), + ); + + await FlutterCallkitIncoming.showCallkitIncoming(params); + log('โœ… [CALLKIT] Native UI displayed successfully', name: 'CallNotificationService'); + + } catch (e, stackTrace) { + log('โŒ [CALLKIT] Error showing native UI: $e', + name: 'CallNotificationService', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// 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); + } + + /// Check if a call has been processed + bool isCallProcessed(String callId) { + return _processedCallIds.contains(callId); + } + + /// 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/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart index 0742d551..f5b5c1d9 100644 --- a/lib/modules/cx_module/chat/chat_provider.dart +++ b/lib/modules/cx_module/chat/chat_provider.dart @@ -25,7 +25,7 @@ import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart'; import 'package:test_sa/modules/cx_module/chat/model/chat_login_response_model.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter/material.dart' as Material; -import 'package:flutter/material.dart'; // NEW - for WidgetsBinding and showDialog +import 'package:flutter/material.dart'; import 'api_client.dart'; import 'chat_api_client.dart'; @@ -39,9 +39,11 @@ import 'model/call_session.dart'; import 'call/call_debug_helper.dart'; import 'call/incoming_call_dialog.dart'; import 'call/services/webrtc_service.dart'; -import 'call/services/callkit_service.dart'; // Add CallKit service +import 'call/services/callkit_service.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'call/call_error_handler.dart'; +import 'call/services/call_notification_service.dart'; +import 'services/call_manager.dart'; // NEW: Import CallManager HubConnection? chatHubConnection; @@ -56,8 +58,6 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { bool userChatHistoryLoading = false; - // UserChatHistoryModel? userChatHistory; - List? userChatHistory; bool messageIsSending = false; @@ -71,46 +71,32 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { late int moduleID; int? referenceID; - // === NEW: CALL STATE VARIABLES === - CallStatus callStatus = CallStatus.idle; - CallSession? currentCall; - Duration callDuration = Duration.zero; - Timer? _callDurationTimer; - Timer? _callTimeoutTimer; - - // Call control state - bool isMuted = false; - bool isSpeakerOn = false; - bool isCameraOn = true; - bool isPeerMuted = false; - bool isPeerCameraOn = true; - - // NEW: Call handlers registration status - bool _callHandlersRegistered = false; - - bool get areCallHandlersRegistered => _callHandlersRegistered; - - bool get isCallInProgress => callStatus != CallStatus.idle; - - // NEW: WebRTC service instance - WebRTCService? _webrtcService; - - // Public getter for WebRTC service (for video renderers access) - WebRTCService? get webrtcService => _webrtcService; - - // NEW: CallKit service instance - final CallKitService _callKitService = CallKitService(); - bool _callKitInitialized = false; - - // NEW: Remote stream for audio playback - MediaStream? _remoteMediaStream; - - MediaStream? get remoteMediaStream => _remoteMediaStream; - - // NEW: Ringtone player for outgoing calls + // === 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; + + // For backwards compatibility with UI components + bool get areCallHandlersRegistered => true; // Always true since CallManager handles it + + // Private state for legacy ringtone support final JustAudio.AudioPlayer _ringingPlayer = JustAudio.AudioPlayer(); bool _isRingingPlaying = false; + /// Update call status with detailed logging + void _updateCallStatus(CallStatus newStatus, {String? reason}) { + // Call status is now managed by CallManager + log('โ„น๏ธ [CALL STATUS] Call status is now managed by CallManager', name: 'ChatProvider'); + notifyListeners(); + } /// OPTIMIZATION: Improved connection disposal to prevent memory leaks /// This properly handles errors and ensures connection is always cleaned up Future _disposeConnection() async { @@ -875,1203 +861,95 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { } // ==================== CALL INFRASTRUCTURE ==================== - // All call-related methods are placed at the end to avoid modifying existing chat logic + // All call operations now delegate to CallManager - /// Start a new audio or video call + /// Start a new audio or video call - delegates to CallManager Future startCall(Participants recipient, CallType callType) async { - log('๐Ÿ”ต [CALL] startCall() invoked', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Current callStatus: $callStatus', name: 'ChatProvider'); - - // Get context for error dialogs - final context = navigatorKey.currentContext; - if (context == null) { - log('โŒ [CALL] No context available', name: 'ChatProvider'); - return; - } - - // Check if already in a call - if (callStatus != CallStatus.idle) { - log('โš ๏ธ [CALL] Cannot start call - already in a call (status: $callStatus)', name: 'ChatProvider'); - CallErrorHandler.showCallAlreadyInProgress(context); - return; - } + log('๐Ÿ“ž [ChatProvider] startCall() - delegating to CallManager', name: 'ChatProvider'); if (sender == null) { - log('โŒ [CALL] Cannot start call - sender is null', name: 'ChatProvider'); - CallErrorHandler.showGenericError(context, 'Unable to start call. Please try again.'); - return; - } - - log('๐Ÿ”ต [CALL] Sender: ${sender!.employeeNumber}', name: 'ChatProvider'); - callStatus = CallStatus.checkingPermissions; - notifyListeners(); - log('๐Ÿ”ต [CALL] Status changed to: checkingPermissions', name: 'ChatProvider'); - - try { - // Generate unique call ID - final callId = const Uuid().v4(); - final isVideo = callType == CallType.video; - log('๐Ÿ”ต [CALL] Generated callId: $callId', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Call type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'ChatProvider'); - - // Request permissions - log('๐Ÿ”ต [CALL] Requesting microphone permission...', name: 'ChatProvider'); - final micStatus = await Permission.microphone.request(); - log('๐Ÿ”ต [CALL] Microphone permission status: ${micStatus.name}', name: 'ChatProvider'); - - if (!micStatus.isGranted) { - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - log('โŒ [CALL] Microphone permission denied - aborting call', name: 'ChatProvider'); - CallErrorHandler.showMicrophonePermissionDenied(context); - return; + log('โŒ [ChatProvider] Cannot start call - sender is null', name: 'ChatProvider'); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showGenericError(context, 'Unable to start call. Please try again.'); } - - if (isVideo) { - log('๐Ÿ”ต [CALL] Requesting camera permission...', name: 'ChatProvider'); - final cameraStatus = await Permission.camera.request(); - log('๐Ÿ”ต [CALL] Camera permission status: ${cameraStatus.name}', name: 'ChatProvider'); - - if (!cameraStatus.isGranted) { - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - log('โŒ [CALL] Camera permission denied - aborting call', name: 'ChatProvider'); - CallErrorHandler.showCameraPermissionDenied(context); - return; - } - } - - // Check SignalR connection before proceeding - if (chatHubConnection == null || chatHubConnection!.state != HubConnectionState.Connected) { - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - log('โŒ [CALL] SignalR not connected', name: 'ChatProvider'); - CallErrorHandler.showSignalRNotConnected(context); - return; - } - - // Create call session - log('๐Ÿ”ต [CALL] Creating CallSession...', name: 'ChatProvider'); - currentCall = CallSession( - callId: callId, - type: callType, - direction: CallDirection.outgoing, - peerId: recipient.employeeNumber ?? '', - peerName: recipient.userName ?? 'Unknown', - peerAvatar: recipient.image, - startTime: DateTime.now(), - ); - log('โœ… [CALL] CallSession created - peerId: ${currentCall!.peerId}, peerName: ${currentCall!.peerName}', name: 'ChatProvider'); - - // Invoke SignalR CallUserAsync - log('๐Ÿ”ต [CALL] Invoking CallUserAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${recipient.employeeNumber}', name: 'ChatProvider'); - log(' - isVideoCall: $isVideo', name: 'ChatProvider'); - - try { - await chatHubConnection?.invoke( - 'CallUserAsync', - args: [ - sender!.employeeNumber ?? '', - recipient.employeeNumber ?? '', - isVideo, - ], - ); - log('โœ… [CALL] CallUserAsync invoked successfully', name: 'ChatProvider'); - } catch (e) { - log('โŒ [CALL] Failed to invoke CallUserAsync: $e', name: 'ChatProvider'); - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - CallErrorHandler.showGenericError(context, 'Failed to start call. Please check your connection and try again.'); - return; - } - - callStatus = CallStatus.outgoingRinging; - notifyListeners(); - log('๐Ÿ”ต [CALL] Status changed to: outgoingRinging', name: 'ChatProvider'); - - // TODO: Play outgoing ringtone (commented for now) - // await _playOutgoingRingtone(); - - // Initialize WebRTC now but DON'T send offer yet - wait for call to be accepted - log('๐Ÿ”ง [CALL] Initializing WebRTC (offer will be sent after accept)...', name: 'ChatProvider'); - - try { - _webrtcService = WebRTCService(); - _setupWebRTCCallbacks(); - - if (callType == CallType.audio) { - await _webrtcService!.initializeForAudioCall(); - } else { - await _webrtcService!.initializeForVideoCall(); // Use video initialization for video calls - } - log('โœ… [CALL] WebRTC initialized, waiting for peer to accept...', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - - // Cleanup and notify peer - await chatHubConnection?.invoke('HangUpAsync', args: [ - sender!.employeeNumber ?? '', - recipient.employeeNumber ?? '', - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ]).catchError((err) { - log('โŒ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider'); - }); - - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - - CallErrorHandler.showWebRTCInitFailed(context); - return; - } - - // Navigate to call screen - if (context != null && context.mounted) { - log('๐Ÿ”ต [CALL] Navigating to call screen...', name: 'ChatProvider'); - - // Double check currentCall is still valid before navigation - if (currentCall == null) { - log('โŒ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider'); - return; - } - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => currentCall!.type == CallType.audio - ? const AudioCallPage() - : const VideoCallPage(), - ), - ).then((_) { - log('โœ… [CALL] Navigation completed', name: 'ChatProvider'); - }).catchError((e) { - log('โŒ [CALL] Navigation error: $e', name: 'ChatProvider'); - }); - - log('โœ… [CALL] Navigated to call screen', name: 'ChatProvider'); - } else { - log('โš ๏ธ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider'); - - // Alternative: Use WidgetsBinding to schedule navigation after frame - WidgetsBinding.instance.addPostFrameCallback((_) { - // Wait for app to fully come to foreground - Future.delayed(const Duration(milliseconds: 500), () { - final ctx = navigatorKey.currentContext; - if (ctx != null && ctx.mounted) { - log('๐Ÿ”ต [CALL] Navigating via post-frame callback...', name: 'ChatProvider'); - Navigator.of(ctx).push( - MaterialPageRoute( - builder: (context) => currentCall!.type == CallType.audio - ? const AudioCallPage() - : const VideoCallPage(), - ), - ).then((_) { - log('โœ… [CALL] Post-frame navigation completed', name: 'ChatProvider'); - }).catchError((e) { - log('โŒ [CALL] Post-frame navigation error: $e', name: 'ChatProvider'); - }); - } else { - log('โŒ [CALL] Still no context available - navigation failed', name: 'ChatProvider'); - // Last resort: dismiss CallKit and clean up - _callKitService.endCall(currentCall!.callId); - _teardownCall(); - } - }); - }); - } - - // Wait for caller to send SDP offer via OnOfferAsync event - log('โณ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL] Error starting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('โŒ Error starting call: $e'); - } - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - - // Show appropriate error dialog - if (context.mounted) { - if (e.toString().contains('WebRTC') || e.toString().contains('getUserMedia')) { - CallErrorHandler.showWebRTCInitFailed(context); - } else if (e.toString().contains('SignalR') || e.toString().contains('connection')) { - CallErrorHandler.showSignalRNotConnected(context); - } else { - CallErrorHandler.showGenericError(context, 'Failed to start call. Please try again.'); - } - } - } - } - - /// Play outgoing ringtone - Future _playOutgoingRingtone() async { - if (_isRingingPlaying) { - log('โš ๏ธ [RINGTONE] Already playing', name: 'ChatProvider'); return; } + // Initialize CallManager if not already initialized try { - log('๐Ÿ”” [RINGTONE] Starting outgoing ringtone...', name: 'ChatProvider'); - await _ringingPlayer.setAsset('assets/audio/outgoing_ringtone.mp3'); - await _ringingPlayer.setLoopMode(LoopMode.one); // Loop the ringtone - await _ringingPlayer.play(); - _isRingingPlaying = true; - log('โœ… [RINGTONE] Outgoing ringtone playing', name: 'ChatProvider'); - if (kDebugMode) { - print('๐Ÿ”” Outgoing ringtone playing'); - } + await CallManager().initialize( + userId: chatLoginResponse!.userId.toString(), + authToken: chatLoginResponse!.token ?? '', + conversationId: chatParticipantModel?.id?.toString(), + moduleId: moduleID.toString(), + referenceId: referenceID?.toString(), + employeeNumber: sender!.employeeNumber, + ); } catch (e) { - log('โŒ [RINGTONE] Error playing ringtone: $e', name: 'ChatProvider'); - if (kDebugMode) { - print('โŒ Error playing ringtone: $e'); - } + log('โš ๏ธ [ChatProvider] CallManager already initialized or error: $e', name: 'ChatProvider'); } - } - /// Stop outgoing ringtone - Future _stopOutgoingRingtone() async { - if (!_isRingingPlaying) { - return; - } + // Delegate to CallManager + await CallManager().startCall( + peerId: recipient.employeeNumber ?? '', + peerName: recipient.userName ?? 'Unknown', + callType: callType, + peerAvatar: recipient.image, + ); - try { - log('๐Ÿ”• [RINGTONE] Stopping outgoing ringtone...', name: 'ChatProvider'); - await _ringingPlayer.stop(); - await _ringingPlayer.pause(); - await _ringingPlayer.seek(Duration.zero); - _isRingingPlaying = false; - log('โœ… [RINGTONE] Ringtone stopped', name: 'ChatProvider'); - if (kDebugMode) { - print('๐Ÿ”• Ringtone stopped'); - } - } catch (e) { - log('โš ๏ธ [RINGTONE] Error stopping ringtone: $e', name: 'ChatProvider'); - // Force stop even on error - _isRingingPlaying = false; - } + notifyListeners(); } - /// Toggle microphone mute/unmute + /// Toggle mute - delegates to CallManager Future toggleMute() async { - log('๐ŸŽค [CALL CONTROL] toggleMute() called', name: 'ChatProvider'); - log('๐ŸŽค [CALL CONTROL] Current mute state: $isMuted', name: 'ChatProvider'); - - if (_webrtcService == null) { - log('โš ๏ธ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); - return; - } - - // Toggle local mute state - isMuted = !isMuted; + await CallManager().toggleMute(); notifyListeners(); - - log('โœ… [CALL CONTROL] Mute state changed to: $isMuted', name: 'ChatProvider'); - - // Update WebRTC audio track - _webrtcService!.setMicrophoneMuted(isMuted); - - // Notify peer via SignalR - if (chatHubConnection?.state == HubConnectionState.Connected && - sender != null && - currentCall != null) { - try { - log('๐Ÿ”” [CALL CONTROL] Notifying peer of mute toggle...', name: 'ChatProvider'); - await chatHubConnection!.invoke( - 'AudioToggle', - args: [sender!.employeeNumber ?? '', currentCall!.peerId], - ); - log('โœ… [CALL CONTROL] Peer notified of mute toggle', name: 'ChatProvider'); - } catch (e) { - log('โŒ [CALL CONTROL] Error notifying peer of mute: $e', name: 'ChatProvider'); - } - } - - if (kDebugMode) { - print('๐ŸŽค Microphone ${isMuted ? "muted" : "unmuted"}'); - } } - /// Toggle speakerphone on/off + /// Toggle speaker - delegates to CallManager Future toggleSpeaker() async { - log('๐Ÿ”Š [CALL CONTROL] toggleSpeaker() called', name: 'ChatProvider'); - log('๐Ÿ”Š [CALL CONTROL] Current speaker state: $isSpeakerOn', name: 'ChatProvider'); - - if (_webrtcService == null) { - log('โš ๏ธ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); - return; - } - - // Toggle speaker state - isSpeakerOn = !isSpeakerOn; + await CallManager().toggleSpeaker(); notifyListeners(); - - log('โœ… [CALL CONTROL] Speaker state changed to: $isSpeakerOn', name: 'ChatProvider'); - - // Update audio output - await _webrtcService!.setSpeakerphoneEnabled(isSpeakerOn); - - if (kDebugMode) { - print('๐Ÿ”Š Speakerphone ${isSpeakerOn ? "on" : "off"}'); - } } - /// Toggle camera on/off (video calls only) + /// Toggle camera - delegates to CallManager Future toggleCamera() async { - log('๐Ÿ“น [CALL CONTROL] toggleCamera() called', name: 'ChatProvider'); - log('๐Ÿ“น [CALL CONTROL] Current camera state: $isCameraOn', name: 'ChatProvider'); - - if (_webrtcService == null) { - log('โš ๏ธ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); - return; - } - - if (currentCall?.type != CallType.video) { - log('โš ๏ธ [CALL CONTROL] Not a video call', name: 'ChatProvider'); - return; - } - - // Toggle local camera state - isCameraOn = !isCameraOn; + await CallManager().toggleCamera(); notifyListeners(); - - log('โœ… [CALL CONTROL] Camera state changed to: $isCameraOn', name: 'ChatProvider'); - - // Update WebRTC video track - _webrtcService!.setCameraEnabled(isCameraOn); - - // Notify peer via SignalR - if (chatHubConnection?.state == HubConnectionState.Connected && - sender != null && - currentCall != null) { - try { - log('๐Ÿ”” [CALL CONTROL] Notifying peer of camera toggle...', name: 'ChatProvider'); - await chatHubConnection!.invoke( - 'CameraToggle', - args: [sender!.employeeNumber ?? '', currentCall!.peerId], - ); - log('โœ… [CALL CONTROL] Peer notified of camera toggle', name: 'ChatProvider'); - } catch (e) { - log('โŒ [CALL CONTROL] Error notifying peer of camera toggle: $e', name: 'ChatProvider'); - } - } - - if (kDebugMode) { - print('๐Ÿ“น Camera ${isCameraOn ? "on" : "off"}'); - } } - /// Switch between front and rear camera (video calls only) + /// Switch camera - delegates to CallManager Future switchCamera() async { - log('๐Ÿ”„ [CALL CONTROL] switchCamera() called', name: 'ChatProvider'); - - if (_webrtcService == null) { - log('โš ๏ธ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider'); - return; - } - - if (currentCall?.type != CallType.video) { - log('โš ๏ธ [CALL CONTROL] Not a video call', name: 'ChatProvider'); - return; - } - - if (!isCameraOn) { - log('โš ๏ธ [CALL CONTROL] Camera is off, cannot switch', name: 'ChatProvider'); - return; - } - - try { - await _webrtcService!.switchCamera(); - log('โœ… [CALL CONTROL] Camera switched', name: 'ChatProvider'); - - if (kDebugMode) { - print('๐Ÿ”„ Camera switched'); - } - } catch (e) { - log('โŒ [CALL CONTROL] Error switching camera: $e', name: 'ChatProvider'); - if (kDebugMode) { - print('โŒ Error switching camera: $e'); - } - } + await CallManager().switchCamera(); } - /// End the current call (hang up) + /// Hang up - delegates to CallManager Future hangUp() async { - log('๐Ÿ“ž [CALL CONTROL] hangUp() called', name: 'ChatProvider'); - log('๐Ÿ“ž [CALL CONTROL] Current status: $callStatus', name: 'ChatProvider'); - log('๐Ÿ“ž [CALL CONTROL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); - - if (currentCall == null) { - log('โš ๏ธ [CALL CONTROL] No active call to hang up', name: 'ChatProvider'); - return; - } - - // End CallKit UI first - try { - await _callKitService.endCall(currentCall!.callId); - log('โœ… [CallKit] Native call UI ended', name: 'ChatProvider'); - } catch (e) { - log('โš ๏ธ [CallKit] Error ending native UI: $e', name: 'ChatProvider'); - } - - // Stop ringtone if playing - await _stopOutgoingRingtone(); - - try { - // Invoke SignalR HangUpAsync - if (chatHubConnection?.state == HubConnectionState.Connected && sender != null) { - log('๐Ÿ”” [CALL CONTROL] Invoking HangUpAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: $referenceID', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider'); - - await chatHubConnection!.invoke( - 'HangUpAsync', - args: [ - sender!.employeeNumber ?? '', - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ); - log('โœ… [CALL CONTROL] HangUpAsync invoked successfully', name: 'ChatProvider'); - } - } catch (e, stackTrace) { - log('โŒ [CALL CONTROL] Error invoking HangUpAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('โš ๏ธ Error hanging up call: $e'); - } - } - - // Teardown call resources - _teardownCall(); - - if (kDebugMode) { - print('๐Ÿ“ž Call ended'); - } - } - - /// Handle call timeout (60s no answer) - void _handleCallTimeout() { - log('โฑ๏ธ [CALL] _handleCallTimeout() - Call timeout after 60s', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Current status: $callStatus', name: 'ChatProvider'); - - if (kDebugMode) { - print('โฑ๏ธ Call timeout - no answer'); - } - - // Invoke CallMissedAsync - log('๐Ÿ”ต [CALL] Invoking CallMissedAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall?.peerId}', name: 'ChatProvider'); - log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: ${referenceID?.toString() ?? "null"}', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id?.toString() ?? "null"}', name: 'ChatProvider'); - - chatHubConnection?.invoke( - 'CallMissedAsync', - args: [ - sender?.employeeNumber ?? '', - currentCall?.peerId ?? '', - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ).then((_) { - log('โœ… [CALL] CallMissedAsync invoked successfully', name: 'ChatProvider'); - }).catchError((e) { - log('โŒ [CALL] Error invoking CallMissedAsync: $e', name: 'ChatProvider'); - }); - - _teardownCall(); - } - - /// Clean up call resources - void _teardownCall() { - log('๐Ÿงน [CALL] _teardownCall() - Cleaning up call resources', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Previous status: $callStatus', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Call duration: ${callDuration.inSeconds}s', name: 'ChatProvider'); - - // End CallKit UI if we have an active call - if (currentCall != null) { - try { - _callKitService.endCall(currentCall!.callId); - log('โœ… [CallKit] Native call UI ended in teardown', name: 'ChatProvider'); - } catch (e) { - log('โš ๏ธ [CallKit] Error ending native UI in teardown: $e', name: 'ChatProvider'); - } - } - - // Stop outgoing ringtone if playing - _stopOutgoingRingtone(); - - _callTimeoutTimer?.cancel(); - log('๐Ÿ”ต [CALL] Timeout timer cancelled', name: 'ChatProvider'); - - _callDurationTimer?.cancel(); - log('๐Ÿ”ต [CALL] Duration timer cancelled', name: 'ChatProvider'); - - // Dispose WebRTC service to release media resources - if (_webrtcService != null) { - log('๐Ÿ”ง [CALL] Disposing WebRTC service...', name: 'ChatProvider'); - _webrtcService!.dispose().then((_) { - log('โœ… [CALL] WebRTC service disposed', name: 'ChatProvider'); - }).catchError((e) { - log('โš ๏ธ [CALL] Error disposing WebRTC service: $e', name: 'ChatProvider'); - }); - _webrtcService = null; - } - - callStatus = CallStatus.idle; - currentCall = null; - callDuration = Duration.zero; - isMuted = false; - isSpeakerOn = false; - isCameraOn = true; - isPeerMuted = false; - isPeerCameraOn = true; - _remoteMediaStream = null; - + await CallManager().hangUp(); notifyListeners(); - log('โœ… [CALL] Call resources cleaned up - status reset to idle', name: 'ChatProvider'); - - if (kDebugMode) { - print('๐Ÿงน Call resources cleaned up'); - } - } - - /// Start call duration timer when connected - void _startCallDurationTimer() { - log('โฑ๏ธ [CALL] _startCallDurationTimer() - Starting call duration counter', name: 'ChatProvider'); - _callDurationTimer?.cancel(); - callDuration = Duration.zero; - - _callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - callDuration = Duration(seconds: callDuration.inSeconds + 1); - notifyListeners(); - }); - log('โœ… [CALL] Duration timer started', name: 'ChatProvider'); - - if (kDebugMode) { - print('โฑ๏ธ Call duration timer started'); - } } - /// Accept incoming call + /// Accept call - delegates to CallManager Future acceptCall() async { - log('๐Ÿ”ต [CALL] acceptCall() called', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); - log('๐Ÿ”ต [CALL] Current status: $callStatus', name: 'ChatProvider'); - - if (currentCall == null || callStatus != CallStatus.incomingRinging) { - log('โš ๏ธ [CALL] Cannot accept - no incoming call or wrong status', name: 'ChatProvider'); - return; - } - - // IMPORTANT: Dismiss CallKit UI first - try { - await _callKitService.setCallConnected(currentCall!.callId); - log('โœ… [CallKit] Native UI updated to connected state', name: 'ChatProvider'); - } catch (e) { - log('โš ๏ธ [CallKit] Error updating to connected state: $e', name: 'ChatProvider'); - } - - // Get context - we'll retry if null - BuildContext? context = navigatorKey.currentContext; - - try { - // Check microphone permission - log('๐Ÿ”ต [CALL] Checking microphone permission...', name: 'ChatProvider'); - final micStatus = await Permission.microphone.request(); - if (!micStatus.isGranted) { - log('โŒ [CALL] Microphone permission denied - declining call', name: 'ChatProvider'); - await declineCall('permission_denied'); - if (context != null && context.mounted) { - CallErrorHandler.showMicrophonePermissionDenied(context); - } - return; - } - - // Check camera permission for video calls - if (currentCall!.type == CallType.video) { - log('๐Ÿ”ต [CALL] Checking camera permission...', name: 'ChatProvider'); - final cameraStatus = await Permission.camera.request(); - if (!cameraStatus.isGranted) { - log('โŒ [CALL] Camera permission denied - declining call', name: 'ChatProvider'); - await declineCall('permission_denied'); - if (context != null && context.mounted) { - CallErrorHandler.showCameraPermissionDenied(context); - } - return; - } - } - - // Cancel timeout timer - _callTimeoutTimer?.cancel(); - - // Update status to connecting - callStatus = CallStatus.connecting; - notifyListeners(); - log('๐Ÿ”ต [CALL] Status changed to: connecting', name: 'ChatProvider'); - - // Invoke AnswerCallAsync on SignalR FIRST - if (chatHubConnection?.state != HubConnectionState.Connected) { - log('โŒ [CALL] SignalR not connected', name: 'ChatProvider'); - _teardownCall(); - if (context != null && context.mounted) { - CallErrorHandler.showSignalRNotConnected(context); - } - return; - } - - final myEmployeeNumber = sender?.employeeNumber; - if (myEmployeeNumber == null || myEmployeeNumber.isEmpty) { - log('โŒ [CALL] No employee number found', name: 'ChatProvider'); - _teardownCall(); - if (context != null && context.mounted) { - CallErrorHandler.showGenericError(context, 'Unable to accept call. Please try again.'); - } - return; - } - - log('๐Ÿ”ต [CALL] Invoking AnswerCallAsync with args:', name: 'ChatProvider'); - log(' - source: $myEmployeeNumber', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - - try { - await chatHubConnection!.invoke( - 'AnswerCallAsync', - args: [ - myEmployeeNumber, - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ); - log('โœ… [CALL] AnswerCallAsync invoked successfully', name: 'ChatProvider'); - } catch (e) { - log('โŒ [CALL] Failed to invoke AnswerCallAsync: $e', name: 'ChatProvider'); - _teardownCall(); - if (context != null && context.mounted) { - CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.'); - } - return; - } - - // Initialize WebRTC and setup callbacks - log('๐Ÿ”ง [CALL] Initializing WebRTC for incoming call...', name: 'ChatProvider'); - - try { - _webrtcService = WebRTCService(); - _setupWebRTCCallbacks(); - - if (currentCall!.type == CallType.audio) { - await _webrtcService!.initializeForAudioCall(); - } else { - await _webrtcService!.initializeForVideoCall(); - } - - log('โœ… [CALL] WebRTC initialized', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - - // Cleanup and notify peer - await chatHubConnection?.invoke('HangUpAsync', args: [ - myEmployeeNumber, - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ]).catchError((err) { - log('โŒ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider'); - }); - - _teardownCall(); - // Retry getting context - context = navigatorKey.currentContext; - if (context != null && context.mounted) { - CallErrorHandler.showWebRTCInitFailed(context); - } - return; - } - - // Wait a moment for the app to come to foreground if needed - await Future.delayed(const Duration(milliseconds: 500)); - - // Retry getting context after delay - context = navigatorKey.currentContext; - - // Navigate to call screen - if (context != null && context.mounted) { - log('๐Ÿ”ต [CALL] Navigating to call screen...', name: 'ChatProvider'); - - // Double check currentCall is still valid before navigation - if (currentCall == null) { - log('โŒ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider'); - return; - } - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => currentCall!.type == CallType.audio - ? const AudioCallPage() - : const VideoCallPage(), - ), - ).then((_) { - log('โœ… [CALL] Navigation completed', name: 'ChatProvider'); - }).catchError((e) { - log('โŒ [CALL] Navigation error: $e', name: 'ChatProvider'); - }); - - log('โœ… [CALL] Navigated to call screen', name: 'ChatProvider'); - } else { - log('โš ๏ธ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider'); - - // Alternative: Use WidgetsBinding to schedule navigation after frame - WidgetsBinding.instance.addPostFrameCallback((_) { - // Wait for app to fully come to foreground - Future.delayed(const Duration(milliseconds: 500), () { - final ctx = navigatorKey.currentContext; - if (ctx != null && ctx.mounted) { - log('๐Ÿ”ต [CALL] Navigating via post-frame callback...', name: 'ChatProvider'); - Navigator.of(ctx).push( - MaterialPageRoute( - builder: (context) => currentCall!.type == CallType.audio - ? const AudioCallPage() - : const VideoCallPage(), - ), - ).then((_) { - log('โœ… [CALL] Post-frame navigation completed', name: 'ChatProvider'); - }).catchError((e) { - log('โŒ [CALL] Post-frame navigation error: $e', name: 'ChatProvider'); - }); - } else { - log('โŒ [CALL] Still no context available - navigation failed', name: 'ChatProvider'); - // Last resort: dismiss CallKit and clean up - _callKitService.endCall(currentCall!.callId); - _teardownCall(); - } - }); - }); - } - - // Wait for caller to send SDP offer via OnOfferAsync event - log('โณ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL] Error accepting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('โš ๏ธ Error accepting call: $e'); - } - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - - context = navigatorKey.currentContext; - if (context != null && context.mounted) { - CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.'); - } - } - } - - /// Decline an incoming call - Future declineCall(String reason) async { - log('๐Ÿ”ด [CALL] declineCall() called - reason: $reason', name: 'ChatProvider'); - log('๐Ÿ”ด [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider'); - log('๐Ÿ”ด [CALL] Current status: $callStatus', name: 'ChatProvider'); - - if (currentCall == null || callStatus != CallStatus.incomingRinging) { - log('โš ๏ธ [CALL] Cannot decline - no incoming call or wrong status', name: 'ChatProvider'); - return; - } - - try { - // Invoke SignalR CallDeclinedAsync - log('๐Ÿ”ด [CALL] Invoking CallDeclinedAsync with args:', name: 'ChatProvider'); - log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider'); - log(' - target: ${currentCall!.peerId}', name: 'ChatProvider'); - log(' - moduleCode: $moduleID', name: 'ChatProvider'); - log(' - referenceId: $referenceID', name: 'ChatProvider'); - log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider'); - - await chatHubConnection?.invoke( - 'CallDeclinedAsync', - args: [ - sender?.employeeNumber ?? '', - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ); - log('โœ… [CALL] CallDeclinedAsync invoked successfully', name: 'ChatProvider'); - - await _stopOutgoingRingtone(); - } catch (e, stackTrace) { - log('โŒ [CALL] Error invoking CallDeclinedAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - if (kDebugMode) { - print('โš ๏ธ Error declining call: $e'); - } - } - - _teardownCall(); - } - - /// Register all call event handlers from SignalR - void _registerCallHandlers() { - if (chatHubConnection == null) { - log('โš ๏ธ [CALL] Cannot register call handlers - connection is null', name: 'ChatProvider'); - return; - } - - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'ChatProvider'); - log('๐Ÿ“ž [CALL] Starting call handler registration...', name: 'ChatProvider'); - - chatHubConnection!.on('OnIncomingCallAsync', _handleIncomingCall); - chatHubConnection!.on('OnCallAcceptedAsync', _handleCallAccepted); - chatHubConnection!.on('OnCallDeclinedAsync', _handleCallDeclined); - chatHubConnection!.on('OnHangUpAsync', _handleHangUp); - chatHubConnection!.on('OnOfferAsync', _handleOffer); - chatHubConnection!.on('OnAnswerOfferAsync', _handleAnswer); - chatHubConnection!.on('OnIceCandidateAsync', _handleIceCandidate); - chatHubConnection!.on('OnAudioToggle', _handleAudioToggle); - chatHubConnection!.on('OnCameraToggle', _handleCameraToggle); - - _callHandlersRegistered = true; + await CallManager().acceptCall(); notifyListeners(); - - log('โœ… [CALL] All call handlers registered successfully', name: 'ChatProvider'); - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'ChatProvider'); - - if (kDebugMode) { - print('โœ… Call handlers registered'); - } - } - - /// Setup WebRTC callbacks - void _setupWebRTCCallbacks() { - if (_webrtcService == null) { - log('โš ๏ธ [CALL] Cannot setup callbacks - WebRTC service is null', name: 'ChatProvider'); - return; - } - - log('๐Ÿ”ง [CALL] Setting up WebRTC callbacks...', name: 'ChatProvider'); - - _webrtcService!.onIceCandidate = (RTCIceCandidate candidate) { - log('๐ŸงŠ [CALL] Local ICE candidate generated', name: 'ChatProvider'); - - if (chatHubConnection?.state == HubConnectionState.Connected && sender != null && currentCall != null) { - final candidateJson = jsonEncode({ - 'candidate': candidate.candidate, - 'sdpMid': candidate.sdpMid, - 'sdpMLineIndex': candidate.sdpMLineIndex, - }); - - chatHubConnection!.invoke( - 'IceCandidateAsync', - args: [currentCall!.peerId, candidateJson, currentCall!.sessionId ?? ''], - ); - } - }; - - _webrtcService!.onRemoteStream = (MediaStream stream) { - log('๐Ÿ“ก [CALL] Remote stream received in ChatProvider callback', name: 'ChatProvider'); - log('๐Ÿ“ก [CALL] Remote stream has ${stream.getVideoTracks().length} video tracks', name: 'ChatProvider'); - log('๐Ÿ“ก [CALL] Remote stream has ${stream.getAudioTracks().length} audio tracks', name: 'ChatProvider'); - _remoteMediaStream = stream; - - // Notify listeners to update UI when remote stream is received - notifyListeners(); - log('โœ… [CALL] UI notified about remote stream', name: 'ChatProvider'); - }; - - _webrtcService!.onIceConnectionStateChange = (RTCIceConnectionState state) { - log('๐Ÿ”— [CALL] ICE state: ${state.toString()}', name: 'ChatProvider'); - - if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) { - _stopOutgoingRingtone(); - - // Cancel connection timeout since we're now connected - _callTimeoutTimer?.cancel(); - - if (callStatus != CallStatus.connected) { - callStatus = CallStatus.connected; - notifyListeners(); - _startCallDurationTimer(); - } - } - else if (state == RTCIceConnectionState.RTCIceConnectionStateChecking) { - log('๐Ÿ” [CALL] ICE checking - establishing connection...', name: 'ChatProvider'); - // Start a longer timeout for connection establishment (30 seconds) - _startConnectionTimeout(); - } - else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) { - log('โŒ [CALL] ICE connection failed - ending call', name: 'ChatProvider'); - // Connection failed, terminate the call - _handleConnectionFailure(); - } - else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) { - log('โš ๏ธ [CALL] ICE connection disconnected - waiting for reconnection', name: 'ChatProvider'); - // Start a timeout to end call if it doesn't reconnect within 10 seconds - _startReconnectionTimeout(); - } - }; - - log('โœ… [CALL] WebRTC callbacks setup complete', name: 'ChatProvider'); } - /// Create and send SDP offer - Future _createAndSendOffer() async { - try { - log('๐Ÿ”ง [CALL] Creating SDP offer...', name: 'ChatProvider'); - - if (_webrtcService == null || currentCall == null) { - throw Exception('WebRTC service or call session is null'); - } - - final offer = await _webrtcService!.createOffer(); - log('โœ… [CALL] SDP offer created', name: 'ChatProvider'); - - await chatHubConnection!.invoke( - 'OfferAsync', - args: [currentCall!.peerId, offer.sdp ?? '', currentCall!.callId], - ); - log('โœ… [CALL] SDP offer sent', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL] Error creating/sending offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - _teardownCall(); - } - } - - // ==================== CALL EVENT HANDLERS ==================== - - void _handleIncomingCall(List? args) async { - log('๐Ÿ“ž [CALL EVENT] OnIncomingCallAsync received', name: 'ChatProvider'); - - if (args == null || args.isEmpty) return; - - try { - final callData = args[0] as Map; - final callerId = callData['sourceUserId'] as String?; - final callerName = callData['userName'] as String? ?? 'Unknown'; - final isVideoCall = callData['isVideoCall'] as bool? ?? false; - final sdpOffer = callData['sdpOffer'] as String?; - - // Check if already in a call - decline if busy - if (callStatus != CallStatus.idle) { - log('โš ๏ธ [CALL] Already in a call, declining incoming call', name: 'ChatProvider'); - chatHubConnection?.invoke('CallDeclinedAsync', args: [ - sender?.employeeNumber ?? '', - callerId ?? '', - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ]); - return; - } - - // Generate unique call ID - final callId = const Uuid().v4(); - - // Create call session - currentCall = CallSession( - callId: callId, - type: isVideoCall ? CallType.video : CallType.audio, - direction: CallDirection.incoming, - peerId: callerId ?? '', - peerName: callerName, - peerAvatar: null, - startTime: DateTime.now(), - sdpOffer: sdpOffer, - ); - - callStatus = CallStatus.incomingRinging; - notifyListeners(); - - log('๐Ÿ“ž [CallKit] Showing native incoming call UI...', name: 'ChatProvider'); - log(' Caller: $callerName', name: 'ChatProvider'); - log(' Caller ID: $callerId', name: 'ChatProvider'); - log(' Video: $isVideoCall', name: 'ChatProvider'); - log(' Call ID: $callId', name: 'ChatProvider'); - - // Initialize CallKit if not already initialized - if (!_callKitInitialized) { - await _initializeCallKit(); - } - - // Show native incoming call UI using CallKit - try { - await _callKitService.showIncomingCall( - callId: callId, - callerName: callerName, - callerNumber: callerId ?? '', - callerAvatar: null, // TODO: Get avatar from participant data if available - isVideo: isVideoCall, - extra: { - 'peerId': callerId ?? '', - 'moduleId': moduleID.toString(), - 'referenceId': referenceID?.toString() ?? '', - 'conversationId': chatParticipantModel?.id?.toString() ?? '', - }, - ); - log('โœ… [CallKit] Native incoming call UI displayed', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CallKit] Error showing native UI, falling back to dialog: $e', - name: 'ChatProvider', error: e, stackTrace: stackTrace); - - // Fallback to custom dialog if CallKit fails - final context = navigatorKey.currentContext; - if (context != null) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => IncomingCallDialog(call: currentCall!), - ); - } - } - - // Start call timeout timer (30 seconds for incoming calls) - _callTimeoutTimer?.cancel(); - _callTimeoutTimer = Timer(const Duration(seconds: 30), () { - if (callStatus == CallStatus.incomingRinging) { - log('โฑ๏ธ [CALL] Incoming call timeout - no answer after 30s', name: 'ChatProvider'); - - // End CallKit UI - _callKitService.endCall(callId); - - // Cleanup - _teardownCall(); - } - }); - log('โฑ๏ธ [CALL] Incoming call timeout started (30s)', name: 'ChatProvider'); - - } catch (e, stackTrace) { - log('โŒ [CALL EVENT] Error handling incoming call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - - // Cleanup on error - callStatus = CallStatus.idle; - currentCall = null; - notifyListeners(); - } - } - - void _handleCallAccepted(List? args) { - log('๐Ÿ“ž [CALL EVENT] OnCallAcceptedAsync received', name: 'ChatProvider'); - - if (callStatus != CallStatus.outgoingRinging) return; - - _callTimeoutTimer?.cancel(); - callStatus = CallStatus.connecting; - notifyListeners(); - - _createAndSendOffer(); - } - - void _handleCallDeclined(List? args) { - log('๐Ÿ“ž [CALL EVENT] OnCallDeclinedAsync received', name: 'ChatProvider'); - _teardownCall(); - } - - void _handleHangUp(List? args) { - log('๐Ÿ“ž [CALL EVENT] OnHangUpAsync received', name: 'ChatProvider'); - _teardownCall(); - } - - void _handleOffer(List? args) async { - log('๐Ÿ“ž [CALL EVENT] OnOfferAsync received', name: 'ChatProvider'); - - if (args == null || args.isEmpty) return; - - try { - final offerSdp = args[0] as String?; - if (offerSdp == null || _webrtcService == null) return; - - final answer = await _webrtcService!.createAnswer(offerSdp); - - await chatHubConnection!.invoke( - 'AnswerOfferAsync', - args: [currentCall!.peerId, answer.sdp ?? '', currentCall!.callId], - ); - log('โœ… [CALL EVENT] SDP answer sent', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL EVENT] Error handling offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - } - } - - void _handleAnswer(List? args) async { - log('๐Ÿ“ž [CALL EVENT] OnAnswerOfferAsync received', name: 'ChatProvider'); - - if (args == null || args.isEmpty) return; - - try { - final answerSdp = args[0] as String?; - if (answerSdp == null || _webrtcService == null) return; - - await _webrtcService!.setRemoteAnswer(answerSdp); - log('โœ… [CALL EVENT] Remote answer set', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL EVENT] Error handling answer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - } - } - - void _handleIceCandidate(List? args) async { - log('๐Ÿ“ž [CALL EVENT] OnIceCandidateAsync received', name: 'ChatProvider'); - - if (args == null || args.isEmpty) { - log('โš ๏ธ [CALL EVENT] ICE candidate args are null or empty', name: 'ChatProvider'); - return; - } - - try { - final candidateJson = args[0] as String?; - if (candidateJson == null) { - log('โš ๏ธ [CALL EVENT] ICE candidate JSON is null', name: 'ChatProvider'); - return; - } - - if (_webrtcService == null) { - log('โš ๏ธ [CALL EVENT] WebRTC service not initialized yet, ignoring ICE candidate', name: 'ChatProvider'); - return; - } - - final candidateData = jsonDecode(candidateJson) as Map; - final candidate = RTCIceCandidate( - candidateData['candidate'] as String?, - candidateData['sdpMid'] as String?, - candidateData['sdpMLineIndex'] as int?, - ); - - log('๐ŸงŠ [CALL EVENT] Parsed ICE candidate: ${candidateData['candidate']}', name: 'ChatProvider'); - await _webrtcService!.addIceCandidate(candidate); - log('โœ… [CALL EVENT] ICE candidate added successfully', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CALL EVENT] Error handling ICE candidate: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); - // Don't rethrow - ICE candidate errors shouldn't crash the app - } - } - - void _handleAudioToggle(List? args) { - log('๐Ÿ“ž [CALL EVENT] OnAudioToggle received', name: 'ChatProvider'); - isPeerMuted = !isPeerMuted; + /// Decline call - delegates to CallManager + Future declineCall(String reason) async { + await CallManager().declineCall(reason); notifyListeners(); } - void _handleCameraToggle(List? args) { - log('๐Ÿ“ž [CALL EVENT] OnCameraToggle received', name: 'ChatProvider'); - isPeerCameraOn = !isPeerCameraOn; - notifyListeners(); + /// Register call event handlers - kept for backwards compatibility + void _registerCallHandlers() { + // Call handlers are now managed by CallManager + // This method is kept for backwards compatibility but does nothing + log('โ„น๏ธ [ChatProvider] Call handlers are managed by CallManager', name: 'ChatProvider'); } + /// Handle call history update void _onCallHistoryUpdated(List? args) async { log('๐Ÿ“ž [CALL HISTORY] OnCallHistoryUpdated received', name: 'ChatProvider'); @@ -2092,153 +970,4 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin { log('โŒ [CALL HISTORY] Error reloading chat history: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace); } } - - /// Handle connection failure (ICE connection failed) - void _handleConnectionFailure() { - log('โŒ [CALL] Connection failure detected', name: 'ChatProvider'); - - final context = navigatorKey.currentContext; - - // Notify user about connection failure - if (context != null && context.mounted) { - CallErrorHandler.showConnectionFailed(context); - } - - // Send hangup signal to peer - if (chatHubConnection?.state == HubConnectionState.Connected && - sender != null && - currentCall != null) { - chatHubConnection!.invoke( - 'HangUpAsync', - args: [ - sender!.employeeNumber ?? '', - currentCall!.peerId, - moduleID.toString(), - referenceID?.toString() ?? '', - chatParticipantModel?.id?.toString() ?? '', - ], - ).catchError((e) { - log('โŒ [CALL] Failed to send hangup after connection failure: $e', name: 'ChatProvider'); - }); - } - - // Clean up call resources - _teardownCall(); - - if (kDebugMode) { - print('โŒ Call ended due to connection failure'); - } - } - - /// Start timeout for ICE connection establishment (60 seconds - increased for TURN relay) - void _startConnectionTimeout() { - _callTimeoutTimer?.cancel(); - - _callTimeoutTimer = Timer(const Duration(seconds: 60), () { - log('โฑ๏ธ [CALL] Connection timeout - ICE failed to establish within 60s', name: 'ChatProvider'); - - if (callStatus == CallStatus.connecting) { - log('โŒ [CALL] Ending call due to connection timeout', name: 'ChatProvider'); - _handleConnectionFailure(); - } - }); - - log('โฑ๏ธ [CALL] Connection timeout started (60s - extended for TURN relay)', name: 'ChatProvider'); - } - - /// Start timeout for reconnection (10 seconds) - void _startReconnectionTimeout() { - _callTimeoutTimer?.cancel(); - - _callTimeoutTimer = Timer(const Duration(seconds: 10), () { - log('โฑ๏ธ [CALL] Reconnection timeout - connection did not recover', name: 'ChatProvider'); - - if (callStatus != CallStatus.connected && callStatus != CallStatus.idle) { - log('โŒ [CALL] Ending call due to reconnection timeout', name: 'ChatProvider'); - _handleConnectionFailure(); - } - }); - - log('โฑ๏ธ [CALL] Reconnection timeout started (10s)', name: 'ChatProvider'); - } - - /// Initialize CallKit service - Future _initializeCallKit() async { - if (_callKitInitialized) return; - - try { - log('๐Ÿ“ž [CallKit] Initializing CallKit service...', name: 'ChatProvider'); - - await _callKitService.initialize(); - - // Setup CallKit callbacks - _callKitService.onCallAccepted = _handleCallKitAccepted; - _callKitService.onCallDeclined = _handleCallKitDeclined; - _callKitService.onCallEnded = _handleCallKitEnded; - _callKitService.onCallTimeout = _handleCallKitTimeout; - - _callKitInitialized = true; - log('โœ… [CallKit] Service initialized successfully', name: 'ChatProvider'); - } catch (e, stackTrace) { - log('โŒ [CallKit] Error initializing: $e', - name: 'ChatProvider', error: e, stackTrace: stackTrace); - } - } - - /// Handle CallKit accept event - void _handleCallKitAccepted(String callId) { - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'ChatProvider'); - log('โœ… [CallKit] Call accepted via native UI: $callId', name: 'ChatProvider'); - log('๐Ÿ”ต [CallKit] Current call ID: ${currentCall?.callId}', name: 'ChatProvider'); - log('๐Ÿ”ต [CallKit] Current call status: $callStatus', name: 'ChatProvider'); - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'ChatProvider'); - - // Find the call by ID and accept it - if (currentCall != null && currentCall!.callId == callId) { - log('โœ… [CallKit] Call IDs match - calling acceptCall()', name: 'ChatProvider'); - - // Schedule on next frame to ensure we're on main thread - WidgetsBinding.instance.addPostFrameCallback((_) { - log('๐Ÿ”ต [CallKit] Post-frame callback executing acceptCall()', name: 'ChatProvider'); - acceptCall(); - }); - - // Also call immediately in case we're already on main thread - acceptCall(); - } else { - log('โš ๏ธ [CallKit] Call IDs do NOT match or currentCall is null', name: 'ChatProvider'); - log(' Expected: $callId', name: 'ChatProvider'); - log(' Current: ${currentCall?.callId}', name: 'ChatProvider'); - } - } - - /// Handle CallKit decline event - void _handleCallKitDeclined(String callId) { - log('โŒ [CallKit] Call declined via native UI: $callId', name: 'ChatProvider'); - - // Find the call by ID and decline it - if (currentCall != null && currentCall!.callId == callId) { - declineCall('user_declined_native_ui'); - } - } - - /// Handle CallKit ended event - void _handleCallKitEnded(String callId) { - log('๐Ÿ”ด [CallKit] Call ended via native UI: $callId', name: 'ChatProvider'); - - // End the call - if (currentCall != null && currentCall!.callId == callId) { - hangUp(); - } - } - - /// Handle CallKit timeout event - void _handleCallKitTimeout(String callId) { - log('โฑ๏ธ [CallKit] Call timeout: $callId', name: 'ChatProvider'); - - // Handle timeout - if (currentCall != null && currentCall!.callId == callId) { - _teardownCall(); - } - } } diff --git a/lib/modules/cx_module/chat/services/call_manager.dart b/lib/modules/cx_module/chat/services/call_manager.dart new file mode 100644 index 00000000..89e4481d --- /dev/null +++ b/lib/modules/cx_module/chat/services/call_manager.dart @@ -0,0 +1,995 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:test_sa/main.dart'; +import 'package:test_sa/modules/cx_module/chat/call/audio_call_page.dart'; +import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart'; +import 'package:test_sa/modules/cx_module/chat/call/services/webrtc_service.dart'; +import 'package:test_sa/modules/cx_module/chat/call/services/callkit_service.dart'; +import 'package:test_sa/modules/cx_module/chat/call/call_error_handler.dart'; +import 'package:test_sa/modules/cx_module/chat/model/call_session.dart'; +import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:uuid/uuid.dart'; + +/// CallManager - Singleton service that manages all call responsibilities +/// Works independently of ChatProvider +/// Uses SignalRService for SignalR communication +class CallManager extends ChangeNotifier { + static final CallManager _instance = CallManager._internal(); + factory CallManager() => _instance; + CallManager._internal(); + + // Services + final SignalRService _signalRService = SignalRService(); + final CallKitService _callKitService = CallKitService(); + WebRTCService? _webrtcService; + + // Call state + CallSession? _currentCall; + CallStatus _callStatus = CallStatus.idle; + Duration _callDuration = Duration.zero; + Timer? _callDurationTimer; + Timer? _callTimeoutTimer; + + // Call controls + bool _isMuted = false; + bool _isSpeakerOn = false; + bool _isCameraOn = true; + bool _isPeerMuted = false; + bool _isPeerCameraOn = true; + + // Initialization state + bool _callKitInitialized = false; + bool _handlersRegistered = false; + + // Module context (for SignalR invocations) + String? _moduleId; + String? _referenceId; + String? _conversationId; + String? _myEmployeeNumber; + + // Getters + CallSession? get currentCall => _currentCall; + CallStatus get callStatus => _callStatus; + Duration get callDuration => _callDuration; + bool get isMuted => _isMuted; + bool get isSpeakerOn => _isSpeakerOn; + bool get isCameraOn => _isCameraOn; + bool get isPeerMuted => _isPeerMuted; + bool get isPeerCameraOn => _isPeerCameraOn; + WebRTCService? get webrtcService => _webrtcService; + bool get isCallInProgress => _callStatus != CallStatus.idle; + + /// Initialize CallManager with user credentials + Future initialize({ + required String userId, + required String authToken, + String? conversationId, + String? moduleId, + String? referenceId, + String? employeeNumber, + }) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('๐Ÿ”ง [INIT] Initializing CallManager...', name: 'CallManager'); + log(' User ID: $userId', name: 'CallManager'); + log(' Conversation ID: ${conversationId ?? "none"}', name: 'CallManager'); + + // Store module context + _moduleId = moduleId; + _referenceId = referenceId; + _conversationId = conversationId; + _myEmployeeNumber = employeeNumber; + + // Initialize SignalR connection + log('๐Ÿ”Œ [INIT] Initializing SignalR connection...', name: 'CallManager'); + final connected = await _signalRService.initialize( + userId: userId, + authToken: authToken, + conversationId: conversationId, + ); + + if (!connected) { + throw Exception('Failed to initialize SignalR connection'); + } + log('โœ… [INIT] SignalR connected', name: 'CallManager'); + + // Initialize CallKit + if (!_callKitInitialized) { + await _initializeCallKit(); + } + + // Register call event handlers + if (!_handlersRegistered) { + _registerCallHandlers(); + } + + log('โœ… [INIT] CallManager initialized successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [INIT] Error initializing CallManager: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Initialize CallKit service + Future _initializeCallKit() async { + try { + log('๐Ÿ“ž [CALLKIT] Initializing CallKit...', name: 'CallManager'); + + await _callKitService.initialize(); + + // Setup CallKit callbacks + _callKitService.onCallAccepted = _handleCallKitAccepted; + _callKitService.onCallDeclined = _handleCallKitDeclined; + _callKitService.onCallEnded = _handleCallKitEnded; + _callKitService.onCallTimeout = _handleCallKitTimeout; + + _callKitInitialized = true; + log('โœ… [CALLKIT] CallKit initialized', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [CALLKIT] Error initializing: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + } + } + + /// Register SignalR call event handlers + void _registerCallHandlers() { + log('๐Ÿ”ง [HANDLERS] Registering call event handlers...', name: 'CallManager'); + + _signalRService.on('OnIncomingCallAsync', _handleIncomingCall); + _signalRService.on('OnCallAcceptedAsync', _handleCallAccepted); + _signalRService.on('OnCallDeclinedAsync', _handleCallDeclined); + _signalRService.on('OnHangUpAsync', _handleHangUp); + _signalRService.on('OnOfferAsync', _handleOffer); + _signalRService.on('OnAnswerOfferAsync', _handleAnswer); + _signalRService.on('OnIceCandidateAsync', _handleIceCandidate); + _signalRService.on('OnAudioToggle', _handleAudioToggle); + _signalRService.on('OnCameraToggle', _handleCameraToggle); + + _handlersRegistered = true; + log('โœ… [HANDLERS] All call handlers registered', name: 'CallManager'); + } + + /// Start outgoing call + Future startCall({ + required String peerId, + required String peerName, + required CallType callType, + String? peerAvatar, + }) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('๐Ÿ“ž [OUTGOING] Starting ${callType.name} call to $peerName ($peerId)', name: 'CallManager'); + + // Check if already in call + if (_callStatus != CallStatus.idle) { + log('โš ๏ธ [OUTGOING] Already in a call - CALLER_BUSY', name: 'CallManager'); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showCallAlreadyInProgress(context); + } + return; + } + + // Update status + _updateCallStatus(CallStatus.checkingPermissions); + log('๐Ÿ” [OUTGOING] Checking permissions...', name: 'CallManager'); + + // Check permissions + if (!await _checkPermissions(callType)) { + log('โŒ [OUTGOING] Permissions denied', name: 'CallManager'); + _updateCallStatus(CallStatus.idle); + return; + } + log('โœ… [OUTGOING] Permissions granted', name: 'CallManager'); + + // CRITICAL: Ensure SignalR is connected before every invocation + _updateCallStatus(CallStatus.connecting); + log('๐Ÿ”Œ [OUTGOING] Ensuring SignalR connection...', name: 'CallManager'); + + if (!await _signalRService.ensureConnected()) { + log('โŒ [OUTGOING] SignalR connection failed', name: 'CallManager'); + _updateCallStatus(CallStatus.idle); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showSignalRNotConnected(context); + } + return; + } + log('โœ… [OUTGOING] SignalR connected - state: ${_signalRService.connectionState}', name: 'CallManager'); + + // Generate call ID + final callId = const Uuid().v4(); + log('๐Ÿ†” [OUTGOING] Generated call ID: $callId', name: 'CallManager'); + + // Create call session + _currentCall = CallSession( + callId: callId, + type: callType, + direction: CallDirection.outgoing, + peerId: peerId, + peerName: peerName, + peerAvatar: peerAvatar, + startTime: DateTime.now(), + ); + + // Invoke CallUserAsync + log('๐Ÿ“ค [OUTGOING] Invoking CallUserAsync...', name: 'CallManager'); + log(' From: ${_myEmployeeNumber ?? ""}', name: 'CallManager'); + log(' To: $peerId', name: 'CallManager'); + log(' Video: ${callType == CallType.video}', name: 'CallManager'); + + await _signalRService.invoke( + 'CallUserAsync', + args: [ + _myEmployeeNumber ?? '', + peerId, + callType == CallType.video, + ], + ); + log('โœ… [OUTGOING] CallUserAsync invoked successfully', name: 'CallManager'); + + _updateCallStatus(CallStatus.outgoingRinging); + + // Initialize WebRTC + log('๐Ÿ”ง [OUTGOING] Initializing WebRTC...', name: 'CallManager'); + await _initializeWebRTC(); + log('โœ… [OUTGOING] WebRTC initialized', name: 'CallManager'); + + // Start timeout timer (60 seconds for outgoing calls) + _callTimeoutTimer = Timer(const Duration(seconds: 60), () { + if (_callStatus == CallStatus.outgoingRinging) { + log('โฑ๏ธ [OUTGOING] Call timeout - no answer after 60s', name: 'CallManager'); + _cleanup(); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showGenericError(context, 'Call timeout - no answer'); + } + } + }); + log('โฑ๏ธ [OUTGOING] Timeout timer started (60s)', name: 'CallManager'); + + // Navigate to call screen + _navigateToCallScreen(); + log('โœ… [OUTGOING] Navigated to call screen', name: 'CallManager'); + + log('โœ… [OUTGOING] Call started successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [OUTGOING] Error starting call: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showGenericError(context, 'Failed to start call: $e'); + } + } + } + + /// Handle incoming call from notification + Future handleIncomingCallNotification({ + required String callId, + required String callerId, + required String callerName, + required bool isVideoCall, + Map? extraData, + }) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('๐Ÿ“ž [INCOMING] Handling incoming call notification', name: 'CallManager'); + log(' Call ID: $callId', name: 'CallManager'); + log(' Caller: $callerName ($callerId)', name: 'CallManager'); + log(' Video: $isVideoCall', name: 'CallManager'); + + // Check if already in call + if (_callStatus != CallStatus.idle) { + log('โš ๏ธ [INCOMING] Already in a call - declining with CALLER_BUSY', name: 'CallManager'); + + // Ensure SignalR is connected before declining + if (!await _signalRService.ensureConnected()) { + log('โŒ [INCOMING] Cannot decline - SignalR not connected', name: 'CallManager'); + return; + } + + await _signalRService.invoke('CallDeclinedAsync', args: [ + _myEmployeeNumber ?? '', + callerId, + 'CALLER_BUSY', + ]); + log('โœ… [INCOMING] Declined with CALLER_BUSY', name: 'CallManager'); + return; + } + + // Ensure SignalR is connected + log('๐Ÿ”Œ [INCOMING] Ensuring SignalR connection...', name: 'CallManager'); + if (!await _signalRService.ensureConnected()) { + log('โŒ [INCOMING] SignalR connection failed - cannot handle call', name: 'CallManager'); + return; + } + log('โœ… [INCOMING] SignalR connected', name: 'CallManager'); + + // Create call session + _currentCall = CallSession( + callId: callId, + type: isVideoCall ? CallType.video : CallType.audio, + direction: CallDirection.incoming, + peerId: callerId, + peerName: callerName, + peerAvatar: null, + startTime: DateTime.now(), + ); + + _updateCallStatus(CallStatus.incomingRinging); + log('โœ… [INCOMING] Call session created', name: 'CallManager'); + + // Initialize CallKit if not already initialized + if (!_callKitInitialized) { + await _initializeCallKit(); + } + + // Show CallKit UI + log('๐Ÿ“ฑ [INCOMING] Showing CallKit UI...', name: 'CallManager'); + await _callKitService.showIncomingCall( + callId: callId, + callerName: callerName, + callerNumber: callerId, + isVideo: isVideoCall, + extra: extraData, + ); + log('โœ… [INCOMING] CallKit UI displayed', name: 'CallManager'); + + // Start timeout timer (30 seconds for incoming calls) + _callTimeoutTimer = Timer(const Duration(seconds: 30), () { + if (_callStatus == CallStatus.incomingRinging) { + log('โฑ๏ธ [INCOMING] Call timeout - no answer after 30s', name: 'CallManager'); + _handleCallTimeout(); + } + }); + log('โฑ๏ธ [INCOMING] Timeout timer started (30s)', name: 'CallManager'); + + log('โœ… [INCOMING] Incoming call handled successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [INCOMING] Error handling incoming call: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + /// Handle call timeout (for incoming calls) + Future _handleCallTimeout() async { + try { + log('โฑ๏ธ [TIMEOUT] Handling call timeout...', name: 'CallManager'); + + if (_currentCall == null) return; + + // Invoke CallMissedAsync + if (await _signalRService.ensureConnected()) { + await _signalRService.invoke('CallMissedAsync', args: [ + _myEmployeeNumber ?? '', + _currentCall!.peerId, + ]); + log('โœ… [TIMEOUT] CallMissedAsync invoked', name: 'CallManager'); + } + + _cleanup(); + } catch (e, stackTrace) { + log('โŒ [TIMEOUT] Error: $e', name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + /// Accept incoming call + Future acceptCall() async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('โœ… [ACCEPT] Accepting call...', name: 'CallManager'); + + if (_currentCall == null || _callStatus != CallStatus.incomingRinging) { + log('โš ๏ธ [ACCEPT] No incoming call to accept', name: 'CallManager'); + return; + } + + // Check permissions + log('๐Ÿ” [ACCEPT] Checking permissions...', name: 'CallManager'); + if (!await _checkPermissions(_currentCall!.type)) { + log('โŒ [ACCEPT] Permissions denied', name: 'CallManager'); + await declineCall('permission_denied'); + return; + } + log('โœ… [ACCEPT] Permissions granted', name: 'CallManager'); + + // Ensure SignalR connected + log('๐Ÿ”Œ [ACCEPT] Ensuring SignalR connection...', name: 'CallManager'); + if (!await _signalRService.ensureConnected()) { + log('โŒ [ACCEPT] SignalR not connected', name: 'CallManager'); + _cleanup(); + return; + } + log('โœ… [ACCEPT] SignalR connected', name: 'CallManager'); + + _updateCallStatus(CallStatus.connecting); + + // Cancel timeout timer + _callTimeoutTimer?.cancel(); + + // Invoke AnswerCallAsync + log('๐Ÿ“ค [ACCEPT] Invoking AnswerCallAsync...', name: 'CallManager'); + await _signalRService.invoke('AnswerCallAsync', args: [ + _myEmployeeNumber ?? '', + _currentCall!.peerId, + _moduleId ?? '0', + _referenceId ?? '0', + _conversationId ?? '', + ]); + log('โœ… [ACCEPT] AnswerCallAsync invoked', name: 'CallManager'); + + // Initialize WebRTC + log('๐Ÿ”ง [ACCEPT] Initializing WebRTC...', name: 'CallManager'); + await _initializeWebRTC(); + log('โœ… [ACCEPT] WebRTC initialized', name: 'CallManager'); + + // Navigate to call screen + _navigateToCallScreen(); + log('โœ… [ACCEPT] Navigated to call screen', name: 'CallManager'); + + log('โœ… [ACCEPT] Call accepted successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [ACCEPT] Error accepting call: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + /// Decline incoming call + Future declineCall(String reason) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('โŒ [DECLINE] Declining call - reason: $reason', name: 'CallManager'); + + if (_currentCall == null) { + log('โš ๏ธ [DECLINE] No call to decline', name: 'CallManager'); + return; + } + + // Cancel timeout timer + _callTimeoutTimer?.cancel(); + + // Ensure SignalR connected + if (await _signalRService.ensureConnected()) { + log('๐Ÿ“ค [DECLINE] Invoking CallDeclinedAsync...', name: 'CallManager'); + await _signalRService.invoke('CallDeclinedAsync', args: [ + _myEmployeeNumber ?? '', + _currentCall!.peerId, + reason, + ]); + log('โœ… [DECLINE] CallDeclinedAsync invoked', name: 'CallManager'); + } else { + log('โš ๏ธ [DECLINE] SignalR not connected, skipping backend call', name: 'CallManager'); + } + + _cleanup(); + log('โœ… [DECLINE] Call declined successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [DECLINE] Error declining call: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + /// Hang up call + Future hangUp() async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('๐Ÿ“ž [HANGUP] Hanging up...', name: 'CallManager'); + + if (_currentCall == null) { + log('โš ๏ธ [HANGUP] No call to hang up', name: 'CallManager'); + return; + } + + // Ensure SignalR connected + if (await _signalRService.ensureConnected()) { + log('๐Ÿ“ค [HANGUP] Invoking HangUpAsync...', name: 'CallManager'); + await _signalRService.invoke('HangUpAsync', args: [ + _myEmployeeNumber ?? '', + _currentCall!.peerId, + _moduleId ?? '0', + _referenceId ?? '0', + _conversationId ?? '', + ]); + log('โœ… [HANGUP] HangUpAsync invoked', name: 'CallManager'); + } else { + log('โš ๏ธ [HANGUP] SignalR not connected, skipping backend call', name: 'CallManager'); + } + + _cleanup(); + log('โœ… [HANGUP] Call ended successfully', name: 'CallManager'); + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [HANGUP] Error hanging up: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + /// Toggle mute + Future toggleMute() async { + log('๐ŸŽค [CONTROL] Toggling mute: $_isMuted -> ${!_isMuted}', name: 'CallManager'); + + _isMuted = !_isMuted; + _webrtcService?.setMicrophoneMuted(_isMuted); + + if (await _signalRService.ensureConnected()) { + await _signalRService.invoke('AudioToggle', args: [ + _myEmployeeNumber ?? '', + _currentCall?.peerId ?? '', + ]); + } + + notifyListeners(); + log('โœ… [CONTROL] Mute toggled: $_isMuted', name: 'CallManager'); + } + + /// Toggle speaker + Future toggleSpeaker() async { + log('๐Ÿ”Š [CONTROL] Toggling speaker: $_isSpeakerOn -> ${!_isSpeakerOn}', name: 'CallManager'); + + _isSpeakerOn = !_isSpeakerOn; + await _webrtcService?.setSpeakerphoneEnabled(_isSpeakerOn); + notifyListeners(); + + log('โœ… [CONTROL] Speaker toggled: $_isSpeakerOn', name: 'CallManager'); + } + + /// Toggle camera + Future toggleCamera() async { + if (_currentCall?.type != CallType.video) return; + + log('๐Ÿ“น [CONTROL] Toggling camera: $_isCameraOn -> ${!_isCameraOn}', name: 'CallManager'); + + _isCameraOn = !_isCameraOn; + _webrtcService?.setCameraEnabled(_isCameraOn); + + if (await _signalRService.ensureConnected()) { + await _signalRService.invoke('CameraToggle', args: [ + _myEmployeeNumber ?? '', + _currentCall?.peerId ?? '', + ]); + } + + notifyListeners(); + log('โœ… [CONTROL] Camera toggled: $_isCameraOn', name: 'CallManager'); + } + + /// Switch camera + Future switchCamera() async { + if (_currentCall?.type != CallType.video || !_isCameraOn) return; + + log('๐Ÿ”„ [CONTROL] Switching camera...', name: 'CallManager'); + await _webrtcService?.switchCamera(); + log('โœ… [CONTROL] Camera switched', name: 'CallManager'); + } + + // ==================== Private Methods ==================== + + Future _checkPermissions(CallType callType) async { + log('๐Ÿ” [PERMISSIONS] Checking permissions for ${callType.name} call...', name: 'CallManager'); + + final micStatus = await Permission.microphone.request(); + if (!micStatus.isGranted) { + log('โŒ [PERMISSIONS] Microphone permission denied', name: 'CallManager'); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showMicrophonePermissionDenied(context); + } + return false; + } + log('โœ… [PERMISSIONS] Microphone permission granted', name: 'CallManager'); + + if (callType == CallType.video) { + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + log('โŒ [PERMISSIONS] Camera permission denied', name: 'CallManager'); + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showCameraPermissionDenied(context); + } + return false; + } + log('โœ… [PERMISSIONS] Camera permission granted', name: 'CallManager'); + } + + return true; + } + + Future _initializeWebRTC() async { + log('๐Ÿ”ง [WEBRTC] Initializing WebRTC...', name: 'CallManager'); + + _webrtcService = WebRTCService(); + _setupWebRTCCallbacks(); + + if (_currentCall!.type == CallType.audio) { + log('๐ŸŽต [WEBRTC] Initializing for audio call', name: 'CallManager'); + await _webrtcService!.initializeForAudioCall(); + } else { + log('๐Ÿ“น [WEBRTC] Initializing for video call', name: 'CallManager'); + await _webrtcService!.initializeForVideoCall(); + } + + log('โœ… [WEBRTC] WebRTC initialized', name: 'CallManager'); + } + + void _setupWebRTCCallbacks() { + log('๐Ÿ”ง [WEBRTC] Setting up callbacks...', name: 'CallManager'); + + _webrtcService!.onIceCandidate = (RTCIceCandidate candidate) { + log('๐ŸงŠ [WEBRTC] ICE candidate generated', name: 'CallManager'); + log(' Candidate: ${candidate.candidate?.substring(0, 50)}...', name: 'CallManager'); + + final candidateJson = jsonEncode({ + 'candidate': candidate.candidate, + 'sdpMid': candidate.sdpMid, + 'sdpMLineIndex': candidate.sdpMLineIndex, + }); + + _signalRService.invoke('IceCandidateAsync', args: [ + _currentCall!.peerId, + candidateJson, + _currentCall!.callId, + ]); + }; + + _webrtcService!.onRemoteStream = (MediaStream stream) { + log('๐Ÿ“ก [WEBRTC] Remote stream received', name: 'CallManager'); + log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'CallManager'); + log(' Video tracks: ${stream.getVideoTracks().length}', name: 'CallManager'); + notifyListeners(); + }; + + _webrtcService!.onIceConnectionStateChange = (RTCIceConnectionState state) { + log('๐Ÿ”— [WEBRTC] ICE state: ${state.toString()}', name: 'CallManager'); + + if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) { + log('โœ… [WEBRTC] ICE connection established', name: 'CallManager'); + _updateCallStatus(CallStatus.connected); + _startCallDurationTimer(); + } else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) { + log('โŒ [WEBRTC] ICE connection failed', name: 'CallManager'); + _cleanup(); + } else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) { + log('โš ๏ธ [WEBRTC] ICE connection disconnected', name: 'CallManager'); + } + }; + + log('โœ… [WEBRTC] Callbacks set up', name: 'CallManager'); + } + + void _navigateToCallScreen() { + final context = navigatorKey.currentContext; + if (context == null || _currentCall == null) { + log('โš ๏ธ [NAV] Cannot navigate - context or call is null', name: 'CallManager'); + return; + } + + log('๐Ÿงญ [NAV] Navigating to ${_currentCall!.type.name} call screen', name: 'CallManager'); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _currentCall!.type == CallType.audio + ? const AudioCallPage() + : const VideoCallPage(), + ), + ); + } + + void _updateCallStatus(CallStatus newStatus) { + if (_callStatus == newStatus) return; + + log('๐Ÿ”„ [STATUS] ${_callStatus.name} -> ${newStatus.name}', name: 'CallManager'); + _callStatus = newStatus; + notifyListeners(); + } + + void _startCallDurationTimer() { + _callDurationTimer?.cancel(); + _callDuration = Duration.zero; + + log('โฑ๏ธ [TIMER] Starting call duration timer', name: 'CallManager'); + + _callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _callDuration = Duration(seconds: _callDuration.inSeconds + 1); + notifyListeners(); + }); + } + + void _cleanup() { + log('๐Ÿงน [CLEANUP] Starting cleanup...', name: 'CallManager'); + + _callTimeoutTimer?.cancel(); + _callDurationTimer?.cancel(); + + _webrtcService?.dispose(); + _webrtcService = null; + + if (_currentCall != null) { + _callKitService.endCall(_currentCall!.callId); + } + + _currentCall = null; + _updateCallStatus(CallStatus.idle); + _callDuration = Duration.zero; + _isMuted = false; + _isSpeakerOn = false; + _isCameraOn = true; + _isPeerMuted = false; + _isPeerCameraOn = true; + + log('โœ… [CLEANUP] Cleanup complete', name: 'CallManager'); + } + + /// Reset service (for logout or testing) + Future reset() async { + log('๐Ÿ”„ [RESET] Resetting CallManager...', name: 'CallManager'); + + _cleanup(); + + _callKitInitialized = false; + _handlersRegistered = false; + _moduleId = null; + _referenceId = null; + _conversationId = null; + _myEmployeeNumber = null; + + log('โœ… [RESET] CallManager reset complete', name: 'CallManager'); + } + + // ==================== SignalR Event Handlers ==================== + + void _handleIncomingCall(List? args) { + log('๐Ÿ“ž [EVENT] OnIncomingCallAsync received', name: 'CallManager'); + log(' Args: $args', name: 'CallManager'); + + // Note: Incoming calls are now handled by CallNotificationService + // This event is kept for chat screen integration + log('โ„น๏ธ [EVENT] Incoming call handled by notification service', name: 'CallManager'); + } + + void _handleCallAccepted(List? args) async { + log('๐Ÿ“ž [EVENT] OnCallAcceptedAsync received', name: 'CallManager'); + log(' Current status: ${_callStatus.name}', name: 'CallManager'); + + if (_callStatus != CallStatus.outgoingRinging) { + log('โš ๏ธ [EVENT] Not in outgoing ringing state, ignoring', name: 'CallManager'); + return; + } + + _callTimeoutTimer?.cancel(); + _updateCallStatus(CallStatus.connecting); + + try { + // Create and send offer + log('๐Ÿ”ง [EVENT] Creating SDP offer...', name: 'CallManager'); + final offer = await _webrtcService!.createOffer(); + log('โœ… [EVENT] SDP offer created', name: 'CallManager'); + + log('๐Ÿ“ค [EVENT] Sending offer via OfferAsync...', name: 'CallManager'); + await _signalRService.invoke('OfferAsync', args: [ + _currentCall!.peerId, + offer.sdp ?? '', + _currentCall!.callId, + ]); + log('โœ… [EVENT] Offer sent successfully', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [EVENT] Error handling call accepted: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + _cleanup(); + } + } + + void _handleCallDeclined(List? args) { + log('๐Ÿ“ž [EVENT] OnCallDeclinedAsync received', name: 'CallManager'); + log(' Args: $args', name: 'CallManager'); + + // Extract decline reason if available + String? reason; + if (args != null && args.isNotEmpty) { + reason = args[0]?.toString(); + log(' Decline reason: $reason', name: 'CallManager'); + } + + // Show appropriate message + final context = navigatorKey.currentContext; + if (context != null && reason != null) { + if (reason == 'USER_BUSY') { + CallErrorHandler.showGenericError(context, 'The user is currently busy on another call'); + } else if (reason == 'USER_OFFLINE') { + CallErrorHandler.showGenericError(context, 'The user is currently offline'); + } + } + + _cleanup(); + } + + void _handleHangUp(List? args) { + log('๐Ÿ“ž [EVENT] OnHangUpAsync received', name: 'CallManager'); + log(' Args: $args', name: 'CallManager'); + _cleanup(); + } + + void _handleOffer(List? args) async { + log('๐Ÿ“ž [EVENT] OnOfferAsync received', name: 'CallManager'); + + if (args == null || args.isEmpty) { + log('โš ๏ธ [EVENT] No offer data received', name: 'CallManager'); + return; + } + + try { + final offerSdp = args[0] as String?; + if (offerSdp == null) { + log('โš ๏ธ [EVENT] Offer SDP is null', name: 'CallManager'); + return; + } + + if (_webrtcService == null) { + log('โš ๏ธ [EVENT] WebRTC service not initialized', name: 'CallManager'); + return; + } + + log('๐Ÿ”ง [EVENT] Creating SDP answer...', name: 'CallManager'); + final answer = await _webrtcService!.createAnswer(offerSdp); + log('โœ… [EVENT] SDP answer created', name: 'CallManager'); + + log('๐Ÿ“ค [EVENT] Sending answer via AnswerOfferAsync...', name: 'CallManager'); + await _signalRService.invoke('AnswerOfferAsync', args: [ + _currentCall!.peerId, + answer.sdp ?? '', + _currentCall!.callId, + ]); + log('โœ… [EVENT] Answer sent successfully', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [EVENT] Error handling offer: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + } + } + + void _handleAnswer(List? args) async { + log('๐Ÿ“ž [EVENT] OnAnswerOfferAsync received', name: 'CallManager'); + + if (args == null || args.isEmpty) { + log('โš ๏ธ [EVENT] No answer data received', name: 'CallManager'); + return; + } + + try { + final answerSdp = args[0] as String?; + if (answerSdp == null) { + log('โš ๏ธ [EVENT] Answer SDP is null', name: 'CallManager'); + return; + } + + if (_webrtcService == null) { + log('โš ๏ธ [EVENT] WebRTC service not initialized', name: 'CallManager'); + return; + } + + log('๐Ÿ”ง [EVENT] Setting remote answer...', name: 'CallManager'); + await _webrtcService!.setRemoteAnswer(answerSdp); + log('โœ… [EVENT] Remote answer set successfully', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [EVENT] Error handling answer: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + } + } + + void _handleIceCandidate(List? args) async { + log('๐Ÿ“ž [EVENT] OnIceCandidateAsync received', name: 'CallManager'); + + if (args == null || args.isEmpty) { + log('โš ๏ธ [EVENT] No ICE candidate data received', name: 'CallManager'); + return; + } + + try { + final candidateJson = args[0] as String?; + if (candidateJson == null) { + log('โš ๏ธ [EVENT] Candidate JSON is null', name: 'CallManager'); + return; + } + + if (_webrtcService == null) { + log('โš ๏ธ [EVENT] WebRTC service not initialized', name: 'CallManager'); + return; + } + + final candidateData = jsonDecode(candidateJson) as Map; + final candidate = RTCIceCandidate( + candidateData['candidate'] as String?, + candidateData['sdpMid'] as String?, + candidateData['sdpMLineIndex'] as int?, + ); + + log('๐ŸงŠ [EVENT] Adding remote ICE candidate...', name: 'CallManager'); + await _webrtcService!.addIceCandidate(candidate); + log('โœ… [EVENT] Remote ICE candidate added', name: 'CallManager'); + + } catch (e, stackTrace) { + log('โŒ [EVENT] Error handling ICE candidate: $e', + name: 'CallManager', error: e, stackTrace: stackTrace); + } + } + + void _handleAudioToggle(List? args) { + log('๐Ÿ“ž [EVENT] OnAudioToggle received', name: 'CallManager'); + log(' Peer muted: $_isPeerMuted -> ${!_isPeerMuted}', name: 'CallManager'); + + _isPeerMuted = !_isPeerMuted; + notifyListeners(); + } + + void _handleCameraToggle(List? args) { + log('๐Ÿ“ž [EVENT] OnCameraToggle received', name: 'CallManager'); + log(' Peer camera: $_isPeerCameraOn -> ${!_isPeerCameraOn}', name: 'CallManager'); + + _isPeerCameraOn = !_isPeerCameraOn; + notifyListeners(); + } + + // ==================== CallKit Event Handlers ==================== + + void _handleCallKitAccepted(String callId) { + log('โœ… [CALLKIT] Call accepted: $callId', name: 'CallManager'); + if (_currentCall?.callId == callId) { + acceptCall(); + } else { + log('โš ๏ธ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager'); + } + } + + void _handleCallKitDeclined(String callId) { + log('โŒ [CALLKIT] Call declined: $callId', name: 'CallManager'); + if (_currentCall?.callId == callId) { + declineCall('user_declined'); + } else { + log('โš ๏ธ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager'); + } + } + + void _handleCallKitEnded(String callId) { + log('๐Ÿ”ด [CALLKIT] Call ended: $callId', name: 'CallManager'); + if (_currentCall?.callId == callId) { + hangUp(); + } else { + log('โš ๏ธ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager'); + } + } + + void _handleCallKitTimeout(String callId) { + log('โฑ๏ธ [CALLKIT] Call timeout: $callId', name: 'CallManager'); + if (_currentCall?.callId == callId) { + _handleCallTimeout(); + } else { + log('โš ๏ธ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager'); + } + } +} + diff --git a/lib/modules/cx_module/chat/services/signalr_service.dart b/lib/modules/cx_module/chat/services/signalr_service.dart new file mode 100644 index 00000000..64f0eabb --- /dev/null +++ b/lib/modules/cx_module/chat/services/signalr_service.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:developer'; +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'; + +/// Singleton SignalR service that manages the single HubConnection +/// Both ChatProvider and CallManager use this service +class SignalRService { + static final SignalRService _instance = SignalRService._internal(); + factory SignalRService() => _instance; + SignalRService._internal(); + + // Single HubConnection instance + HubConnection? _hubConnection; + + // Connection state + bool _isInitializing = false; + + // Authentication data + String? _userId; + String? _authToken; + String? _currentConversationId; + + // Event handler registrations + final Map?)>> _eventHandlers = {}; + + /// Get the current HubConnection + HubConnection? get hubConnection => _hubConnection; + + /// Check if connected + bool get isConnected => _hubConnection?.state == HubConnectionState.Connected; + + /// Get current connection state + HubConnectionState? get connectionState => _hubConnection?.state; + + /// Initialize SignalR connection with authentication + Future initialize({ + required String userId, + required String authToken, + String? conversationId, + }) async { + try { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'SignalRService'); + log('๐Ÿ”Œ [SIGNALR] Initializing SignalR connection...', name: 'SignalRService'); + log(' User ID: $userId', name: 'SignalRService'); + log(' Conversation ID: ${conversationId ?? "none"}', name: 'SignalRService'); + + // Prevent multiple simultaneous initializations + if (_isInitializing) { + log('โณ [SIGNALR] Already initializing, waiting...', name: 'SignalRService'); + // Wait for current initialization to complete + final timeout = DateTime.now().add(const Duration(seconds: 5)); + while (_isInitializing && DateTime.now().isBefore(timeout)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + return isConnected; + } + + // If already connected with same credentials, reuse connection + if (isConnected && _userId == userId && _authToken == authToken) { + log('โœ… [SIGNALR] Already connected with same credentials, reusing connection', name: 'SignalRService'); + + // Join new conversation if provided and different + if (conversationId != null && conversationId != _currentConversationId) { + await _joinConversation(conversationId); + } + + return true; + } + + _isInitializing = true; + + // Store credentials + _userId = userId; + _authToken = authToken; + _currentConversationId = conversationId; + + // Dispose existing connection if any + await _disposeConnection(); + + // Create new connection + final httpOp = HttpConnectionOptions( + skipNegotiation: false, + logMessageContent: kDebugMode, + ); + + _hubConnection = HubConnectionBuilder() + .withUrl( + "${URLs.chatHubUrlChat}?UserId=$userId&source=Desktop&access_token=$authToken", + options: httpOp, + ) + .withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]) + .build(); + + // Setup reconnection handlers + _setupReconnectionHandlers(); + + // Start connection + await _hubConnection!.start(); + + log('โœ… [SIGNALR] Connection established', name: 'SignalRService'); + log(' Connection ID: ${_hubConnection!.connectionId}', name: 'SignalRService'); + + // Join conversation if provided + if (conversationId != null) { + await _joinConversation(conversationId); + } + + // Re-register all event handlers + _reregisterAllHandlers(); + + _isInitializing = false; + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'SignalRService'); + + return true; + + } catch (e, stackTrace) { + log('โŒ [SIGNALR] Error initializing connection: $e', + name: 'SignalRService', error: e, stackTrace: stackTrace); + _isInitializing = false; + return false; + } + } + + /// Setup reconnection handlers + void _setupReconnectionHandlers() { + if (_hubConnection == null) return; + + _hubConnection!.onclose(({Exception? error}) { + log('๐Ÿ”ด [SIGNALR] Connection closed: $error', name: 'SignalRService'); + }); + + _hubConnection!.onreconnecting(({Exception? error}) { + log('๐ŸŸก [SIGNALR] Reconnecting: $error', name: 'SignalRService'); + }); + + _hubConnection!.onreconnected(({String? connectionId}) async { + log('๐ŸŸข [SIGNALR] Reconnected: $connectionId', name: 'SignalRService'); + + // Rejoin conversation if we had one + if (_currentConversationId != null) { + await _joinConversation(_currentConversationId!); + } + + // Re-register all event handlers + _reregisterAllHandlers(); + }); + } + + /// Join a conversation + Future _joinConversation(String conversationId) async { + try { + if (_hubConnection?.state != HubConnectionState.Connected) { + log('โš ๏ธ [SIGNALR] Cannot join conversation - not connected', name: 'SignalRService'); + return; + } + + await _hubConnection!.invoke("JoinConversation", args: [conversationId]); + _currentConversationId = conversationId; + log('โœ… [SIGNALR] Joined conversation: $conversationId', name: 'SignalRService'); + } catch (e) { + log('โŒ [SIGNALR] Error joining conversation: $e', name: 'SignalRService'); + } + } + + /// Register an event handler + void on(String eventName, Function(List?) handler) { + log('๐Ÿ”ง [SIGNALR] Registering handler for: $eventName', name: 'SignalRService'); + + // Store handler for re-registration after reconnect + if (!_eventHandlers.containsKey(eventName)) { + _eventHandlers[eventName] = []; + } + _eventHandlers[eventName]!.add(handler); + + // Register with SignalR if connected + if (_hubConnection != null) { + _hubConnection!.on(eventName, handler); + } + } + + /// Unregister an event handler + void off(String eventName, [Function(List?)? handler]) { + log('๐Ÿ”ง [SIGNALR] Unregistering handler for: $eventName', name: 'SignalRService'); + + if (handler != null) { + _eventHandlers[eventName]?.remove(handler); + if (_eventHandlers[eventName]?.isEmpty ?? false) { + _eventHandlers.remove(eventName); + } + } else { + _eventHandlers.remove(eventName); + } + + // Unregister from SignalR if connected + if (_hubConnection != null) { + _hubConnection!.off(eventName, method: handler); + } + } + + /// Re-register all event handlers (after reconnection) + void _reregisterAllHandlers() { + if (_hubConnection == null) return; + + log('๐Ÿ”„ [SIGNALR] Re-registering ${_eventHandlers.length} event handlers...', name: 'SignalRService'); + + for (final entry in _eventHandlers.entries) { + final eventName = entry.key; + final handlers = entry.value; + + for (final handler in handlers) { + _hubConnection!.on(eventName, handler); + } + } + + log('โœ… [SIGNALR] All event handlers re-registered', name: 'SignalRService'); + } + + /// Invoke a SignalR method + Future invoke(String methodName, {List? args}) async { + if (_hubConnection?.state != HubConnectionState.Connected) { + throw Exception('SignalR not connected. Current state: ${_hubConnection?.state}'); + } + + log('๐Ÿ“ค [SIGNALR] Invoking: $methodName', name: 'SignalRService'); + return await _hubConnection!.invoke(methodName, args: args); + } + + /// Ensure connection is ready (connect if needed) + Future ensureConnected() async { + if (isConnected) { + return true; + } + + if (_userId != null && _authToken != null) { + return await initialize( + userId: _userId!, + authToken: _authToken!, + conversationId: _currentConversationId, + ); + } + + log('โŒ [SIGNALR] Cannot reconnect - no credentials stored', name: 'SignalRService'); + return false; + } + + /// Dispose connection + Future _disposeConnection() async { + try { + if (_hubConnection != null) { + await _hubConnection!.stop(); + _hubConnection = null; + log('โœ… [SIGNALR] Connection disposed', name: 'SignalRService'); + } + } catch (e) { + log('โš ๏ธ [SIGNALR] Error disposing connection: $e', name: 'SignalRService'); + } + } + + /// Reset service (for logout) + Future reset() async { + log('๐Ÿ”„ [SIGNALR] Resetting service...', name: 'SignalRService'); + + await _disposeConnection(); + + _eventHandlers.clear(); + _userId = null; + _authToken = null; + _currentConversationId = null; + _isInitializing = false; + + log('โœ… [SIGNALR] Service reset complete', name: 'SignalRService'); + } +} +