foreground calling implemented

ui_ux_rollout_merge_audio_video_call
WaseemAbbasi22 4 weeks ago
parent 90b0f177af
commit 71a2be931f

@ -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<CMDetailPage> {
super.initState();
_requestProvider = Provider.of<CMDetailProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<ChatProvider>(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<ChatProvider>(context, listen: false);
final signalRService = getIt<SignalRService>();
log('📋 [CMDetailPage] Checking chat state...', name: 'CMDetailPage');
log(' SignalR connected: ${signalRService.isConnected}', name: 'CMDetailPage');
log(' Chat logged in: ${chatProvider.chatLoginResponse != null}', name: 'CMDetailPage');
log(' Current reference: ${chatProvider.referenceID}', name: 'CMDetailPage');
log(' New reference: ${widget.requestId}', name: 'CMDetailPage');
// DON'T RESET - just let ChatWidget handle its own initialization
// ChatWidget will check credentials and update conversation if needed
log('✅ [CMDetailPage] Skipping reset - ChatWidget will handle initialization', name: 'CMDetailPage');
getInitialData();
});
}
@ -152,7 +171,6 @@ class _CMDetailPageState extends State<CMDetailPage> {
),
4.width,
],
),
).onPress(() async {
LoanProvider loanProvider = Provider.of<LoanProvider>(context, listen: false);
Utils.showLoading(context);
@ -164,7 +182,7 @@ class _CMDetailPageState extends State<CMDetailPage> {
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => LoanEquipmentDetailPage(loanId: data.id!)));
}
});
}));
}
return const SizedBox();
}

@ -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<void> _showNativeIncomingCallUI({
required String callId,
required String callerName,
required String callerNumber,
required bool isVideo,
required Map<String, dynamic> 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: <String, dynamic>{'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<String, dynamic>? get pendingCallData => _pendingCallData;

@ -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;

@ -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('🔧 [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(' Conversation ID: ${conversationId ?? "none"}', 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<void> _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()) {
// 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 ?? '',
_currentCall!.peerId,
_moduleId ?? '0',
_referenceId ?? '0',
_conversationId ?? '',
peerId,
]);
log('✅ [HANGUP] HangUpAsync invoked', name: 'CallManager');
log('✅ [HANGUP] HangUpAsync invoked successfully', name: 'CallManager');
} else {
log('⚠️ [HANGUP] SignalR not connected, skipping backend call', name: 'CallManager');
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<Object?>? args) {
void _handleHangUp(List<Object?>? 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<Object?>? 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<Object?>? 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<Object?>? 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<Object?>? 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');
}
}

@ -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<bool> ensureConnected() async {
// If already connected, we're good
if (isConnected) {
return true;
}
// CRITICAL FIX: If currently reconnecting, wait for it to complete
// Don't dispose the connection while it's reconnecting!
if (_hubConnection?.state == HubConnectionState.Reconnecting) {
log('⏳ [SIGNALR] Already reconnecting, waiting for completion...', name: 'SignalRService');
// Wait up to 10 seconds for reconnection to complete
final startTime = DateTime.now();
while (_hubConnection?.state == HubConnectionState.Reconnecting) {
await Future.delayed(const Duration(milliseconds: 500));
// Timeout after 10 seconds
if (DateTime.now().difference(startTime).inSeconds > 10) {
log('⏱️ [SIGNALR] Reconnection timeout, forcing new connection', name: 'SignalRService');
break;
}
}
// Check if reconnection succeeded
if (isConnected) {
log('✅ [SIGNALR] Reconnection completed successfully', name: 'SignalRService');
return true;
}
}
// If we have credentials, try to reconnect
if (_userId != null && _authToken != null) {
log('🔄 [SIGNALR] Not connected, attempting to reconnect...', name: 'SignalRService');
return await initialize(

Loading…
Cancel
Save