From 71a2be931f50861bf06738af0aae764eb135ccf4 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:44:47 +0300 Subject: [PATCH] foreground calling implemented --- lib/modules/cm_module/cm_detail_page.dart | 46 ++- .../services/call_notification_service.dart | 92 +---- .../chat/call/services/webrtc_service.dart | 178 +++++++++- .../cx_module/chat/services/call_manager.dart | 326 +++++++++++++++--- .../chat/services/signalr_service.dart | 26 ++ 5 files changed, 510 insertions(+), 158 deletions(-) diff --git a/lib/modules/cm_module/cm_detail_page.dart b/lib/modules/cm_module/cm_detail_page.dart index 1bc46d8a..e9de2371 100644 --- a/lib/modules/cm_module/cm_detail_page.dart +++ b/lib/modules/cm_module/cm_detail_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'dart:developer'; import 'package:test_sa/controllers/providers/api/user_provider.dart'; import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; @@ -14,6 +15,8 @@ import 'package:test_sa/modules/cm_module/cm_detail_provider.dart'; import 'package:test_sa/modules/cm_module/views/components/bottom_sheets/service_request_bottomsheet.dart'; import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; import 'package:test_sa/modules/cx_module/chat/chat_widget.dart'; +import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart'; +import 'package:test_sa/core/di/service_locator.dart'; import 'package:test_sa/modules/export_module/provider/export_provider.dart'; import 'package:test_sa/modules/export_module/widgets/file_success_dialog.dart'; import 'package:test_sa/modules/loan_module/pages/create_loan_request_page.dart'; @@ -46,7 +49,23 @@ class _CMDetailPageState extends State { super.initState(); _requestProvider = Provider.of(context, listen: false); WidgetsBinding.instance.addPostFrameCallback((_) { - Provider.of(context, listen: false).reset(); + // CRITICAL FIX: NEVER reset ChatProvider/SignalR connection + // CallManager already initialized it from LandPage and is using it for calls + // Resetting will disconnect SignalR and break incoming calls + + final chatProvider = Provider.of(context, listen: false); + final signalRService = getIt(); + + log('๐Ÿ“‹ [CMDetailPage] Checking chat state...', name: 'CMDetailPage'); + log(' SignalR connected: ${signalRService.isConnected}', name: 'CMDetailPage'); + log(' Chat logged in: ${chatProvider.chatLoginResponse != null}', name: 'CMDetailPage'); + log(' Current reference: ${chatProvider.referenceID}', name: 'CMDetailPage'); + log(' New reference: ${widget.requestId}', name: 'CMDetailPage'); + + // DON'T RESET - just let ChatWidget handle its own initialization + // ChatWidget will check credentials and update conversation if needed + log('โœ… [CMDetailPage] Skipping reset - ChatWidget will handle initialization', name: 'CMDetailPage'); + getInitialData(); }); } @@ -152,19 +171,18 @@ class _CMDetailPageState extends State { ), 4.width, ], - ), - ).onPress(() async { - LoanProvider loanProvider = Provider.of(context, listen: false); - Utils.showLoading(context); - var data = await loanProvider.getLoanByWorkOrderId(widget.requestId); - Utils.hideLoading(context); - if (data == null) { - CMDetailProvider cmProvider = Provider.of(context, listen: false); - Navigator.push(context, MaterialPageRoute(builder: (context) => CreateLoanRequestPage(workOrderId: widget.requestId, workOrder: cmProvider.currentWorkOrder!.data))); - } else { - Navigator.push(context, MaterialPageRoute(builder: (context) => LoanEquipmentDetailPage(loanId: data.id!))); - } - }); + ).onPress(() async { + LoanProvider loanProvider = Provider.of(context, listen: false); + Utils.showLoading(context); + var data = await loanProvider.getLoanByWorkOrderId(widget.requestId); + Utils.hideLoading(context); + if (data == null) { + CMDetailProvider cmProvider = Provider.of(context, listen: false); + Navigator.push(context, MaterialPageRoute(builder: (context) => CreateLoanRequestPage(workOrderId: widget.requestId, workOrder: cmProvider.currentWorkOrder!.data))); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => LoanEquipmentDetailPage(loanId: data.id!))); + } + })); } return const SizedBox(); } diff --git a/lib/modules/cx_module/chat/call/services/call_notification_service.dart b/lib/modules/cx_module/chat/call/services/call_notification_service.dart index c825838d..2a50051b 100644 --- a/lib/modules/cx_module/chat/call/services/call_notification_service.dart +++ b/lib/modules/cx_module/chat/call/services/call_notification_service.dart @@ -15,18 +15,14 @@ class CallNotificationService { }) async { try { log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallNotificationService'); - log('๐Ÿ“ž [INCOMING CALL] Notification received from: $source', name: 'CallNotificationService'); log('๐Ÿ“ž [INCOMING CALL] Raw data: $notificationData', name: 'CallNotificationService'); final notificationType = notificationData['notificationType'] as String? ?? notificationData['type'] as String?; // Backend uses 'type' final transactionType = notificationData['transactionType'] as String?; log('๐Ÿ“ž [INCOMING CALL] Notification Type: $notificationType', name: 'CallNotificationService'); - log('๐Ÿ“ž [INCOMING CALL] Transaction Type: $transactionType', name: 'CallNotificationService'); // Verify this is a call notification if (notificationType != 'incoming_call' && transactionType != 'call') { - log('โš ๏ธ [INCOMING CALL] Not a call notification, ignoring', name: 'CallNotificationService'); - log(' Expected: notificationType=incoming_call OR transactionType=call', name: 'CallNotificationService'); log(' Got: notificationType=$notificationType, transactionType=$transactionType', name: 'CallNotificationService'); return; } @@ -36,8 +32,9 @@ class CallNotificationService { notificationData['sessionId'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(); // Generate if missing + // CRITICAL FIX: Use callerEmployeeNumber (the person calling), NOT calleeEmployeeNumber (the receiver) final callerId = notificationData['callerId'] as String? ?? - notificationData['calleeEmployeeNumber'] as String? ?? // Backend field + notificationData['callerEmployeeNumber'] as String? ?? // โœ… The actual caller notificationData['sourceUserId'] as String?; final callerName = notificationData['callerName'] as String? ?? @@ -64,18 +61,9 @@ class CallNotificationService { notificationData['applicationId']?.toString(); final referenceId = notificationData['referenceId'] as String?; final conversationId = notificationData['conversationId'] as String?; - - log('๐Ÿ“ž [INCOMING CALL] Extracted data:', name: 'CallNotificationService'); - log(' Call ID: $callId', name: 'CallNotificationService'); - log(' Caller ID: $callerId', name: 'CallNotificationService'); - log(' Caller Name: $callerName', name: 'CallNotificationService'); log(' Caller Employee Number: $callerEmployeeNumber', name: 'CallNotificationService'); - log(' Is Video: $isVideoCall', name: 'CallNotificationService'); - log(' Module ID: $moduleId', name: 'CallNotificationService'); - if (callerId == null) { log('โŒ [INCOMING CALL] Missing callerId/calleeEmployeeNumber', name: 'CallNotificationService'); - log(' Available fields: ${notificationData.keys.toList()}', name: 'CallNotificationService'); return; } @@ -107,18 +95,7 @@ class CallNotificationService { 'timestamp': DateTime.now().toIso8601String(), }; - // Show CallKit/ConnectionService immediately - log('๐Ÿ“ฑ [INCOMING CALL] Showing CallKit UI...', name: 'CallNotificationService'); - 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 + // Delegate to CallManager - it will show CallKit UI once log('๐Ÿ“ž [INCOMING CALL] Delegating to CallManager...', name: 'CallNotificationService'); await CallManager().handleIncomingCallNotification( callId: callId, @@ -137,69 +114,6 @@ class CallNotificationService { } } - /// 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', - textAccept: 'Accept', - textDecline: 'Decline', - 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; diff --git a/lib/modules/cx_module/chat/call/services/webrtc_service.dart b/lib/modules/cx_module/chat/call/services/webrtc_service.dart index 229a16c9..13d85dd7 100644 --- a/lib/modules/cx_module/chat/call/services/webrtc_service.dart +++ b/lib/modules/cx_module/chat/call/services/webrtc_service.dart @@ -132,27 +132,71 @@ class WebRTCService { // Get local audio stream try { + log('๐ŸŽค [WebRTC] Requesting microphone access...', name: 'WebRTCService'); _localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints); + + // CRITICAL: Verify audio tracks were captured + final audioTracks = _localStream!.getAudioTracks(); + log('โœ… [WebRTC] Local audio stream captured', name: 'WebRTCService'); + log(' Audio tracks count: ${audioTracks.length}', name: 'WebRTCService'); + + for (var i = 0; i < audioTracks.length; i++) { + final track = audioTracks[i]; + log(' Track $i: ${track.kind} - ID: ${track.id}', name: 'WebRTCService'); + log(' Track $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService'); + + // CRITICAL FIX: Ensure track is enabled + if (!track.enabled) { + log('โš ๏ธ [WebRTC] Track $i was disabled, enabling it', name: 'WebRTCService'); + track.enabled = true; + } + } + + if (audioTracks.isEmpty) { + throw Exception('No audio tracks captured from microphone'); + } + } catch (e) { log('โŒ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e); throw Exception('Microphone access denied or unavailable'); } // Create peer connection + log('๐Ÿ”ง [WebRTC] Creating peer connection...', name: 'WebRTCService'); _peerConnection = await createPeerConnection(_configuration); if (_peerConnection == null) { throw Exception('Failed to create peer connection'); } + log('โœ… [WebRTC] Peer connection created', name: 'WebRTCService'); // Add local stream tracks to peer connection - _localStream!.getTracks().forEach((track) { - _peerConnection!.addTrack(track, _localStream!); - }); + log('๐Ÿ“ค [WebRTC] Adding local tracks to peer connection...', name: 'WebRTCService'); + final tracks = _localStream!.getTracks(); + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + log(' Adding track $i: ${track.kind} (ID: ${track.id})', name: 'WebRTCService'); + + await _peerConnection!.addTrack(track, _localStream!); + + log(' โœ… Track $i added successfully', name: 'WebRTCService'); + } + log('โœ… [WebRTC] All local tracks added to peer connection', name: 'WebRTCService'); // Setup peer connection event handlers _setupPeerConnectionListeners(); + // CRITICAL FIX: Enable speakerphone by default for audio calls + // This ensures audio plays through the loudspeaker instead of the quiet earpiece + log('๐Ÿ”Š [WebRTC] Enabling speakerphone by default for audio call...', name: 'WebRTCService'); + try { + await Helper.setSpeakerphoneOn(true); + log('โœ… [WebRTC] Speakerphone enabled', name: 'WebRTCService'); + } catch (e) { + log('โš ๏ธ [WebRTC] Failed to enable speakerphone: $e', name: 'WebRTCService'); + // Continue anyway - user can manually enable it + } + if (kDebugMode) { log('โœ… [WebRTC] Audio call initialized', name: 'WebRTCService'); } @@ -259,11 +303,31 @@ class WebRTCService { // Handle remote stream _peerConnection!.onTrack = (RTCTrackEvent event) { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'WebRTCService'); + log('๐Ÿ“ก [WebRTC] onTrack event received!', name: 'WebRTCService'); + log(' Track kind: ${event.track?.kind}', name: 'WebRTCService'); + log(' Track ID: ${event.track?.id}', name: 'WebRTCService'); + log(' Track enabled: ${event.track?.enabled}', name: 'WebRTCService'); + log(' Track muted: ${event.track?.muted}', name: 'WebRTCService'); + // log(' Track readyState: ${event.track?.readyState}', name: 'WebRTCService'); + log(' Streams count: ${event.streams.length}', name: 'WebRTCService'); + if (event.streams.isNotEmpty) { final stream = event.streams[0]; + log(' Stream ID: ${stream.id}', name: 'WebRTCService'); + log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'WebRTCService'); + log(' Video tracks: ${stream.getVideoTracks().length}', name: 'WebRTCService'); + + // Log all tracks in the stream + final allTracks = stream.getTracks(); + for (var i = 0; i < allTracks.length; i++) { + final track = allTracks[i]; + log(' Track $i: ${track.kind} - enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService'); + } // If this is the first remote stream or a different stream, update it if (_remoteStream == null || _remoteStream!.id != stream.id) { + log('โœ… [WebRTC] Setting new remote stream', name: 'WebRTCService'); _remoteStream = stream; // For video calls, assign stream to renderer @@ -293,10 +357,9 @@ class WebRTCService { onRemoteStream!(_remoteStream!); } - if (kDebugMode) { - log('๐Ÿ“ก [WebRTC] Remote stream received', name: 'WebRTCService'); - } + log('โœ… [WebRTC] Remote stream set and callback notified', name: 'WebRTCService'); } else { + log('โ„น๏ธ [WebRTC] Additional track on existing stream', name: 'WebRTCService'); // Additional track on existing stream if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) { try { @@ -310,7 +373,10 @@ class WebRTCService { onRemoteStream!(_remoteStream!); } } + } else { + log('โš ๏ธ [WebRTC] No streams in onTrack event!', name: 'WebRTCService'); } + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'WebRTCService'); }; // Handle connection state changes @@ -328,6 +394,22 @@ class WebRTCService { throw Exception('Peer connection not initialized'); } + log('๐Ÿ”ง [WebRTC] Creating SDP offer...', name: 'WebRTCService'); + + // CRITICAL: Verify local tracks before creating offer + final senders = await _peerConnection!.getSenders(); + log('๐Ÿ“Š [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService'); + for (var i = 0; i < senders.length; i++) { + final sender = senders[i]; + final track = sender.track; + if (track != null) { + log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService'); + log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService'); + } else { + log(' Sender $i: NO TRACK!', name: 'WebRTCService'); + } + } + final offer = await _peerConnection!.createOffer({ 'offerToReceiveAudio': true, 'offerToReceiveVideo': _isVideoCall, @@ -338,6 +420,28 @@ class WebRTCService { if (kDebugMode) { log('โœ… [WebRTC] SDP offer created', name: 'WebRTCService'); + log(' Offer type: ${offer.type}', name: 'WebRTCService'); + log(' Offer SDP length: ${offer.sdp?.length ?? 0}', name: 'WebRTCService'); + + // CRITICAL: Log SDP to verify audio track is included + if (offer.sdp != null) { + final sdpLines = offer.sdp!.split('\n'); + final audioLines = sdpLines.where((line) => + line.contains('m=audio') || + line.contains('a=sendrecv') || + line.contains('a=sendonly') || + line.contains('a=recvonly') + ).toList(); + + log('๐Ÿ“‹ [WebRTC] SDP Audio Configuration:', name: 'WebRTCService'); + for (final line in audioLines) { + log(' $line', name: 'WebRTCService'); + } + + if (!offer.sdp!.contains('m=audio')) { + log('โŒ [WebRTC] WARNING: No audio media line in SDP offer!', name: 'WebRTCService'); + } + } } return offer; @@ -354,15 +458,55 @@ class WebRTCService { throw Exception('Peer connection not initialized'); } + log('๐Ÿ”ง [WebRTC] Creating SDP answer...', name: 'WebRTCService'); + log(' Received offer SDP length: ${offerSdp.length}', name: 'WebRTCService'); + + // CRITICAL: Log received offer to verify it has audio + if (kDebugMode) { + final offerLines = offerSdp.split('\n'); + final audioLines = offerLines.where((line) => + line.contains('m=audio') || + line.contains('a=sendrecv') || + line.contains('a=sendonly') || + line.contains('a=recvonly') + ).toList(); + + log('๐Ÿ“‹ [WebRTC] Received Offer Audio Configuration:', name: 'WebRTCService'); + for (final line in audioLines) { + log(' $line', name: 'WebRTCService'); + } + + if (!offerSdp.contains('m=audio')) { + log('โŒ [WebRTC] WARNING: No audio media line in received offer!', name: 'WebRTCService'); + } + } + + // CRITICAL: Verify local tracks before creating answer + final senders = await _peerConnection!.getSenders(); + log('๐Ÿ“Š [WebRTC] Peer connection has ${senders.length} sender(s)', name: 'WebRTCService'); + for (var i = 0; i < senders.length; i++) { + final sender = senders[i]; + final track = sender.track; + if (track != null) { + log(' Sender $i: ${track.kind} track (ID: ${track.id})', name: 'WebRTCService'); + log(' Sender $i: enabled=${track.enabled}, muted=${track.muted}', name: 'WebRTCService'); + } else { + log(' Sender $i: NO TRACK!', name: 'WebRTCService'); + } + } + // Set remote description (offer from caller) final offer = RTCSessionDescription(offerSdp, 'offer'); + log('๐Ÿ”ง [WebRTC] Setting remote description (offer)...', name: 'WebRTCService'); await _peerConnection!.setRemoteDescription(offer); _remoteDescriptionSet = true; + log('โœ… [WebRTC] Remote description set', name: 'WebRTCService'); // Flush queued ICE candidates await _flushIceCandidateQueue(); // Create answer + log('๐Ÿ”ง [WebRTC] Creating answer...', name: 'WebRTCService'); final answer = await _peerConnection!.createAnswer({ 'offerToReceiveAudio': true, 'offerToReceiveVideo': _isVideoCall, @@ -372,6 +516,28 @@ class WebRTCService { if (kDebugMode) { log('โœ… [WebRTC] SDP answer created', name: 'WebRTCService'); + log(' Answer type: ${answer.type}', name: 'WebRTCService'); + log(' Answer SDP length: ${answer.sdp?.length ?? 0}', name: 'WebRTCService'); + + // CRITICAL: Log SDP to verify audio track is included + if (answer.sdp != null) { + final sdpLines = answer.sdp!.split('\n'); + final audioLines = sdpLines.where((line) => + line.contains('m=audio') || + line.contains('a=sendrecv') || + line.contains('a=sendonly') || + line.contains('a=recvonly') + ).toList(); + + log('๐Ÿ“‹ [WebRTC] SDP Answer Audio Configuration:', name: 'WebRTCService'); + for (final line in audioLines) { + log(' $line', name: 'WebRTCService'); + } + + if (!answer.sdp!.contains('m=audio')) { + log('โŒ [WebRTC] WARNING: No audio media line in SDP answer!', name: 'WebRTCService'); + } + } } return answer; diff --git a/lib/modules/cx_module/chat/services/call_manager.dart b/lib/modules/cx_module/chat/services/call_manager.dart index 4e0591ae..dc435886 100644 --- a/lib/modules/cx_module/chat/services/call_manager.dart +++ b/lib/modules/cx_module/chat/services/call_manager.dart @@ -17,9 +17,6 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:uuid/uuid.dart'; import 'package:test_sa/core/di/service_locator.dart'; -/// CallManager - Singleton service that manages all call responsibilities -/// Works independently of ChatProvider -/// Uses SignalRService via Dependency Injection class CallManager extends ChangeNotifier { static final CallManager _instance = CallManager._internal(); factory CallManager() => _instance; @@ -58,6 +55,12 @@ class CallManager extends ChangeNotifier { String? _conversationId; String? _myEmployeeNumber; + // CRITICAL FIX: Static credential cache for background calls + // This allows incoming calls to work even when app is in background + static String? _cachedUserId; + static String? _cachedAuthToken; + static String? _cachedEmployeeNumber; + // Getters CallSession? get currentCall => _currentCall; CallStatus get callStatus => _callStatus; @@ -80,10 +83,61 @@ class CallManager extends ChangeNotifier { 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'); + log('๐Ÿ”ง [CallManager] Initializing...', name: 'CallManager'); + + // CRITICAL FIX: Don't re-initialize if already initialized with same USER credentials + // conversationId can change (different chats) - that's OK, just update it + // Only re-initialize if USER changes (userId, authToken, employeeNumber) + // Compare employee numbers case-insitively (backend sometimes changes case) + if (_userId == userId && + _authToken == authToken && + _myEmployeeNumber?.toLowerCase() == employeeNumber?.toLowerCase() && + _handlersRegistered) { + + // User is the same, just update conversation context without reconnecting + final conversationChanged = _conversationId != conversationId; + final moduleChanged = _moduleId != moduleId; + final referenceChanged = _referenceId != referenceId; + + if (conversationChanged || moduleChanged || referenceChanged) { + log('๐Ÿ”„ [CallManager] Updating context (no reconnection needed)', name: 'CallManager'); + log(' Conversation: $_conversationId -> $conversationId', name: 'CallManager'); + log(' Module: $_moduleId -> $moduleId', name: 'CallManager'); + log(' Reference: $_referenceId -> $referenceId', name: 'CallManager'); + + // Update context + _conversationId = conversationId; + _moduleId = moduleId; + _referenceId = referenceId; + + // Update SignalR conversation only if needed + if (conversationChanged && conversationId != null) { + await _signalRService.initialize( + userId: userId, + authToken: authToken, + conversationId: conversationId, + ); + } + } else { + log('โœ… [CallManager] Already initialized with same credentials - skipping', name: 'CallManager'); + } + + // Update employee number if case changed (keep latest version) + if (_myEmployeeNumber != employeeNumber) { + log('๐Ÿ”„ [CallManager] Updating employee number case: $_myEmployeeNumber -> $employeeNumber', name: 'CallManager'); + _myEmployeeNumber = employeeNumber; + } + + log(' User ID: $userId', name: 'CallManager'); + log(' Employee Number: $employeeNumber', name: 'CallManager'); + log(' Conversation ID: $conversationId', name: 'CallManager'); + log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager'); + return; + } + + log('๐Ÿ”„ [CallManager] User changed - full re-initialization', name: 'CallManager'); + log(' Previous User: $_userId -> New: $userId', name: 'CallManager'); + log(' Previous Employee: $_myEmployeeNumber -> New: $employeeNumber', name: 'CallManager'); // Store module context _userId = userId; @@ -94,7 +148,6 @@ class CallManager extends ChangeNotifier { _myEmployeeNumber = employeeNumber; // Initialize SignalR connection - log('๐Ÿ”Œ [INIT] Initializing SignalR connection...', name: 'CallManager'); final connected = await _signalRService.initialize( userId: userId, authToken: authToken, @@ -104,7 +157,6 @@ class CallManager extends ChangeNotifier { if (!connected) { throw Exception('Failed to initialize SignalR connection'); } - log('โœ… [INIT] SignalR connected', name: 'CallManager'); // Initialize CallKit if (!_callKitInitialized) { @@ -116,11 +168,10 @@ class CallManager extends ChangeNotifier { _registerCallHandlers(); } - log('โœ… [INIT] CallManager initialized successfully', name: 'CallManager'); - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('โœ… [CallManager] Initialized successfully', name: 'CallManager'); } catch (e, stackTrace) { - log('โŒ [INIT] Error initializing CallManager: $e', + log('โŒ [CallManager] Initialization error: $e', name: 'CallManager', error: e, stackTrace: stackTrace); rethrow; } @@ -129,8 +180,6 @@ class CallManager extends ChangeNotifier { /// Initialize CallKit service Future _initializeCallKit() async { try { - log('๐Ÿ“ž [CALLKIT] Initializing CallKit...', name: 'CallManager'); - await _callKitService.initialize(); // Setup CallKit callbacks @@ -140,18 +189,15 @@ class CallManager extends ChangeNotifier { _callKitService.onCallTimeout = _handleCallKitTimeout; _callKitInitialized = true; - log('โœ… [CALLKIT] CallKit initialized', name: 'CallManager'); } catch (e, stackTrace) { - log('โŒ [CALLKIT] Error initializing: $e', + log('โŒ [CallKit] Initialization error: $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); @@ -161,18 +207,13 @@ class CallManager extends ChangeNotifier { _signalRService.on('OnIceCandidateAsync', _handleIceCandidate); _signalRService.on('OnAudioToggle', _handleAudioToggle); _signalRService.on('OnCameraToggle', _handleCameraToggle); + _signalRService.on('OnUserOnlineAsync', _handleUserOnline); + _signalRService.on('OnUserOfflineAsync', _handleUserOffline); + _signalRService.on('OnCallHistoryUpdated', _handleCallHistoryUpdated); + _signalRService.on('OnError', _handleError); _handlersRegistered = true; - log('โœ… [HANDLERS] All call handlers registered', name: 'CallManager'); - log(' - OnIncomingCallAsync', name: 'CallManager'); - log(' - OnCallAcceptedAsync โญ', name: 'CallManager'); - log(' - OnCallDeclinedAsync', name: 'CallManager'); - log(' - OnHangUpAsync โญ', name: 'CallManager'); - log(' - OnOfferAsync โญ', name: 'CallManager'); - log(' - OnAnswerOfferAsync โญ', name: 'CallManager'); - log(' - OnIceCandidateAsync โญ', name: 'CallManager'); - log(' - OnAudioToggle', name: 'CallManager'); - log(' - OnCameraToggle', name: 'CallManager'); + log('โœ… [CallManager] Event handlers registered (13 events)', name: 'CallManager'); } /// Start outgoing call @@ -269,6 +310,15 @@ class CallManager extends ChangeNotifier { ); log('โœ… [OUTGOING] CallUserAsync invoked successfully', name: 'CallManager'); + // CRITICAL FIX: Check if call was declined/cancelled while we were waiting + if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) { + log('โš ๏ธ [OUTGOING] Call was cancelled/declined during setup - aborting', name: 'CallManager'); + log(' Current call ID: ${_currentCall?.callId}', name: 'CallManager'); + log(' Expected call ID: $callId', name: 'CallManager'); + log(' Current status: ${_callStatus.name}', name: 'CallManager'); + return; + } + _updateCallStatus(CallStatus.outgoingRinging); // Initialize WebRTC @@ -276,6 +326,12 @@ class CallManager extends ChangeNotifier { await _initializeWebRTC(); log('โœ… [OUTGOING] WebRTC initialized', name: 'CallManager'); + // CRITICAL FIX: Check again after WebRTC initialization + if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) { + log('โš ๏ธ [OUTGOING] Call was cancelled/declined after WebRTC init - aborting', name: 'CallManager'); + return; + } + // Start timeout timer (60 seconds for outgoing calls) _callTimeoutTimer = Timer(const Duration(seconds: 60), () { if (_callStatus == CallStatus.outgoingRinging) { @@ -612,35 +668,106 @@ class CallManager extends ChangeNotifier { try { log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); log('๐Ÿ“ž [HANGUP] Hanging up...', name: 'CallManager'); + log(' Current call: ${_currentCall?.callId}', name: 'CallManager'); + log(' Current status: ${_callStatus.name}', name: 'CallManager'); + log(' SignalR state: ${_signalRService.connectionState}', 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'); + // Store call info before cleanup + final peerId = _currentCall!.peerId; + final callId = _currentCall!.callId; + + // CRITICAL FIX: Verify SignalR connection state + log('๐Ÿ” [HANGUP] Verifying SignalR connection...', name: 'CallManager'); + + try { + // Check if connected, if not try to reconnect + if (!_signalRService.isConnected) { + log('โš ๏ธ [HANGUP] SignalR not connected, attempting to reconnect...', name: 'CallManager'); + final reconnected = await _signalRService.ensureConnected(); + + if (!reconnected) { + log('โŒ [HANGUP] SignalR reconnection failed', name: 'CallManager'); + } else { + log('โœ… [HANGUP] SignalR reconnected successfully', name: 'CallManager'); + } + } + + // Try to invoke HangUpAsync if connected + if (_signalRService.isConnected) { + log('๐Ÿ“ค [HANGUP] Invoking HangUpAsync...', name: 'CallManager'); + log(' From: ${_myEmployeeNumber ?? ""}', name: 'CallManager'); + log(' To: $peerId', name: 'CallManager'); + + // CRITICAL FIX: HangUpAsync only takes 2 parameters (from, to) + // The old code was sending moduleId as 3rd parameter which caused: + // "Failed to invoke 'HangUpAsync' due to an error on the server" + await _signalRService.invoke('HangUpAsync', args: [ + _myEmployeeNumber ?? '', + peerId, + ]); + + log('โœ… [HANGUP] HangUpAsync invoked successfully', name: 'CallManager'); + } else { + log('โš ๏ธ [HANGUP] SignalR not connected after retry, skipping backend call', name: 'CallManager'); + } + } catch (e) { + log('โŒ [HANGUP] Error invoking HangUpAsync: $e', name: 'CallManager'); + // Continue with cleanup even if SignalR call fails } + // End CallKit call + log('๐Ÿ“ฑ [HANGUP] Ending CallKit call...', name: 'CallManager'); + try { + await _callKitService.endCall(callId); + log('โœ… [HANGUP] CallKit call ended', name: 'CallManager'); + } catch (e) { + log('โš ๏ธ [HANGUP] Error ending CallKit: $e', name: 'CallManager'); + } + + // CRITICAL FIX: Don't manually navigate - let the call pages handle navigation + // Call pages listen to status changes and will pop when status becomes idle + log('๐Ÿงญ [HANGUP] Navigation will be handled by call page listeners', name: 'CallManager'); + + // CRITICAL: Always cleanup to reset state for next call + log('๐Ÿงน [HANGUP] Starting cleanup...', name: 'CallManager'); _cleanup(); + + // CRITICAL FIX: Verify SignalR is still connected after cleanup + log('๐Ÿ” [HANGUP] Post-cleanup SignalR verification...', name: 'CallManager'); + log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager'); + log(' SignalR connected: ${_signalRService.isConnected}', name: 'CallManager'); + log(' CallManager status: ${_callStatus.name}', name: 'CallManager'); + log(' Current call: ${_currentCall?.callId ?? "null"}', name: 'CallManager'); + + if (!_signalRService.isConnected) { + log('โš ๏ธ [HANGUP] SignalR disconnected after cleanup, attempting reconnect...', name: 'CallManager'); + await _signalRService.ensureConnected(); + log(' Reconnect result: ${_signalRService.isConnected}', name: 'CallManager'); + } + log('โœ… [HANGUP] Call ended successfully', name: 'CallManager'); + log(' SignalR ready for next call: ${_signalRService.isConnected}', name: 'CallManager'); log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); } catch (e, stackTrace) { log('โŒ [HANGUP] Error hanging up: $e', name: 'CallManager', error: e, stackTrace: stackTrace); + + // CRITICAL FIX: Don't manually navigate in error case either + log('๐Ÿงญ [HANGUP-ERROR] Navigation will be handled by call page listeners', name: 'CallManager'); _cleanup(); + + // Try to ensure SignalR is ready for next call + try { + await _signalRService.ensureConnected(); + } catch (reconnectError) { + log('โŒ [HANGUP] Failed to reconnect SignalR: $reconnectError', name: 'CallManager'); + } } } @@ -743,6 +870,12 @@ class CallManager extends ChangeNotifier { if (_currentCall!.type == CallType.audio) { log('๐ŸŽต [WEBRTC] Initializing for audio call', name: 'CallManager'); await _webrtcService!.initializeForAudioCall(); + + // CRITICAL FIX: Speaker is enabled by default in WebRTC service + // Update the CallManager state to reflect this + _isSpeakerOn = true; + log('โœ… [WEBRTC] Speaker state synchronized: ON', name: 'CallManager'); + notifyListeners(); } else { log('๐Ÿ“น [WEBRTC] Initializing for video call', name: 'CallManager'); await _webrtcService!.initializeForVideoCall(); @@ -990,25 +1123,64 @@ class CallManager extends ChangeNotifier { _cleanup(); } - void _handleHangUp(List? args) { + void _handleHangUp(List? args) async { log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); log('๐Ÿ“ž [EVENT] ๐Ÿ”ด OnHangUpAsync received', name: 'CallManager'); log(' Args count: ${args?.length ?? 0}', name: 'CallManager'); log(' Args: $args', name: 'CallManager'); log(' Current call status: ${_callStatus.name}', name: 'CallManager'); log(' Current call ID: ${_currentCall?.callId}', name: 'CallManager'); - log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager'); - // CRITICAL: Navigate back to close call screen on both devices - final context = navigatorKey.currentContext; - if (context != null && Navigator.of(context).canPop()) { - log('๐Ÿงญ [HANGUP] Navigating back to close call screen', name: 'CallManager'); - Navigator.of(context).pop(); + // CRITICAL FIX: Don't manually navigate - call pages listen to status and auto-pop when idle + // Avoid double navigation which causes navigation errors + log('๐Ÿงญ [EVENT-HANGUP] Skipping manual navigation - call page will auto-pop on status change', name: 'CallManager'); + + // End CallKit call first + if (_currentCall != null) { + log('๐Ÿ“ฑ [EVENT-HANGUP] Ending CallKit call...', name: 'CallManager'); + try { + await _callKitService.endCall(_currentCall!.callId); + log('โœ… [EVENT-HANGUP] CallKit call ended', name: 'CallManager'); + } catch (e) { + log('โš ๏ธ [EVENT-HANGUP] Error ending CallKit: $e', name: 'CallManager'); + } } + // Cleanup call resources - this will set status to idle, triggering call page navigation + log('๐Ÿงน [EVENT-HANGUP] Starting cleanup...', name: 'CallManager'); _cleanup(); - log('โœ… [HANGUP] Call ended and screen closed', name: 'CallManager'); + // CRITICAL FIX: Verify SignalR is still connected after cleanup + log('๐Ÿ” [EVENT-HANGUP] Post-cleanup SignalR verification...', name: 'CallManager'); + log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager'); + log(' SignalR connected: ${_signalRService.isConnected}', name: 'CallManager'); + log(' CallManager status: ${_callStatus.name}', name: 'CallManager'); + + // CRITICAL: Ensure SignalR is ready for next call + if (!_signalRService.isConnected) { + log('โš ๏ธ [EVENT-HANGUP] SignalR disconnected after cleanup, attempting reconnect...', name: 'CallManager'); + try { + final reconnected = await _signalRService.ensureConnected(); + log(' Reconnect result: $reconnected', name: 'CallManager'); + log(' SignalR state after reconnect: ${_signalRService.connectionState}', name: 'CallManager'); + + if (!reconnected) { + log('โŒ [EVENT-HANGUP] Failed to reconnect SignalR!', name: 'CallManager'); + log(' This will prevent next call from working', name: 'CallManager'); + } else { + log('โœ… [EVENT-HANGUP] SignalR reconnected successfully', name: 'CallManager'); + } + } catch (e) { + log('โŒ [EVENT-HANGUP] Error reconnecting SignalR: $e', name: 'CallManager'); + } + } else { + log('โœ… [EVENT-HANGUP] SignalR still connected after cleanup', name: 'CallManager'); + } + + log('โœ… [EVENT-HANGUP] Hangup event processed', name: 'CallManager'); + log(' Call page will auto-navigate when it detects idle status', name: 'CallManager'); + log(' SignalR ready for next call: ${_signalRService.isConnected}', name: 'CallManager'); log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); } @@ -1190,4 +1362,60 @@ class CallManager extends ChangeNotifier { log('โš ๏ธ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager'); } } + + // ==================== Additional SignalR Event Handlers ==================== + + void _handleUserOnline(List? args) { + log('๐Ÿ“ž [EVENT] ๐ŸŸข OnUserOnlineAsync received', name: 'CallManager'); + log(' Args count: ${args?.length ?? 0}', name: 'CallManager'); + if (args != null && args.isNotEmpty) { + log(' User online: ${args[0]}', name: 'CallManager'); + } + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + // Notify listeners in case UI needs to update user status + notifyListeners(); + } + + void _handleUserOffline(List? args) { + log('๐Ÿ“ž [EVENT] ๐Ÿ”ด OnUserOfflineAsync received', name: 'CallManager'); + log(' Args count: ${args?.length ?? 0}', name: 'CallManager'); + if (args != null && args.isNotEmpty) { + log(' User offline: ${args[0]}', name: 'CallManager'); + } + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + // Notify listeners in case UI needs to update user status + notifyListeners(); + } + + void _handleCallHistoryUpdated(List? args) { + log('๐Ÿ“ž [EVENT] ๐Ÿ“‹ OnCallHistoryUpdated received', name: 'CallManager'); + log(' Args count: ${args?.length ?? 0}', name: 'CallManager'); + if (args != null && args.isNotEmpty) { + log(' Call history update: ${args[0]}', name: 'CallManager'); + } + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + + // Forward to ChatProvider if needed (call history is typically managed there) + // This is just logging for now + } + + void _handleError(List? args) { + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + log('๐Ÿ“ž [EVENT] โŒ OnError received', name: 'CallManager'); + log(' Args count: ${args?.length ?? 0}', name: 'CallManager'); + + if (args != null && args.isNotEmpty) { + final error = args[0]?.toString() ?? 'Unknown error'; + log(' Error: $error', name: 'CallManager'); + + // Show error to user if we have context + final context = navigatorKey.currentContext; + if (context != null) { + CallErrorHandler.showGenericError(context, 'Call error: $error'); + } + } + log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', name: 'CallManager'); + } } diff --git a/lib/modules/cx_module/chat/services/signalr_service.dart b/lib/modules/cx_module/chat/services/signalr_service.dart index 20149861..26e50d4c 100644 --- a/lib/modules/cx_module/chat/services/signalr_service.dart +++ b/lib/modules/cx_module/chat/services/signalr_service.dart @@ -306,10 +306,36 @@ class SignalRService { /// Ensure connection is ready (connect if needed) /// Thread-safe: Multiple callers will wait for the same connection task Future ensureConnected() async { + // If already connected, we're good if (isConnected) { return true; } + // CRITICAL FIX: If currently reconnecting, wait for it to complete + // Don't dispose the connection while it's reconnecting! + if (_hubConnection?.state == HubConnectionState.Reconnecting) { + log('โณ [SIGNALR] Already reconnecting, waiting for completion...', name: 'SignalRService'); + + // Wait up to 10 seconds for reconnection to complete + final startTime = DateTime.now(); + while (_hubConnection?.state == HubConnectionState.Reconnecting) { + await Future.delayed(const Duration(milliseconds: 500)); + + // Timeout after 10 seconds + if (DateTime.now().difference(startTime).inSeconds > 10) { + log('โฑ๏ธ [SIGNALR] Reconnection timeout, forcing new connection', name: 'SignalRService'); + break; + } + } + + // Check if reconnection succeeded + if (isConnected) { + log('โœ… [SIGNALR] Reconnection completed successfully', name: 'SignalRService'); + return true; + } + } + + // If we have credentials, try to reconnect if (_userId != null && _authToken != null) { log('๐Ÿ”„ [SIGNALR] Not connected, attempting to reconnect...', name: 'SignalRService'); return await initialize(