You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1349 lines
44 KiB
Dart
1349 lines
44 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:developer';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.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:test_sa/modules/cx_module/chat/chat_provider.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:test_sa/core/di/service_locator.dart';
|
|
import 'package:test_sa/modules/cx_module/chat/services/pending_call_storage.dart';
|
|
import 'package:test_sa/modules/cx_module/chat/services/call_coordinator.dart';
|
|
|
|
class CallManager extends ChangeNotifier {
|
|
static final CallManager _instance = CallManager._internal();
|
|
|
|
factory CallManager() => _instance;
|
|
|
|
CallManager._internal();
|
|
|
|
// Services - Retrieved from DI
|
|
SignalRService get _signalRService => getIt<SignalRService>();
|
|
|
|
WebRTCService get _webrtcService => getIt<WebRTCService>();
|
|
final CallKitService _callKitService = CallKitService();
|
|
|
|
// Public getter for WebRTC service (used by call pages and ChatProvider)
|
|
WebRTCService get 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? _userId;
|
|
String? _authToken;
|
|
String? _moduleId;
|
|
String? _referenceId;
|
|
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;
|
|
|
|
// CRITICAL: Pending call data for background acceptance
|
|
// This preserves the COMPLETE notification payload
|
|
Map<String, dynamic>? _pendingCallData;
|
|
|
|
// CRITICAL FIX: Navigation pending flag for background calls
|
|
// When app is in background and call is accepted, we set this flag
|
|
// The main UI will check this flag when it resumes and navigate
|
|
bool _navigationPending = false;
|
|
|
|
// CRITICAL FIX: WebRTC initialization synchronization
|
|
Completer<void>? _webrtcInitCompleter;
|
|
String? _pendingOfferSdp; // Queue for offer that arrives during initialization
|
|
|
|
// 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;
|
|
|
|
bool get isCallInProgress => _callStatus != CallStatus.idle;
|
|
|
|
// CRITICAL: Public getter for navigation pending flag
|
|
bool get isNavigationPending => _navigationPending;
|
|
|
|
/// Initialize CallManager with user credentials
|
|
Future<void> initialize({
|
|
required String userId,
|
|
required String authToken,
|
|
String? conversationId,
|
|
String? moduleId,
|
|
String? referenceId,
|
|
String? employeeNumber,
|
|
}) async {
|
|
try {
|
|
// CRITICAL FIX: Cache credentials for background call handling
|
|
_cachedUserId = userId;
|
|
_cachedAuthToken = authToken;
|
|
_cachedEmployeeNumber = employeeNumber;
|
|
|
|
// CRITICAL FIX: Don't re-initialize if already initialized with same USER credentials
|
|
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) {
|
|
_conversationId = conversationId;
|
|
_moduleId = moduleId;
|
|
_referenceId = referenceId;
|
|
|
|
if (conversationChanged && conversationId != null) {
|
|
await _signalRService.initialize(
|
|
userId: userId,
|
|
authToken: authToken,
|
|
conversationId: conversationId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (_myEmployeeNumber != employeeNumber) {
|
|
_myEmployeeNumber = employeeNumber;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Store module context
|
|
_userId = userId;
|
|
_authToken = authToken;
|
|
_moduleId = moduleId;
|
|
_referenceId = referenceId;
|
|
_conversationId = conversationId;
|
|
_myEmployeeNumber = employeeNumber;
|
|
|
|
// Initialize SignalR connection
|
|
final connected = await _signalRService.initialize(
|
|
userId: userId,
|
|
authToken: authToken,
|
|
conversationId: conversationId,
|
|
);
|
|
|
|
if (!connected) {
|
|
throw Exception('Failed to initialize SignalR connection');
|
|
}
|
|
|
|
// Initialize CallKit
|
|
if (!_callKitInitialized) {
|
|
await _initializeCallKit();
|
|
}
|
|
|
|
// Register call event handlers
|
|
if (!_handlersRegistered) {
|
|
_registerCallHandlers();
|
|
}
|
|
} catch (e, stackTrace) {
|
|
log('❌ [CallManager] Initialization error: $e\nStack: $stackTrace', name: 'CallManager');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Initialize CallKit service
|
|
Future<void> _initializeCallKit() async {
|
|
if (_callKitInitialized) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _callKitService.initialize();
|
|
|
|
_callKitService.onCallAccepted = _handleCallKitAccepted;
|
|
_callKitService.onCallDeclined = _handleCallKitDeclined;
|
|
_callKitService.onCallEnded = _handleCallKitEnded;
|
|
_callKitService.onCallTimeout = _handleCallKitTimeout;
|
|
|
|
_callKitInitialized = true;
|
|
} catch (e, stackTrace) {
|
|
log('❌ [CallKit] Initialization error: $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
/// Register SignalR call event handlers
|
|
void _registerCallHandlers() {
|
|
_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);
|
|
_signalRService.on('OnUserOnlineAsync', _handleUserOnline);
|
|
_signalRService.on('OnUserOfflineAsync', _handleUserOffline);
|
|
_signalRService.on('OnCallHistoryUpdated', _handleCallHistoryUpdated);
|
|
_signalRService.on('OnError', _handleError);
|
|
|
|
_handlersRegistered = true;
|
|
}
|
|
|
|
/// Start outgoing call
|
|
Future<void> startCall({
|
|
required String peerId,
|
|
required String peerName,
|
|
required CallType callType,
|
|
String? peerAvatar,
|
|
}) async {
|
|
try {
|
|
if (_userId == null || _authToken == null) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(context, 'Call system not ready. Please try again.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_handlersRegistered) {
|
|
_registerCallHandlers();
|
|
}
|
|
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
_updateCallStatus(CallStatus.idle);
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final callId = const Uuid().v4();
|
|
|
|
_currentCall = CallSession(
|
|
callId: callId,
|
|
type: callType,
|
|
direction: CallDirection.outgoing,
|
|
peerId: peerId,
|
|
peerName: peerName,
|
|
peerAvatar: peerAvatar,
|
|
startTime: DateTime.now(),
|
|
);
|
|
|
|
await _signalRService.invoke(
|
|
'CallUserAsync',
|
|
args: [
|
|
_myEmployeeNumber ?? '',
|
|
peerId,
|
|
callType == CallType.video,
|
|
],
|
|
);
|
|
|
|
if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) {
|
|
return;
|
|
}
|
|
|
|
_updateCallStatus(CallStatus.outgoingRinging);
|
|
|
|
await _initializeWebRTC();
|
|
|
|
if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) {
|
|
return;
|
|
}
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 60), () {
|
|
if (_callStatus == CallStatus.outgoingRinging) {
|
|
_cleanup();
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(context, 'Call timeout - no answer');
|
|
}
|
|
}
|
|
});
|
|
|
|
_navigateToCallScreen();
|
|
} catch (e, stackTrace) {
|
|
log('❌ [OUTGOING] Error starting call: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(context, 'Failed to start call: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle incoming call from notification
|
|
Future<void> handleIncomingCallNotification({
|
|
required String callId,
|
|
required String callerId,
|
|
required String callerName,
|
|
required bool isVideoCall,
|
|
Map<String, dynamic>? extraData,
|
|
}) async {
|
|
try {
|
|
_pendingCallData = {
|
|
'callId': callId,
|
|
'callerId': callerId,
|
|
'callerName': callerName,
|
|
'isVideoCall': isVideoCall,
|
|
'conversationId': extraData?['conversationId'],
|
|
'moduleId': extraData?['moduleId'],
|
|
'referenceId': extraData?['referenceId'],
|
|
'callerEmployeeNumber': extraData?['callerEmployeeNumber'],
|
|
'timestamp': DateTime.now().toIso8601String(),
|
|
};
|
|
|
|
if (_userId == null || _authToken == null) {
|
|
if (_cachedUserId != null && _cachedAuthToken != null && _cachedEmployeeNumber != null) {
|
|
await initialize(
|
|
userId: _cachedUserId!,
|
|
authToken: _cachedAuthToken!,
|
|
conversationId: extraData?['conversationId'] as String?,
|
|
moduleId: extraData?['moduleId'] as String?,
|
|
referenceId: extraData?['referenceId'] as String?,
|
|
employeeNumber: _cachedEmployeeNumber,
|
|
);
|
|
} else if (navigatorKey.currentContext != null) {
|
|
try {
|
|
final chatProvider = Provider.of<ChatProvider>(navigatorKey.currentContext!, listen: false);
|
|
final chatLoginResponse = chatProvider.chatLoginResponse;
|
|
final sender = chatProvider.sender;
|
|
|
|
if (chatLoginResponse != null) {
|
|
await initialize(
|
|
userId: chatLoginResponse.userId.toString(),
|
|
authToken: chatLoginResponse.token ?? '',
|
|
conversationId: extraData?['conversationId'] as String?,
|
|
moduleId: extraData?['moduleId'] as String?,
|
|
referenceId: extraData?['referenceId'] as String?,
|
|
employeeNumber: sender?.employeeNumber,
|
|
);
|
|
} else {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_callStatus != CallStatus.idle) {
|
|
if (!await _signalRService.ensureConnected()) {
|
|
return;
|
|
}
|
|
|
|
await _signalRService.invoke('CallDeclinedAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
callerId,
|
|
'CALLER_BUSY',
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
}
|
|
return;
|
|
}
|
|
|
|
_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);
|
|
|
|
if (!_callKitInitialized) {
|
|
await _initializeCallKit();
|
|
}
|
|
|
|
await _callKitService.showIncomingCall(
|
|
callId: callId,
|
|
callerName: callerName,
|
|
callerNumber: callerId,
|
|
isVideo: isVideoCall,
|
|
extra: extraData,
|
|
);
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 30), () async {
|
|
if (_callStatus == CallStatus.incomingRinging) {
|
|
await _handleCallTimeout();
|
|
}
|
|
});
|
|
} catch (e, stackTrace) {
|
|
log('❌ [INCOMING] Error handling incoming call: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
/// Handle call timeout (for incoming calls)
|
|
Future<void> _handleCallTimeout() async {
|
|
try {
|
|
if (_currentCall == null) return;
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
await _signalRService.invoke('CallMissedAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall!.peerId,
|
|
_moduleId ?? '',
|
|
]);
|
|
}
|
|
|
|
await _cleanup();
|
|
} catch (e, stackTrace) {
|
|
log('❌ [TIMEOUT] Error: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
/// Accept incoming call
|
|
Future<void> acceptCall() async {
|
|
try {
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
log('✅ [ACCEPT] ▶️ ACCEPT CALL STARTED', name: 'CallManager');
|
|
log('✅ [ACCEPT] Timestamp: ${DateTime.now().toIso8601String()}', name: 'CallManager');
|
|
|
|
// CRITICAL: Handle background acceptance scenario
|
|
if (_currentCall == null && _pendingCallData != null) {
|
|
log('🔧 [ACCEPT] No current call but pending data exists - restoring from background', name: 'CallManager');
|
|
log(' Pending call ID: ${_pendingCallData!['callId']}', name: 'CallManager');
|
|
log(' Pending caller: ${_pendingCallData!['callerName']}', name: 'CallManager');
|
|
|
|
// Restore call session from pending data
|
|
_currentCall = CallSession(
|
|
callId: _pendingCallData!['callId'] as String,
|
|
type: (_pendingCallData!['isVideoCall'] as bool) ? CallType.video : CallType.audio,
|
|
direction: CallDirection.incoming,
|
|
peerId: _pendingCallData!['callerId'] as String,
|
|
peerName: _pendingCallData!['callerName'] as String,
|
|
peerAvatar: null,
|
|
startTime: DateTime.now(),
|
|
);
|
|
|
|
_updateCallStatus(CallStatus.incomingRinging);
|
|
log('✅ [ACCEPT] Call session restored from pending data', name: 'CallManager');
|
|
|
|
// Ensure CallManager is initialized with pending call context
|
|
if (_userId == null || _authToken == null) {
|
|
log('⚠️ [ACCEPT] CallManager not initialized, attempting auto-initialization...', name: 'CallManager');
|
|
|
|
if (_cachedUserId != null && _cachedAuthToken != null && _cachedEmployeeNumber != null) {
|
|
await initialize(
|
|
userId: _cachedUserId!,
|
|
authToken: _cachedAuthToken!,
|
|
conversationId: _pendingCallData!['conversationId'] as String?,
|
|
moduleId: _pendingCallData!['moduleId'] as String?,
|
|
referenceId: _pendingCallData!['referenceId'] as String?,
|
|
employeeNumber: _cachedEmployeeNumber,
|
|
);
|
|
log('✅ [ACCEPT] CallManager initialized for background call', name: 'CallManager');
|
|
} else {
|
|
log('❌ [ACCEPT] Cannot initialize - no cached credentials', name: 'CallManager');
|
|
await _cleanup();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_currentCall == null || _callStatus != CallStatus.incomingRinging) {
|
|
log('⚠️ [ACCEPT] No incoming call to accept', name: 'CallManager');
|
|
log(' Current call: ${_currentCall?.callId}', name: 'CallManager');
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
log('📊 [ACCEPT] Current call state:', name: 'CallManager');
|
|
|
|
// CRITICAL FIX: Check context availability
|
|
final hasContext = navigatorKey.currentContext != null;
|
|
log(' App lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name ?? "NULL"}', name: 'CallManager');
|
|
|
|
if (hasContext) {
|
|
log(' Context widget: ${navigatorKey.currentContext!.widget.runtimeType}', name: 'CallManager');
|
|
} else {
|
|
log(' Context available: FALSE (background acceptance)', name: 'CallManager');
|
|
}
|
|
|
|
if (hasContext) {
|
|
log('✅ [ACCEPT] App in foreground with context', name: 'CallManager');
|
|
} else {
|
|
log(' Lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name ?? "null"}', name: 'CallManager');
|
|
}
|
|
|
|
// CRITICAL: Ensure SignalR connected before accepting
|
|
log('🔌 [ACCEPT] Ensuring SignalR connection...', name: 'CallManager');
|
|
if (!await _signalRService.ensureConnected()) {
|
|
log('❌ [ACCEPT] SignalR connection failed', name: 'CallManager');
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
}
|
|
await _cleanup();
|
|
return;
|
|
}
|
|
log('✅ [ACCEPT] SignalR connected', name: 'CallManager');
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
// CRITICAL: Ensure handlers are registered
|
|
if (!_handlersRegistered) {
|
|
log('⚠️ [ACCEPT] Call handlers not registered, registering now...', name: 'CallManager');
|
|
_registerCallHandlers();
|
|
}
|
|
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
// Cancel timeout timer
|
|
_callTimeoutTimer?.cancel();
|
|
log('✅ [ACCEPT] Timeout timer cancelled', name: 'CallManager');
|
|
|
|
// Invoke AnswerCallAsync
|
|
log('📤 [ACCEPT] Invoking AnswerCallAsync...', name: 'CallManager');
|
|
log(' My Employee Number: ${_myEmployeeNumber ?? "NULL"}', name: 'CallManager');
|
|
log(' Peer ID: ${_currentCall!.peerId}', name: 'CallManager');
|
|
log(' Module ID: ${_moduleId ?? "0"}', name: 'CallManager');
|
|
log(' Reference ID: ${_referenceId ?? "0"}', name: 'CallManager');
|
|
log(' Conversation ID: ${_conversationId ?? ""}', name: 'CallManager');
|
|
|
|
// CRITICAL: Try parsing moduleId and referenceId as integers if they're numeric strings
|
|
final moduleIdValue = _moduleId ?? '0';
|
|
final referenceIdValue = _referenceId ?? '0';
|
|
|
|
// Try to parse as int if possible (backend might expect int instead of string)
|
|
final moduleIdArg = int.tryParse(moduleIdValue) ?? moduleIdValue;
|
|
final referenceIdArg = int.tryParse(referenceIdValue) ?? referenceIdValue;
|
|
|
|
log(' Module ID arg type: ${moduleIdArg.runtimeType} (value: $moduleIdArg)', name: 'CallManager');
|
|
log(' Reference ID arg type: ${referenceIdArg.runtimeType} (value: $referenceIdArg)', name: 'CallManager');
|
|
|
|
try {
|
|
await _signalRService.invoke('AnswerCallAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall!.peerId,
|
|
moduleIdArg,
|
|
// referenceIdArg,
|
|
// _conversationId ?? '',
|
|
]);
|
|
log('✅ [ACCEPT] AnswerCallAsync invoked successfully', name: 'CallManager');
|
|
} catch (answerError) {
|
|
log('❌ [ACCEPT] AnswerCallAsync FAILED on server', name: 'CallManager');
|
|
// Cleanup and show user-friendly error
|
|
await _cleanup();
|
|
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(
|
|
context,
|
|
'Unable to accept call. The caller may have ended the call or there may be a connection issue.',
|
|
);
|
|
}
|
|
|
|
// Rethrow to be caught by outer catch block
|
|
rethrow;
|
|
}
|
|
|
|
// Clear pending call data after successful accept
|
|
_pendingCallData = null;
|
|
|
|
// Initialize WebRTC
|
|
log('🔧 [ACCEPT] Initializing WebRTC...', name: 'CallManager');
|
|
log(' Call type: ${_currentCall!.type.name}', name: 'CallManager');
|
|
await _initializeWebRTC();
|
|
log('✅ [ACCEPT] WebRTC initialized', name: 'CallManager');
|
|
log(' WebRTC negotiation can proceed in background', name: 'CallManager');
|
|
|
|
// CRITICAL FIX: Navigation logic with comprehensive logging
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
log('🧭 [ACCEPT] ▶️ NAVIGATION PHASE STARTED', name: 'CallManager');
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
if (!hasContext) {
|
|
log('🔄 [NAV] App was in BACKGROUND - setting navigation pending flag', name: 'CallManager');
|
|
log(' Current time: ${DateTime.now().toIso8601String()}', name: 'CallManager');
|
|
log(' Lifecycle state: ${WidgetsBinding.instance.lifecycleState?.name ?? "NULL"}', name: 'CallManager');
|
|
|
|
// CRITICAL FIX: Set navigation pending flag instead of trying to navigate from background isolate
|
|
_navigationPending = true;
|
|
log('✅ [NAV] Navigation pending flag SET', name: 'CallManager');
|
|
log(' Main UI will check this flag when app resumes', name: 'CallManager');
|
|
log(' Call handlePendingNavigation() from main UI lifecycle observer', name: 'CallManager');
|
|
} else {
|
|
// Foreground: Navigate immediately
|
|
log('🎯 [NAV] App IS in FOREGROUND - navigating immediately', name: 'CallManager');
|
|
log(' Current time: ${DateTime.now().toIso8601String()}', name: 'CallManager');
|
|
log(' Context available: YES', name: 'CallManager');
|
|
log(' Context type: ${navigatorKey.currentContext.runtimeType}', name: 'CallManager');
|
|
log('🚀 [NAV] Calling _navigateToCallScreen() [IMMEDIATE]...', name: 'CallManager');
|
|
|
|
try {
|
|
_navigateToCallScreen();
|
|
log('✅ [NAV] _navigateToCallScreen() [IMMEDIATE] returned successfully', name: 'CallManager');
|
|
} catch (e, stackTrace) {
|
|
log('❌ [NAV] Navigation error (foreground): $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
} catch (e, stackTrace) {
|
|
log('❌ [ACCEPT] ❌ ERROR in acceptCall(): $e\nStack: $stackTrace', name: 'CallManager');
|
|
await _cleanup();
|
|
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(context, 'Failed to accept call: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decline incoming call
|
|
Future<void> declineCall(String reason) async {
|
|
try {
|
|
if (_currentCall == null) {
|
|
return;
|
|
}
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
await _signalRService.invoke('CallDeclinedAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall!.peerId,
|
|
_moduleId ?? '',
|
|
// reason,
|
|
]);
|
|
}
|
|
|
|
await _cleanup();
|
|
} catch (e, stackTrace) {
|
|
log('❌ [DECLINE] Error declining call: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
/// Hang up call
|
|
Future<void> hangUp() async {
|
|
try {
|
|
if (_currentCall == null) {
|
|
return;
|
|
}
|
|
|
|
final peerId = _currentCall!.peerId;
|
|
|
|
try {
|
|
if (!_signalRService.isConnected) {
|
|
final reconnected = await _signalRService.ensureConnected();
|
|
if (!reconnected) {
|
|
// Connection failed, continue with cleanup anyway
|
|
}
|
|
}
|
|
|
|
if (_signalRService.isConnected) {
|
|
await _signalRService.invoke('HangUpAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
peerId,
|
|
_moduleId ?? '',
|
|
]);
|
|
}
|
|
} catch (e) {
|
|
// Continue with cleanup even if SignalR call fails
|
|
}
|
|
|
|
// try {
|
|
// await _callKitService.endCall(callId);
|
|
// } catch (e) {
|
|
// // Ignore CallKit errors
|
|
// }
|
|
|
|
await _cleanup();
|
|
|
|
if (!_signalRService.isConnected) {
|
|
await _signalRService.ensureConnected();
|
|
}
|
|
} catch (e, stackTrace) {
|
|
log('❌ [HANGUP] Error hanging up: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
|
|
try {
|
|
await _signalRService.ensureConnected();
|
|
} catch (reconnectError) {
|
|
// Ignore reconnect errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle mute
|
|
Future<void> toggleMute() async {
|
|
_isMuted = !_isMuted;
|
|
_webrtcService.setMicrophoneMuted(_isMuted);
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
await _signalRService.invoke('AudioToggle', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall?.peerId ?? '',
|
|
]);
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Toggle speaker
|
|
Future<void> toggleSpeaker() async {
|
|
_isSpeakerOn = !_isSpeakerOn;
|
|
await _webrtcService.setSpeakerphoneEnabled(_isSpeakerOn);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Toggle camera
|
|
Future<void> toggleCamera() async {
|
|
if (_currentCall?.type != CallType.video) return;
|
|
|
|
_isCameraOn = !_isCameraOn;
|
|
_webrtcService.setCameraEnabled(_isCameraOn);
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
await _signalRService.invoke('CameraToggle', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall?.peerId ?? '',
|
|
]);
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Switch camera
|
|
Future<void> switchCamera() async {
|
|
if (_currentCall?.type != CallType.video || !_isCameraOn) return;
|
|
await _webrtcService.switchCamera();
|
|
}
|
|
|
|
// ==================== Private Methods ====================
|
|
|
|
Future<void> _initializeWebRTC() async {
|
|
// Reset completer for new initialization
|
|
_webrtcInitCompleter = Completer<void>();
|
|
|
|
try {
|
|
_setupWebRTCCallbacks();
|
|
|
|
// Initialize with timeout protection
|
|
final initFuture = _currentCall!.type == CallType.audio
|
|
? _webrtcService.initializeForAudioCall()
|
|
: _webrtcService.initializeForVideoCall();
|
|
|
|
// Add 10 second timeout for initialization
|
|
await initFuture.timeout(
|
|
const Duration(seconds: 10),
|
|
onTimeout: () {
|
|
throw TimeoutException('WebRTC initialization timed out');
|
|
},
|
|
);
|
|
|
|
if (_currentCall!.type == CallType.audio) {
|
|
_isSpeakerOn = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
// Complete the completer
|
|
if (!_webrtcInitCompleter!.isCompleted) {
|
|
_webrtcInitCompleter!.complete();
|
|
}
|
|
|
|
// Process pending offer if any
|
|
if (_pendingOfferSdp != null) {
|
|
final offerSdp = _pendingOfferSdp!;
|
|
_pendingOfferSdp = null;
|
|
|
|
log('📥 [WEBRTC] Processing pending offer after initialization', name: 'CallManager');
|
|
|
|
try {
|
|
final answer = await _webrtcService.createAnswer(offerSdp);
|
|
|
|
await _signalRService.invoke('AnswerOfferAsync', args: [
|
|
_currentCall!.peerId,
|
|
answer.sdp ?? '',
|
|
_currentCall!.callId,
|
|
]);
|
|
|
|
log('✅ [WEBRTC] Pending offer processed successfully', name: 'CallManager');
|
|
} catch (e, stackTrace) {
|
|
log('❌ [WEBRTC] Error processing pending offer: $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
log('❌ [WEBRTC] Initialization error: $e\nStack: $stackTrace', name: 'CallManager');
|
|
|
|
if (!_webrtcInitCompleter!.isCompleted) {
|
|
_webrtcInitCompleter!.completeError(e, stackTrace);
|
|
}
|
|
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void _setupWebRTCCallbacks() {
|
|
_webrtcService.onIceCandidate = (RTCIceCandidate candidate) {
|
|
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) {
|
|
notifyListeners();
|
|
};
|
|
|
|
_webrtcService.onIceConnectionStateChange = (RTCIceConnectionState state) {
|
|
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
|
_updateCallStatus(CallStatus.connected);
|
|
_startCallDurationTimer();
|
|
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
|
_handleIceConnectionFailure();
|
|
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
|
|
_handleIceDisconnection();
|
|
}
|
|
};
|
|
}
|
|
|
|
void _navigateToCallScreen() {
|
|
final context = navigatorKey.currentContext;
|
|
|
|
if (context == null || _currentCall == null) {
|
|
log('⚠️ [NAV] Cannot navigate - context or call is null', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check if we're already on a call screen to prevent duplicate navigation
|
|
final currentRoute = ModalRoute.of(context);
|
|
if (currentRoute != null && (currentRoute.settings.name == '/audio_call' || currentRoute.settings.name == '/video_call')) {
|
|
log('⚠️ [NAV] Already on call screen, skipping navigation', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
final route = MaterialPageRoute(
|
|
settings: RouteSettings(name: _currentCall?.type == CallType.audio ? '/audio_call' : '/video_call'),
|
|
builder: (context) => _currentCall?.type == CallType.audio ? const AudioCallPage() : const VideoCallPage(),
|
|
);
|
|
|
|
Navigator.of(context).push(route).then((result) {
|
|
// Navigation completed successfully
|
|
log('✅ [NAV] Navigation completed', name: 'CallManager');
|
|
}).catchError((error, stackTrace) {
|
|
log('❌ [NAV] Navigation future error: $error\nStack: $stackTrace', name: 'CallManager');
|
|
});
|
|
} catch (e, stackTrace) {
|
|
log('❌ [NAV] ❌ NAVIGATION EXCEPTION: $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
void _updateCallStatus(CallStatus newStatus) {
|
|
if (_callStatus == newStatus) return;
|
|
_callStatus = newStatus;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _startCallDurationTimer() {
|
|
_callDurationTimer?.cancel();
|
|
_callDuration = Duration.zero;
|
|
|
|
_callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
_callDuration = Duration(seconds: _callDuration.inSeconds + 1);
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
Future<void> _cleanup() async {
|
|
_callTimeoutTimer?.cancel();
|
|
_callDurationTimer?.cancel();
|
|
|
|
// Clear pending state
|
|
_navigationPending = false;
|
|
_pendingCallData = null;
|
|
_pendingOfferSdp = null;
|
|
_webrtcInitCompleter = null;
|
|
|
|
try {
|
|
await _webrtcService.dispose();
|
|
} catch (e) {
|
|
log('⚠️ [CLEANUP] WebRTC dispose error: $e', name: 'CallManager');
|
|
}
|
|
|
|
if (_currentCall != null) {
|
|
try {
|
|
await _callKitService.endCall(_currentCall!.callId);
|
|
} catch (e) {
|
|
log('⚠️ [CLEANUP] CallKit end error: $e', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
_currentCall = null;
|
|
_updateCallStatus(CallStatus.idle);
|
|
_callDuration = Duration.zero;
|
|
_isMuted = false;
|
|
_isSpeakerOn = false;
|
|
_isCameraOn = true;
|
|
_isPeerMuted = false;
|
|
_isPeerCameraOn = true;
|
|
}
|
|
|
|
/// Reset service (for logout or testing)
|
|
Future<void> reset() async {
|
|
await _cleanup();
|
|
|
|
_callKitInitialized = false;
|
|
_handlersRegistered = false;
|
|
_moduleId = null;
|
|
_referenceId = null;
|
|
_conversationId = null;
|
|
_myEmployeeNumber = null;
|
|
}
|
|
|
|
// ==================== SignalR Event Handlers ====================
|
|
|
|
void _handleIncomingCall(List<Object?>? args) {
|
|
if (args == null || args.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final callData = args[0] as Map<String, dynamic>;
|
|
final callId = callData['callId']?.toString() ?? '';
|
|
final callerEmployeeNumber = callData['sourceUserId']?.toString() ?? callData['employeeNumber']?.toString() ?? '';
|
|
final callerName = callData['userName']?.toString() ?? callData['id']?.toString() ?? 'Unknown Caller';
|
|
final isVideoCall = callData['isVideoCall'] as bool? ?? false;
|
|
|
|
if (_currentCall?.callId == callId) {
|
|
return;
|
|
}
|
|
|
|
_currentCall = CallSession(
|
|
callId: callId,
|
|
peerId: callerEmployeeNumber,
|
|
peerName: callerName,
|
|
type: isVideoCall ? CallType.video : CallType.audio,
|
|
direction: CallDirection.incoming,
|
|
startTime: DateTime.now(),
|
|
);
|
|
|
|
_updateCallStatus(CallStatus.incomingRinging);
|
|
} catch (e, stackTrace) {
|
|
log('❌ [EVENT] Failed to set up incoming call: $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
void _handleCallAccepted(List<Object?>? args) async {
|
|
if (_currentCall?.direction != CallDirection.outgoing) {
|
|
return;
|
|
}
|
|
|
|
if (_callStatus != CallStatus.connecting && _callStatus != CallStatus.outgoingRinging) {
|
|
return;
|
|
}
|
|
|
|
if (_callStatus == CallStatus.connecting) {
|
|
_updateCallStatus(CallStatus.outgoingRinging);
|
|
}
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
try {
|
|
final offer = await _webrtcService.createOffer();
|
|
|
|
await _signalRService.invoke('OfferAsync', args: [
|
|
_currentCall!.peerId,
|
|
offer.sdp ?? '',
|
|
_currentCall!.callId,
|
|
]);
|
|
} catch (e, stackTrace) {
|
|
log('❌ [EVENT] Error handling call accepted: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
await _cleanup();
|
|
}
|
|
}
|
|
|
|
void _handleCallDeclined(List<Object?>? args) async {
|
|
String? reason;
|
|
if (args != null && args.isNotEmpty) {
|
|
reason = args[0]?.toString();
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
await _cleanup();
|
|
}
|
|
|
|
void _handleHangUp(List<Object?>? args) async {
|
|
// if (_currentCall != null) {
|
|
// try {
|
|
// await _callKitService.endCall(_currentCall!.callId);
|
|
// } catch (e) {
|
|
// // Ignore errors
|
|
// }
|
|
// }
|
|
|
|
await _cleanup();
|
|
|
|
if (!_signalRService.isConnected) {
|
|
try {
|
|
await _signalRService.ensureConnected();
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleOffer(List<Object?>? args) async {
|
|
if (args == null || args.isEmpty) {
|
|
log('⚠️ [EVENT] Offer received but args empty', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final offerSdp = args[0] as String?;
|
|
if (offerSdp == null || offerSdp.isEmpty) {
|
|
log('⚠️ [EVENT] Offer SDP is null or empty', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
log('📥 [EVENT] Offer received', name: 'CallManager');
|
|
|
|
// If WebRTC is still initializing, queue the offer
|
|
if (_webrtcInitCompleter != null && !_webrtcInitCompleter!.isCompleted) {
|
|
log('⏳ [EVENT] WebRTC still initializing, queuing offer', name: 'CallManager');
|
|
_pendingOfferSdp = offerSdp;
|
|
return;
|
|
}
|
|
|
|
// Wait for initialization to complete with timeout
|
|
if (_webrtcInitCompleter != null) {
|
|
try {
|
|
await _webrtcInitCompleter!.future.timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () {
|
|
throw TimeoutException('Waiting for WebRTC initialization timed out');
|
|
},
|
|
);
|
|
} catch (e) {
|
|
log('❌ [EVENT] Error waiting for WebRTC init: $e', name: 'CallManager');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Verify peer connection is ready
|
|
if (!_webrtcService.isPeerConnectionInitialized) {
|
|
log('⚠️ [EVENT] PeerConnection not initialized, cannot process offer', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
log('🔄 [EVENT] Creating answer for offer', name: 'CallManager');
|
|
final answer = await _webrtcService.createAnswer(offerSdp);
|
|
|
|
log('📤 [EVENT] Sending answer to peer', 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<Object?>? args) async {
|
|
if (args == null || args.isEmpty) {
|
|
log('⚠️ [EVENT] Answer received but args empty', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final answerSdp = args[0] as String?;
|
|
if (answerSdp == null || answerSdp.isEmpty) {
|
|
log('⚠️ [EVENT] Answer SDP is null or empty', name: 'CallManager');
|
|
return;
|
|
}
|
|
|
|
log('📥 [EVENT] Answer received, 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<Object?>? args) async {
|
|
if (args == null || args.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final candidateJson = args[0] as String?;
|
|
if (candidateJson == null) {
|
|
return;
|
|
}
|
|
|
|
final candidateData = jsonDecode(candidateJson) as Map<String, dynamic>;
|
|
final candidate = RTCIceCandidate(
|
|
candidateData['candidate'] as String?,
|
|
candidateData['sdpMid'] as String?,
|
|
candidateData['sdpMLineIndex'] as int?,
|
|
);
|
|
|
|
await _webrtcService.addIceCandidate(candidate);
|
|
} catch (e, stackTrace) {
|
|
log('❌ [EVENT] Error handling ICE candidate: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
}
|
|
}
|
|
|
|
void _handleAudioToggle(List<Object?>? args) {
|
|
_isPeerMuted = !_isPeerMuted;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _handleCameraToggle(List<Object?>? args) {
|
|
_isPeerCameraOn = !_isPeerCameraOn;
|
|
notifyListeners();
|
|
}
|
|
|
|
// ==================== CallKit Event Handlers ====================
|
|
|
|
void _handleCallKitAccepted(String callId) async {
|
|
if (_currentCall?.callId == callId) {
|
|
await acceptCall();
|
|
return;
|
|
}
|
|
|
|
if (_currentCall == null) {
|
|
final pendingData = await PendingCallStorage.getPendingCall();
|
|
|
|
if (pendingData != null && pendingData['callId'] == callId) {
|
|
_pendingCallData = pendingData;
|
|
await acceptCall();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleCallKitDeclined(String callId) async {
|
|
if (_currentCall?.callId == callId) {
|
|
await declineCall('user_declined');
|
|
return;
|
|
}
|
|
|
|
if (_currentCall == null) {
|
|
final pendingData = await PendingCallStorage.getPendingCall();
|
|
if (pendingData != null && pendingData['callId'] == callId) {
|
|
await PendingCallStorage.clearPendingCall();
|
|
await PendingCallStorage.clearAcceptedCallMarker();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleCallKitEnded(String callId) {
|
|
if (_currentCall?.callId == callId) {
|
|
hangUp();
|
|
}
|
|
}
|
|
|
|
void _handleCallKitTimeout(String callId) {
|
|
if (_currentCall?.callId == callId) {
|
|
_handleCallTimeout();
|
|
}
|
|
}
|
|
|
|
// ==================== Additional SignalR Event Handlers ====================
|
|
|
|
void _handleUserOnline(List<Object?>? args) {
|
|
notifyListeners();
|
|
}
|
|
|
|
void _handleUserOffline(List<Object?>? args) {
|
|
notifyListeners();
|
|
}
|
|
|
|
void _handleCallHistoryUpdated(List<Object?>? args) {
|
|
// Forward to ChatProvider if needed
|
|
}
|
|
|
|
void _handleError(List<Object?>? args) {
|
|
if (args != null && args.isNotEmpty) {
|
|
final error = args[0]?.toString() ?? 'Unknown error';
|
|
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(context, 'Call error: $error');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== ICE Connection Failure Handlers ====================
|
|
|
|
/// Handle ICE connection failure
|
|
Future<void> _handleIceConnectionFailure() async {
|
|
try {
|
|
if (_currentCall != null && await _signalRService.ensureConnected()) {
|
|
await _signalRService.invoke('HangUpAsync', args: [
|
|
_myEmployeeNumber ?? '',
|
|
_currentCall!.peerId,
|
|
_moduleId ?? '1',
|
|
]);
|
|
}
|
|
} catch (e) {
|
|
// Ignore errors
|
|
}
|
|
|
|
await _cleanup();
|
|
|
|
final context = navigatorKey.currentContext;
|
|
if (context != null) {
|
|
CallErrorHandler.showGenericError(
|
|
context,
|
|
'Connection failed. Please check your network and try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Handle ICE connection disconnection
|
|
Future<void> _handleIceDisconnection() async {
|
|
await Future.delayed(const Duration(seconds: 5));
|
|
|
|
final state = _webrtcService.getIceConnectionState();
|
|
if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
|
|
await _handleIceConnectionFailure();
|
|
}
|
|
}
|
|
|
|
// ==================== PENDING CALL RESTORATION (Background/Terminated App) ====================
|
|
|
|
/// Check for pending calls on app resume
|
|
Future<void> checkForPendingCalls() async {
|
|
try {
|
|
final pendingCallData = await PendingCallStorage.getPendingCall();
|
|
|
|
if (pendingCallData == null) {
|
|
return;
|
|
}
|
|
|
|
final acceptedCallId = await PendingCallStorage.getAcceptedCallId();
|
|
|
|
if (acceptedCallId == pendingCallData['callId']) {
|
|
_currentCall = CallSession(
|
|
callId: pendingCallData['callId'] as String,
|
|
type: (pendingCallData['isVideoCall'] as bool) ? CallType.video : CallType.audio,
|
|
direction: CallDirection.incoming,
|
|
peerId: pendingCallData['callerId'] as String,
|
|
peerName: pendingCallData['callerName'] as String,
|
|
peerAvatar: null,
|
|
startTime: DateTime.now(),
|
|
);
|
|
|
|
_pendingCallData = pendingCallData;
|
|
|
|
final coordinator = CallCoordinator();
|
|
final initialized = await coordinator.initializeForIncomingCall(
|
|
callerEmployeeNumber: pendingCallData['callerEmployeeNumber'] as String? ?? pendingCallData['callerId'] as String,
|
|
conversationId: pendingCallData['conversationId'] as String?,
|
|
moduleId: pendingCallData['moduleId'] as String?,
|
|
referenceId: pendingCallData['referenceId'] as String?,
|
|
);
|
|
|
|
if (initialized) {
|
|
_navigationPending = true;
|
|
} else {
|
|
await PendingCallStorage.clearPendingCall();
|
|
await PendingCallStorage.clearAcceptedCallMarker();
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
log('❌ [MAIN ISOLATE] Error checking pending calls: $e\nStack: $stackTrace', name: 'CallManager');
|
|
}
|
|
}
|
|
|
|
/// Handle pending navigation when app resumes
|
|
Future<void> handlePendingNavigation() async {
|
|
try {
|
|
if (!_navigationPending) {
|
|
return;
|
|
}
|
|
|
|
if (_currentCall == null) {
|
|
_navigationPending = false;
|
|
return;
|
|
}
|
|
|
|
final hasContext = navigatorKey.currentContext != null;
|
|
final hasState = navigatorKey.currentState != null;
|
|
|
|
if (!hasContext || !hasState) {
|
|
return;
|
|
}
|
|
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
|
|
_navigationPending = false;
|
|
|
|
_navigateToCallScreen();
|
|
|
|
await PendingCallStorage.clearPendingCall();
|
|
await PendingCallStorage.clearAcceptedCallMarker();
|
|
} catch (e, stackTrace) {
|
|
log('❌ [NAVIGATION] Error handling pending navigation: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
_navigationPending = false;
|
|
}
|
|
}
|
|
}
|