|
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:developer';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
import 'package:audio_waveforms/audio_waveforms.dart';
|
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
import 'package:http/http.dart';
|
|
|
|
|
import 'package:intl/intl.dart';
|
|
|
|
|
import 'package:just_audio/just_audio.dart' as JustAudio;
|
|
|
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
|
import 'package:signalr_netcore/hub_connection.dart';
|
|
|
|
|
import 'package:signalr_netcore/signalr_client.dart';
|
|
|
|
|
import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
|
|
|
|
import 'package:test_sa/controllers/api_routes/urls.dart';
|
|
|
|
|
import 'package:test_sa/extensions/string_extensions.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/model/chat_login_response_model.dart';
|
|
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
|
import 'package:flutter/material.dart' as Material;
|
|
|
|
|
import 'package:flutter/material.dart'; // NEW - for WidgetsBinding and showDialog
|
|
|
|
|
|
|
|
|
|
import 'api_client.dart';
|
|
|
|
|
import 'chat_api_client.dart';
|
|
|
|
|
import 'model/chat_attachment_model.dart';
|
|
|
|
|
import 'model/chat_participant_model.dart';
|
|
|
|
|
import 'model/get_search_user_chat_model.dart';
|
|
|
|
|
import 'model/get_single_user_chat_list_model.dart';
|
|
|
|
|
import 'model/unread_message_model.dart';
|
|
|
|
|
import 'model/user_chat_history_model.dart';
|
|
|
|
|
import 'model/call_session.dart';
|
|
|
|
|
import 'call/call_debug_helper.dart';
|
|
|
|
|
import 'call/incoming_call_dialog.dart';
|
|
|
|
|
import 'call/services/webrtc_service.dart';
|
|
|
|
|
import 'call/services/callkit_service.dart'; // Add CallKit service
|
|
|
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
|
|
|
import 'call/call_error_handler.dart';
|
|
|
|
|
|
|
|
|
|
HubConnection? chatHubConnection;
|
|
|
|
|
|
|
|
|
|
class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|
|
|
|
bool isTyping = false;
|
|
|
|
|
|
|
|
|
|
bool chatLoginTokenLoading = false;
|
|
|
|
|
ChatLoginResponse? chatLoginResponse;
|
|
|
|
|
|
|
|
|
|
bool chatParticipantLoading = false;
|
|
|
|
|
ChatParticipantModel? chatParticipantModel;
|
|
|
|
|
|
|
|
|
|
bool userChatHistoryLoading = false;
|
|
|
|
|
|
|
|
|
|
// UserChatHistoryModel? userChatHistory;
|
|
|
|
|
|
|
|
|
|
List<SingleUserChatModel>? userChatHistory;
|
|
|
|
|
|
|
|
|
|
bool messageIsSending = false;
|
|
|
|
|
|
|
|
|
|
List<SingleUserChatModel> chatResponseList = [];
|
|
|
|
|
|
|
|
|
|
Participants? sender;
|
|
|
|
|
Participants? recipient;
|
|
|
|
|
|
|
|
|
|
late String receiverID;
|
|
|
|
|
late int moduleID;
|
|
|
|
|
int? referenceID;
|
|
|
|
|
|
|
|
|
|
// === NEW: CALL STATE VARIABLES ===
|
|
|
|
|
CallStatus callStatus = CallStatus.idle;
|
|
|
|
|
CallSession? currentCall;
|
|
|
|
|
Duration callDuration = Duration.zero;
|
|
|
|
|
Timer? _callDurationTimer;
|
|
|
|
|
Timer? _callTimeoutTimer;
|
|
|
|
|
|
|
|
|
|
// Call control state
|
|
|
|
|
bool isMuted = false;
|
|
|
|
|
bool isSpeakerOn = false;
|
|
|
|
|
bool isCameraOn = true;
|
|
|
|
|
bool isPeerMuted = false;
|
|
|
|
|
bool isPeerCameraOn = true;
|
|
|
|
|
|
|
|
|
|
// NEW: Call handlers registration status
|
|
|
|
|
bool _callHandlersRegistered = false;
|
|
|
|
|
|
|
|
|
|
bool get areCallHandlersRegistered => _callHandlersRegistered;
|
|
|
|
|
|
|
|
|
|
bool get isCallInProgress => callStatus != CallStatus.idle;
|
|
|
|
|
|
|
|
|
|
// NEW: WebRTC service instance
|
|
|
|
|
WebRTCService? _webrtcService;
|
|
|
|
|
|
|
|
|
|
// Public getter for WebRTC service (for video renderers access)
|
|
|
|
|
WebRTCService? get webrtcService => _webrtcService;
|
|
|
|
|
|
|
|
|
|
// NEW: CallKit service instance
|
|
|
|
|
final CallKitService _callKitService = CallKitService();
|
|
|
|
|
bool _callKitInitialized = false;
|
|
|
|
|
|
|
|
|
|
// NEW: Remote stream for audio playback
|
|
|
|
|
MediaStream? _remoteMediaStream;
|
|
|
|
|
|
|
|
|
|
MediaStream? get remoteMediaStream => _remoteMediaStream;
|
|
|
|
|
|
|
|
|
|
// NEW: Ringtone player for outgoing calls
|
|
|
|
|
final JustAudio.AudioPlayer _ringingPlayer = JustAudio.AudioPlayer();
|
|
|
|
|
bool _isRingingPlaying = false;
|
|
|
|
|
|
|
|
|
|
/// OPTIMIZATION: Improved connection disposal to prevent memory leaks
|
|
|
|
|
/// This properly handles errors and ensures connection is always cleaned up
|
|
|
|
|
Future<void> _disposeConnection() async {
|
|
|
|
|
try {
|
|
|
|
|
if (chatHubConnection != null) {
|
|
|
|
|
await chatHubConnection!.stop();
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔌 SignalR connection closed successfully');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error closing SignalR connection: $e');
|
|
|
|
|
}
|
|
|
|
|
// Don't rethrow - we still want to clean up
|
|
|
|
|
} finally {
|
|
|
|
|
chatHubConnection = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reset provider state and properly dispose SignalR connection
|
|
|
|
|
Future<void> reset() async {
|
|
|
|
|
// OPTIMIZATION: Use async/await for proper cleanup
|
|
|
|
|
await _disposeConnection();
|
|
|
|
|
|
|
|
|
|
chatLoginTokenLoading = false;
|
|
|
|
|
chatParticipantLoading = false;
|
|
|
|
|
userChatHistoryLoading = false;
|
|
|
|
|
chatLoginResponse = null;
|
|
|
|
|
chatParticipantModel = null;
|
|
|
|
|
userChatHistory = null;
|
|
|
|
|
sender = null;
|
|
|
|
|
recipient = null;
|
|
|
|
|
ChatApiClient().chatLoginResponse = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// OPTIMIZATION: Override dispose to ensure SignalR connection cleanup
|
|
|
|
|
/// This prevents connection leaks when provider is removed from widget tree
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_disposeConnection().then((_) {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('✅ ChatProvider disposed');
|
|
|
|
|
}
|
|
|
|
|
}).catchError((error) {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error during ChatProvider disposal: $error');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Future<void> getUserAutoLoginToken(int moduleId, int requestId, String title, String myId, String assigneeEmployeeNumber) async {
|
|
|
|
|
// reset();
|
|
|
|
|
// chatLoginTokenLoading = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// chatLoginResponse = await ChatApiClient().getChatLoginToken(moduleId, requestId, title, myId);
|
|
|
|
|
// chatLoginTokenLoading = false;
|
|
|
|
|
// chatParticipantLoading = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// // loadParticipants(moduleId, requestId);
|
|
|
|
|
// if (chatLoginResponse != null) {
|
|
|
|
|
// await loadChatHistory(moduleId, requestId, myId, assigneeEmployeeNumber);
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
Future<void> getUserAutoLoginTokenSilent(int moduleId, int requestId, String title, String myId, String assigneeEmployeeNumber, {bool isMounted = true}) async {
|
|
|
|
|
// OPTIMIZATION: Use async reset for proper cleanup
|
|
|
|
|
await reset();
|
|
|
|
|
receiverID = assigneeEmployeeNumber;
|
|
|
|
|
chatLoginTokenLoading = true;
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
log('i am called ...');
|
|
|
|
|
chatLoginResponse = await ChatApiClient().getChatLoginToken(moduleId, requestId, title, myId, assigneeEmployeeNumber);
|
|
|
|
|
log('✅ Got chatLoginResponse');
|
|
|
|
|
|
|
|
|
|
chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber);
|
|
|
|
|
log('✅ Got chatParticipantModel: ${chatParticipantModel?.toJson()}');
|
|
|
|
|
|
|
|
|
|
// Log available participants for debugging
|
|
|
|
|
log('📋 Available participants: ${chatParticipantModel?.participants?.map((p) => '${p.employeeNumber} (${p.userName})').toList()}');
|
|
|
|
|
log('🔍 Looking for myId: $myId');
|
|
|
|
|
log('🔍 Looking for assigneeEmployeeNumber: $assigneeEmployeeNumber');
|
|
|
|
|
|
|
|
|
|
// Use case-insensitive matching and handle not found gracefully
|
|
|
|
|
try {
|
|
|
|
|
sender = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == myId.toLowerCase());
|
|
|
|
|
log('✅ sender i got is ${sender?.toJson()}');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ Sender NOT FOUND for myId: $myId. Error: $e');
|
|
|
|
|
sender = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
recipient = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber?.toLowerCase() == assigneeEmployeeNumber.toLowerCase());
|
|
|
|
|
log('✅ recipient i got is ${recipient?.toJson()}');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ Recipient NOT FOUND for assigneeEmployeeNumber: $assigneeEmployeeNumber. Error: $e');
|
|
|
|
|
recipient = null;
|
|
|
|
|
}
|
|
|
|
|
} catch (ex) {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error in getUserAutoLoginTokenSilent: $ex');
|
|
|
|
|
}
|
|
|
|
|
log('❌ EXCEPTION in getUserAutoLoginTokenSilent: $ex');
|
|
|
|
|
}
|
|
|
|
|
chatLoginTokenLoading = false;
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Future<void> getUserLoadChatHistory(int moduleId, int requestId, String myId, String assigneeEmployeeNumber) async {
|
|
|
|
|
// await loadChatHistory(moduleId, requestId, myId, assigneeEmployeeNumber);
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// Future<void> loadParticipants(int moduleId, int requestId, String myId, String assigneeEmployeeNumber) async {
|
|
|
|
|
// userChatHistoryLoading = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// try {
|
|
|
|
|
// chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber);
|
|
|
|
|
// } catch (ex) {
|
|
|
|
|
// userChatHistoryLoading = false;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// return;
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// try {
|
|
|
|
|
// sender = chatParticipantModel?.participants?.singleWhere((participant) => participant.employeeNumber == myId);
|
|
|
|
|
// recipient = chatParticipantModel?.participants?.singleWhere((participant) => participant.employeeNumber == assigneeEmployeeNumber);
|
|
|
|
|
// } catch (e) {}
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
Future<void> connectToHub(int moduleId, int requestId, String myId, String assigneeEmployeeNumber, bool readOnly, {bool isMounted = true}) async {
|
|
|
|
|
userChatHistoryLoading = true;
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
moduleID = moduleId;
|
|
|
|
|
referenceID = requestId;
|
|
|
|
|
|
|
|
|
|
// OPTIMIZATION: Wrap connection in try-catch to prevent leaks on error
|
|
|
|
|
if (!readOnly) {
|
|
|
|
|
try {
|
|
|
|
|
await buildHubConnection(chatParticipantModel!.id!.toString());
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Failed to build hub connection: $e');
|
|
|
|
|
}
|
|
|
|
|
// Continue loading chat history even if hub connection fails
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userChatHistory = null;
|
|
|
|
|
userChatHistory = await ChatApiClient().loadChatHistory(moduleId, requestId, myId, assigneeEmployeeNumber);
|
|
|
|
|
chatResponseList = userChatHistory ?? [];
|
|
|
|
|
chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!));
|
|
|
|
|
userChatHistoryLoading = false;
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Future<void> loadChatHistory(int moduleId, int requestId, String myId, String assigneeEmployeeNumber) async {
|
|
|
|
|
// userChatHistoryLoading = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// userChatHistory = await ChatApiClient().loadChatHistory(moduleId, requestId, myId, assigneeEmployeeNumber);
|
|
|
|
|
// chatResponseList = userChatHistory ?? [];
|
|
|
|
|
// chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!));
|
|
|
|
|
// userChatHistoryLoading = false;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
Future<bool> invokeSendMessage(Object object) async {
|
|
|
|
|
messageIsSending = true;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
bool returnStatus = false;
|
|
|
|
|
try {
|
|
|
|
|
await chatHubConnection!.invoke("AddChatUserAsync", args: <Object>[object]);
|
|
|
|
|
returnStatus = true;
|
|
|
|
|
} catch (ex) {}
|
|
|
|
|
|
|
|
|
|
messageIsSending = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
return returnStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Future<bool> sendTextMessage(String message) async {
|
|
|
|
|
// messageIsSending = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// bool returnStatus = false;
|
|
|
|
|
//
|
|
|
|
|
// ChatResponse? chatResponse = await ChatApiClient().sendTextMessage(message, chatParticipantModel!.id!);
|
|
|
|
|
// if (chatResponse != null) {
|
|
|
|
|
// returnStatus = true;
|
|
|
|
|
// // chatResponseList.add(chatResponse);
|
|
|
|
|
// try {
|
|
|
|
|
// chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!));
|
|
|
|
|
// } catch (ex) {}
|
|
|
|
|
// }
|
|
|
|
|
// messageIsSending = false;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// return returnStatus;
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// List<groups.GroupResponse>? uGroups = [], searchGroups = [];
|
|
|
|
|
|
|
|
|
|
// Future<void> getUserAutoLoginToken() async {
|
|
|
|
|
// userLoginToken.UserAutoLoginModel userLoginResponse = await ChatApiClient().getUserLoginToken();
|
|
|
|
|
//
|
|
|
|
|
// if (userLoginResponse.StatusCode == 500) {
|
|
|
|
|
// disbaleChatForThisUser = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// if (userLoginResponse.response != null) {
|
|
|
|
|
// // AppState().setchatUserDetails = userLoginResponse;
|
|
|
|
|
// } else {
|
|
|
|
|
// // AppState().setchatUserDetails = userLoginResponse;
|
|
|
|
|
// userLoginResponse.errorResponses!.first.fieldName.toString() + " Erorr".showToast;
|
|
|
|
|
// disbaleChatForThisUser = true;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
/// OPTIMIZATION: Improved hub connection with error handling
|
|
|
|
|
/// Prevents connection leaks if initialization fails
|
|
|
|
|
Future<void> buildHubConnection(String conversationID) async {
|
|
|
|
|
try {
|
|
|
|
|
// Dispose existing connection if any
|
|
|
|
|
await _disposeConnection();
|
|
|
|
|
|
|
|
|
|
chatHubConnection = await getHubConnection();
|
|
|
|
|
await chatHubConnection!.start();
|
|
|
|
|
|
|
|
|
|
// NEW: Log connection details for diagnostics
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
log('🔌 SignalR Hub Connection: Started', name: 'ChatProvider');
|
|
|
|
|
log('🔑 Connection ID: ${chatHubConnection!.connectionId ?? "NULL"}', name: 'ChatProvider');
|
|
|
|
|
log('👤 User ID: ${chatLoginResponse!.userId}', name: 'ChatProvider');
|
|
|
|
|
log('💬 Conversation ID: $conversationID', name: 'ChatProvider');
|
|
|
|
|
log('📞 My Employee Number: ${sender?.employeeNumber ?? "NOT SET"}', name: 'ChatProvider');
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print("🔌 SignalR Hub Connection: Started");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await chatHubConnection!.invoke("JoinConversation", args: [conversationID]);
|
|
|
|
|
log('✅ Joined conversation: $conversationID', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Register ALL event handlers (chat + call)
|
|
|
|
|
_registerAllEventHandlers();
|
|
|
|
|
|
|
|
|
|
//group On message
|
|
|
|
|
// chatHubConnection.on("OnDeliveredGroupChatHistoryAsync", onGroupMsgReceived);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CONNECTION] Error building SignalR connection: $e', name: 'ChatProvider');
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error building SignalR connection: $e');
|
|
|
|
|
}
|
|
|
|
|
// Clean up on error
|
|
|
|
|
await _disposeConnection();
|
|
|
|
|
rethrow; // Rethrow so caller knows about the error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Register all event handlers (chat + call) - called on initial connection and after reconnect
|
|
|
|
|
void _registerAllEventHandlers() {
|
|
|
|
|
if (chatHubConnection == null) {
|
|
|
|
|
log('⚠️ Cannot register event handlers - connection is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('🔧 [EVENT HANDLERS] Registering all event handlers...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Register chat event handlers
|
|
|
|
|
chatHubConnection!.on("ReceiveMessage", onMsgReceived1);
|
|
|
|
|
chatHubConnection!.on("OnMessageReceivedAsync", onMsgReceived);
|
|
|
|
|
chatHubConnection!.on("OnSubmitChatAsync", onSubmitChatAsync);
|
|
|
|
|
chatHubConnection!.on("OnTypingAsync", OnTypingAsync);
|
|
|
|
|
chatHubConnection!.on("OnStopTypingAsync", OnStopTypingAsync);
|
|
|
|
|
chatHubConnection!.on("OnSeenChatUserAsync", onSeenUserChatAsync);
|
|
|
|
|
chatHubConnection!.on("OnAckSeenAsync", onAckSeenAsync);
|
|
|
|
|
|
|
|
|
|
// Register call history event handler
|
|
|
|
|
chatHubConnection!.on("OnCallHistoryUpdated", _onCallHistoryUpdated);
|
|
|
|
|
|
|
|
|
|
// Register call event handlers
|
|
|
|
|
_registerCallHandlers();
|
|
|
|
|
|
|
|
|
|
log('✅ [EVENT HANDLERS] All event handlers registered successfully', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<HubConnection> getHubConnection() async {
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔧 Creating new SignalR hub connection...');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HubConnection hub;
|
|
|
|
|
HttpConnectionOptions httpOp = HttpConnectionOptions(
|
|
|
|
|
skipNegotiation: false,
|
|
|
|
|
logMessageContent: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
hub = HubConnectionBuilder()
|
|
|
|
|
.withUrl("${URLs.chatHubUrlChat}?UserId=${chatLoginResponse!.userId}&source=Desktop&access_token=${chatLoginResponse!.token}", options: httpOp)
|
|
|
|
|
.withAutomaticReconnect(retryDelays: <int>[2000, 5000, 10000, 20000]).build();
|
|
|
|
|
|
|
|
|
|
// NEW: Add global event logging in debug mode AND re-register handlers on reconnect
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
// Log connection state changes
|
|
|
|
|
hub.onclose(({Exception? error}) {
|
|
|
|
|
log('🔴 [SignalR] Connection closed: $error', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hub.onreconnecting(({Exception? error}) {
|
|
|
|
|
log('🟡 [SignalR] Reconnecting: $error', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hub.onreconnected(({String? connectionId}) async {
|
|
|
|
|
log('🟢 [SignalR] Reconnected: $connectionId', name: 'ChatProvider');
|
|
|
|
|
log('🔄 [SignalR] Re-registering all event handlers after reconnection...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Re-register all event handlers after reconnection
|
|
|
|
|
_registerAllEventHandlers();
|
|
|
|
|
|
|
|
|
|
log('✅ [SignalR] Event handlers re-registered after reconnection', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
print('✅ SignalR hub connection created with debug logging and auto-reregister');
|
|
|
|
|
} else {
|
|
|
|
|
// Production: still need to re-register handlers on reconnect
|
|
|
|
|
hub.onreconnected(({String? connectionId}) async {
|
|
|
|
|
_registerAllEventHandlers();
|
|
|
|
|
});
|
|
|
|
|
print('✅ SignalR hub connection created');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hub;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void registerEvents() {
|
|
|
|
|
// chatHubConnection.on("OnUpdateUserStatusAsync", changeStatus);
|
|
|
|
|
// chatHubConnection.on("OnDeliveredChatUserAsync", onMsgReceived);
|
|
|
|
|
|
|
|
|
|
// chatHubConnection.on("OnSubmitChatAsync", OnSubmitChatAsync);
|
|
|
|
|
// chatHubConnection.on("OnUserTypingAsync", onUserTyping);
|
|
|
|
|
chatHubConnection?.on("OnUserCountAsync", userCountAsync);
|
|
|
|
|
// chatHubConnection.on("OnUpdateUserChatHistoryWindowsAsync", updateChatHistoryWindow);
|
|
|
|
|
// chatHubConnection.on("OnGetUserChatHistoryNotDeliveredAsync", chatNotDelivered);
|
|
|
|
|
// chatHubConnection.on("OnUpdateUserChatHistoryStatusAsync", updateUserChatStatus);
|
|
|
|
|
// chatHubConnection.on("OnGetGroupUserStatusAsync", getGroupUserStatus);
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
|
// {"type":1,"target":"","arguments":[[{"id":217869,"userName":"Sultan.Khan","email":"Sultan.Khan@cloudsolutions.com.sa","phone":null,"title":"Sultan.Khan","userStatus":1,"image":null,"unreadMessageCount":0,"userAction":3,"isPin":false,"isFav":false,"isAdmin":false,"rKey":null,"totalCount":0,"isHuaweiDevice":false,"deviceToken":null},{"id":15153,"userName":"Tamer.Fanasheh","email":"Tamer.F@cloudsolutions.com.sa","phone":null,"title":"Tamer Fanasheh","userStatus":2,"image":null,"unreadMessageCount":0,"userAction":3,"isPin":false,"isFav":false,"isAdmin":true,"rKey":null,"totalCount":0,"isHuaweiDevice":false,"deviceToken":null}]]}
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
// logger.i("All listeners registered");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Future<void> getUserRecentChats() async {
|
|
|
|
|
// ChatUserModel recentChat = await ChatApiClient().getRecentChats();
|
|
|
|
|
// ChatUserModel favUList = await ChatApiClient().getFavUsers();
|
|
|
|
|
// // userGroups = await ChatApiClient().getGroupsByUserId();
|
|
|
|
|
// if (favUList.response != null && recentChat.response != null) {
|
|
|
|
|
// favUsersList = favUList.response!;
|
|
|
|
|
// favUsersList.sort((ChatUser a, ChatUser b) => a.userName!.toLowerCase().compareTo(b.userName!.toLowerCase()));
|
|
|
|
|
// for (dynamic user in recentChat.response!) {
|
|
|
|
|
// for (dynamic favUser in favUList.response!) {
|
|
|
|
|
// if (user.id == favUser.id) {
|
|
|
|
|
// user.isFav = favUser.isFav;
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// pChatHistory = recentChat.response ?? [];
|
|
|
|
|
// uGroups = userGroups.groupresponse ?? [];
|
|
|
|
|
// pChatHistory!.sort((ChatUser a, ChatUser b) => a.userName!.toLowerCase().compareTo(b.userName!.toLowerCase()));
|
|
|
|
|
// searchedChats = pChatHistory;
|
|
|
|
|
// isLoading = false;
|
|
|
|
|
// await invokeUserChatHistoryNotDeliveredAsync(userId: int.parse(AppState().chatDetails!.response!.id.toString()));
|
|
|
|
|
// sort();
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// if (searchedChats!.isNotEmpty || favUsersList.isNotEmpty) {
|
|
|
|
|
// getUserImages();
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
Future invokeUserChatHistoryNotDeliveredAsync({required int userId}) async {
|
|
|
|
|
await chatHubConnection!.invoke("GetUserChatHistoryNotDeliveredAsync", args: [userId]);
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// void getSingleUserChatHistory({required int senderUID, required int receiverUID, required bool loadMore, bool isNewChat = false}) async {
|
|
|
|
|
// isLoading = true;
|
|
|
|
|
// if (isNewChat) userChatHistory = [];
|
|
|
|
|
// if (!loadMore) paginationVal = 0;
|
|
|
|
|
// isChatScreenActive = true;
|
|
|
|
|
// receiverID = receiverUID;
|
|
|
|
|
// Response response = await ChatApiClient().getSingleUserChatHistory(senderUID: senderUID, receiverUID: receiverUID, loadMore: loadMore, paginationVal: paginationVal);
|
|
|
|
|
// if (response.statusCode == 204) {
|
|
|
|
|
// if (isNewChat) {
|
|
|
|
|
// userChatHistory = [];
|
|
|
|
|
// } else if (loadMore) {}
|
|
|
|
|
// } else {
|
|
|
|
|
// if (loadMore) {
|
|
|
|
|
// List<SingleUserChatModel> temp = getSingleUserChatModel(response.body).reversed.toList();
|
|
|
|
|
// userChatHistory.addAll(temp);
|
|
|
|
|
// } else {
|
|
|
|
|
// userChatHistory = getSingleUserChatModel(response.body).reversed.toList();
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// isLoading = false;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
//
|
|
|
|
|
// if (isChatScreenActive && receiverUID == receiverID) {
|
|
|
|
|
// markRead(userChatHistory, receiverUID);
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// generateConvId();
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// void generateConvId() async {
|
|
|
|
|
// Uuid uuid = const Uuid();
|
|
|
|
|
// chatCID = uuid.v4();
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
void markRead(List<SingleUserChatModel> data, String receiverID) {
|
|
|
|
|
for (SingleUserChatModel element in data) {
|
|
|
|
|
// if (AppState().chatDetails!.response!.id! == element.targetUserId) {
|
|
|
|
|
if (element.isSeen != null) {
|
|
|
|
|
if (!element.isSeen!) {
|
|
|
|
|
element.isSeen = true;
|
|
|
|
|
dynamic data = [
|
|
|
|
|
{
|
|
|
|
|
"userChatHistoryId": element.userChatHistoryId,
|
|
|
|
|
"TargetUserId": element.currentUserId == receiverID ? element.currentUserId : element.targetUserId,
|
|
|
|
|
"isDelivered": true,
|
|
|
|
|
"isSeen": true,
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
updateUserChatHistoryStatusAsync(data);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
// }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> resetCount({
|
|
|
|
|
required int moduleId,
|
|
|
|
|
required int referenceNo,
|
|
|
|
|
String? userId,
|
|
|
|
|
}) async {
|
|
|
|
|
try {
|
|
|
|
|
return await ChatApiClient().resetCountApi(moduleId, referenceNo, userId);
|
|
|
|
|
} catch (e, stack) {
|
|
|
|
|
debugPrint('resetCount error: $e');
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void updateUserChatHistoryStatusAsync(List data) {
|
|
|
|
|
try {
|
|
|
|
|
chatHubConnection!.invoke("UpdateUserChatHistoryStatusAsync", args: [data]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void updateUserChatHistoryOnMsg(List data) {
|
|
|
|
|
try {
|
|
|
|
|
chatHubConnection!.invoke("UpdateUserChatHistoryStatusAsync", args: [data]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List<SingleUserChatModel> getSingleUserChatModel(String str) => List<SingleUserChatModel>.from(json.decode(str).map((x) => SingleUserChatModel.fromJson(x)));
|
|
|
|
|
List<SingleUserChatModel> getSingleUserChatModel(String str) {
|
|
|
|
|
final dynamic decodedJson = json.decode(str);
|
|
|
|
|
|
|
|
|
|
// Check if the decoded JSON is already a List
|
|
|
|
|
if (decodedJson is List) {
|
|
|
|
|
return List<SingleUserChatModel>.from(decodedJson.map((x) => SingleUserChatModel.fromJson(x)));
|
|
|
|
|
}
|
|
|
|
|
// If it's a Map (a single object), wrap it in a list
|
|
|
|
|
else if (decodedJson is Map<String, dynamic>) {
|
|
|
|
|
return [SingleUserChatModel.fromJson(decodedJson)];
|
|
|
|
|
}
|
|
|
|
|
// Handle unexpected types
|
|
|
|
|
else {
|
|
|
|
|
throw const FormatException('Expected a JSON object or a list of JSON objects.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List<groupchathistory.GetGroupChatHistoryAsync> getGroupChatHistoryAsync(String str) =>
|
|
|
|
|
// List<groupchathistory.GetGroupChatHistoryAsync>.from(json.decode(str).map((x) => groupchathistory.GetGroupChatHistoryAsync.fromJson(x)));
|
|
|
|
|
//
|
|
|
|
|
Future<dynamic> uploadAttachments(String userId, File file, String fileSource) async {
|
|
|
|
|
dynamic result;
|
|
|
|
|
try {
|
|
|
|
|
Map<String, String> jsonData = {
|
|
|
|
|
"IsContextual": true.toString(),
|
|
|
|
|
"ModuleCode": moduleID.toString(),
|
|
|
|
|
"ReferenceId": referenceID.toString(),
|
|
|
|
|
"ReferenceType": "ticket",
|
|
|
|
|
"ConversationId": chatParticipantModel!.id.toString(),
|
|
|
|
|
"TargetUserId": receiverID,
|
|
|
|
|
"SendMessage": true.toString(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Object? response = await ChatApiClient().uploadMedia(userId, file, fileSource, jsonData: jsonData);
|
|
|
|
|
if (response != null) {
|
|
|
|
|
result = response;
|
|
|
|
|
} else {
|
|
|
|
|
result = [];
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<List<UnReadMessage>> getUnReadMessages(String employeeId) async {
|
|
|
|
|
// employeeId = "FMEngineer";
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
Response response = await ApiClient().getJsonForResponse(
|
|
|
|
|
"${URLs.unreadMessages}?retrieveAll=true",
|
|
|
|
|
headers: {'x-api-key': URLs.chatApiKey, 'x-employee-number': employeeId},
|
|
|
|
|
);
|
|
|
|
|
if (response.statusCode == 200) {
|
|
|
|
|
List data = jsonDecode(response.body);
|
|
|
|
|
return data.map((elemet) => UnReadMessage.fromJson(elemet)).toList();
|
|
|
|
|
} else {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// void updateUserChatStatus(List<Object?>? args) {
|
|
|
|
|
// dynamic items = args!.toList();
|
|
|
|
|
// for (var cItem in items[0]) {
|
|
|
|
|
// for (SingleUserChatModel chat in userChatHistory) {
|
|
|
|
|
// if (cItem["contantNo"].toString() == chat.contantNo.toString()) {
|
|
|
|
|
// chat.isSeen = cItem["isSeen"];
|
|
|
|
|
// chat.isDelivered = cItem["isDelivered"];
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
void getGroupUserStatus(List<Object?>? args) {
|
|
|
|
|
//note: need to implement this function...
|
|
|
|
|
print(args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> markMessageAsRead(int messageId) async {
|
|
|
|
|
final senderId = sender?.userId;
|
|
|
|
|
if (senderId == null) return;
|
|
|
|
|
chatHubConnection?.invoke(
|
|
|
|
|
"SendMessageReadAsync",
|
|
|
|
|
args: [messageId, senderId],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void onChatSeen(List<Object?>? args) {
|
|
|
|
|
dynamic items = args!.toList();
|
|
|
|
|
// for (var user in searchedChats!) {
|
|
|
|
|
// if (user.id == items.first["id"]) {
|
|
|
|
|
// user.userStatus = items.first["userStatus"];
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void userCountAsync(List<Object?>? args) {
|
|
|
|
|
dynamic items = args!.toList();
|
|
|
|
|
// logger.d(items);
|
|
|
|
|
//logger.d("---------------------------------User Count Async -------------------------------------");
|
|
|
|
|
//logger.d(items);
|
|
|
|
|
// for (var user in searchedChats!) {
|
|
|
|
|
// if (user.id == items.first["id"]) {
|
|
|
|
|
// user.userStatus = items.first["userStatus"];
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// void updateChatHistoryWindow(List<Object?>? args) {
|
|
|
|
|
// dynamic items = args!.toList();
|
|
|
|
|
// if (kDebugMode) {
|
|
|
|
|
// logger.i("---------------------------------Update Chat History Windows Async -------------------------------------");
|
|
|
|
|
// }
|
|
|
|
|
// logger.d(items);
|
|
|
|
|
// // for (var user in searchedChats!) {
|
|
|
|
|
// // if (user.id == items.first["id"]) {
|
|
|
|
|
// // user.userStatus = items.first["userStatus"];
|
|
|
|
|
// // }
|
|
|
|
|
// // }
|
|
|
|
|
// // notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// void chatNotDelivered(List<Object?>? args) {
|
|
|
|
|
// dynamic items = args!.toList();
|
|
|
|
|
// for (dynamic item in items[0]) {
|
|
|
|
|
// for (ChatUser element in searchedChats!) {
|
|
|
|
|
// if (element.id == item["currentUserId"]) {
|
|
|
|
|
// int? val = element.unreadMessageCount ?? 0;
|
|
|
|
|
// element.unreadMessageCount = val! + 1;
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// void changeStatus(List<Object?>? args) {
|
|
|
|
|
// dynamic items = args!.toList();
|
|
|
|
|
// for (ChatUser user in searchedChats!) {
|
|
|
|
|
// if (user.id == items.first["id"]) {
|
|
|
|
|
// user.userStatus = items.first["userStatus"];
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// if (teamMembersList.isNotEmpty) {
|
|
|
|
|
// for (ChatUser user in teamMembersList!) {
|
|
|
|
|
// if (user.id == items.first["id"]) {
|
|
|
|
|
// user.userStatus = items.first["userStatus"];
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// void filter(String value) async {
|
|
|
|
|
// List<ChatUser>? tmp = [];
|
|
|
|
|
// if (value.isEmpty || value == "") {
|
|
|
|
|
// tmp = pChatHistory;
|
|
|
|
|
// } else {
|
|
|
|
|
// for (ChatUser element in pChatHistory!) {
|
|
|
|
|
// if (element.userName!.toLowerCase().contains(value.toLowerCase())) {
|
|
|
|
|
// tmp.add(element);
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// searchedChats = tmp;
|
|
|
|
|
// notifyListeners();
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
Timer? timer;
|
|
|
|
|
|
|
|
|
|
Future<void> OnTypingAsync(List<Object?>? parameters) async {
|
|
|
|
|
// String empId = parameters!.first as String;
|
|
|
|
|
isTyping = true;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
if (timer?.isActive ?? false) {
|
|
|
|
|
timer!.cancel();
|
|
|
|
|
}
|
|
|
|
|
timer = Timer(const Duration(milliseconds: 2500), () {
|
|
|
|
|
isTyping = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> OnStopTypingAsync(List<Object?>? parameters) async {
|
|
|
|
|
if (timer?.isActive ?? false) {
|
|
|
|
|
timer!.cancel();
|
|
|
|
|
}
|
|
|
|
|
isTyping = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> onSeenUserChatAsync(List<Object?>? parameters) async {
|
|
|
|
|
try {
|
|
|
|
|
if (parameters == null || parameters.isEmpty) {
|
|
|
|
|
log('onSeenUserChatAsync: parameters are null or empty');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final parm = parameters.first;
|
|
|
|
|
if (parm is! List || parm.isEmpty) {
|
|
|
|
|
log('onSeenUserChatAsync: parm is not a valid list');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final firstItem = parm.first;
|
|
|
|
|
if (firstItem is! Map<String, dynamic>) {
|
|
|
|
|
log('onSeenUserChatAsync: firstItem is not a Map');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
"AckSeenAsync",
|
|
|
|
|
args: [
|
|
|
|
|
firstItem['currentUserId'],
|
|
|
|
|
[firstItem['userChatHistoryId']],
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('onSeenUserChatAsync error: $e');
|
|
|
|
|
log('StackTrace: $stackTrace');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> onAckSeenAsync(List<Object?>? parameters) async {
|
|
|
|
|
try {
|
|
|
|
|
if (parameters == null || parameters.isEmpty) {
|
|
|
|
|
log('onAckSeenAsync: parameters are null or empty');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final parm = parameters.first;
|
|
|
|
|
log('parm onAckSeenAsync $parm');
|
|
|
|
|
if (chatResponseList.isEmpty) {
|
|
|
|
|
log('onAckSeenAsync: chatResponseList is empty');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
log('last list item id ${chatResponseList.first.toJson()}');
|
|
|
|
|
chatResponseList.first.isSeen = true;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('onAckSeenAsync error: $e');
|
|
|
|
|
log('StackTrace: $stackTrace');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> onSubmitChatAsync(List<Object?>? parameters) async {
|
|
|
|
|
if (kDebugMode) print("OnSubmitChatAsync:$parameters");
|
|
|
|
|
|
|
|
|
|
if (parameters == null || parameters.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
for (dynamic msg in parameters) {
|
|
|
|
|
var data = getSingleUserChatModel(jsonEncode(msg));
|
|
|
|
|
if (kDebugMode) print('Parsed message: $data');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (kDebugMode) print('Error in OnSubmitChatAsync: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> onMsgReceived1(List<Object?>? parameters) async {
|
|
|
|
|
print("onMsgReceived1:$parameters");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> onMsgReceived(List<Object?>? parameters) async {
|
|
|
|
|
List<SingleUserChatModel> data = [];
|
|
|
|
|
print("OnMessageReceivedAsync:$parameters");
|
|
|
|
|
for (dynamic msg in parameters!) {
|
|
|
|
|
data = getSingleUserChatModel(jsonEncode(msg));
|
|
|
|
|
// ...existing code...
|
|
|
|
|
}
|
|
|
|
|
// ...existing code...
|
|
|
|
|
userChatHistory?.insert(0, data.first);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
// ...existing code...
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==================== CALL INFRASTRUCTURE ====================
|
|
|
|
|
// All call-related methods are placed at the end to avoid modifying existing chat logic
|
|
|
|
|
|
|
|
|
|
/// Start a new audio or video call
|
|
|
|
|
Future<void> startCall(Participants recipient, CallType callType) async {
|
|
|
|
|
log('🔵 [CALL] startCall() invoked', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Current callStatus: $callStatus', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Get context for error dialogs
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
if (context == null) {
|
|
|
|
|
log('❌ [CALL] No context available', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if already in a call
|
|
|
|
|
if (callStatus != CallStatus.idle) {
|
|
|
|
|
log('⚠️ [CALL] Cannot start call - already in a call (status: $callStatus)', name: 'ChatProvider');
|
|
|
|
|
CallErrorHandler.showCallAlreadyInProgress(context);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sender == null) {
|
|
|
|
|
log('❌ [CALL] Cannot start call - sender is null', name: 'ChatProvider');
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Unable to start call. Please try again.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('🔵 [CALL] Sender: ${sender!.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
callStatus = CallStatus.checkingPermissions;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('🔵 [CALL] Status changed to: checkingPermissions', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Generate unique call ID
|
|
|
|
|
final callId = const Uuid().v4();
|
|
|
|
|
final isVideo = callType == CallType.video;
|
|
|
|
|
log('🔵 [CALL] Generated callId: $callId', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Call type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Request permissions
|
|
|
|
|
log('🔵 [CALL] Requesting microphone permission...', name: 'ChatProvider');
|
|
|
|
|
final micStatus = await Permission.microphone.request();
|
|
|
|
|
log('🔵 [CALL] Microphone permission status: ${micStatus.name}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (!micStatus.isGranted) {
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('❌ [CALL] Microphone permission denied - aborting call', name: 'ChatProvider');
|
|
|
|
|
CallErrorHandler.showMicrophonePermissionDenied(context);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isVideo) {
|
|
|
|
|
log('🔵 [CALL] Requesting camera permission...', name: 'ChatProvider');
|
|
|
|
|
final cameraStatus = await Permission.camera.request();
|
|
|
|
|
log('🔵 [CALL] Camera permission status: ${cameraStatus.name}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (!cameraStatus.isGranted) {
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('❌ [CALL] Camera permission denied - aborting call', name: 'ChatProvider');
|
|
|
|
|
CallErrorHandler.showCameraPermissionDenied(context);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check SignalR connection before proceeding
|
|
|
|
|
if (chatHubConnection == null || chatHubConnection!.state != HubConnectionState.Connected) {
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('❌ [CALL] SignalR not connected', name: 'ChatProvider');
|
|
|
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create call session
|
|
|
|
|
log('🔵 [CALL] Creating CallSession...', name: 'ChatProvider');
|
|
|
|
|
currentCall = CallSession(
|
|
|
|
|
callId: callId,
|
|
|
|
|
type: callType,
|
|
|
|
|
direction: CallDirection.outgoing,
|
|
|
|
|
peerId: recipient.employeeNumber ?? '',
|
|
|
|
|
peerName: recipient.userName ?? 'Unknown',
|
|
|
|
|
peerAvatar: recipient.image,
|
|
|
|
|
startTime: DateTime.now(),
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL] CallSession created - peerId: ${currentCall!.peerId}, peerName: ${currentCall!.peerName}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Invoke SignalR CallUserAsync
|
|
|
|
|
log('🔵 [CALL] Invoking CallUserAsync with args:', name: 'ChatProvider');
|
|
|
|
|
log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
log(' - target: ${recipient.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
log(' - isVideoCall: $isVideo', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await chatHubConnection?.invoke(
|
|
|
|
|
'CallUserAsync',
|
|
|
|
|
args: [
|
|
|
|
|
sender!.employeeNumber ?? '',
|
|
|
|
|
recipient.employeeNumber ?? '',
|
|
|
|
|
isVideo,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL] CallUserAsync invoked successfully', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CALL] Failed to invoke CallUserAsync: $e', name: 'ChatProvider');
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Failed to start call. Please check your connection and try again.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
callStatus = CallStatus.outgoingRinging;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('🔵 [CALL] Status changed to: outgoingRinging', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// TODO: Play outgoing ringtone (commented for now)
|
|
|
|
|
// await _playOutgoingRingtone();
|
|
|
|
|
|
|
|
|
|
// Initialize WebRTC now but DON'T send offer yet - wait for call to be accepted
|
|
|
|
|
log('🔧 [CALL] Initializing WebRTC (offer will be sent after accept)...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
_webrtcService = WebRTCService();
|
|
|
|
|
_setupWebRTCCallbacks();
|
|
|
|
|
|
|
|
|
|
if (callType == CallType.audio) {
|
|
|
|
|
await _webrtcService!.initializeForAudioCall();
|
|
|
|
|
} else {
|
|
|
|
|
await _webrtcService!.initializeForVideoCall(); // Use video initialization for video calls
|
|
|
|
|
}
|
|
|
|
|
log('✅ [CALL] WebRTC initialized, waiting for peer to accept...', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
|
|
|
|
// Cleanup and notify peer
|
|
|
|
|
await chatHubConnection?.invoke('HangUpAsync', args: [
|
|
|
|
|
sender!.employeeNumber ?? '',
|
|
|
|
|
recipient.employeeNumber ?? '',
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
]).catchError((err) {
|
|
|
|
|
log('❌ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
CallErrorHandler.showWebRTCInitFailed(context);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Navigate to call screen
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
log('🔵 [CALL] Navigating to call screen...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Double check currentCall is still valid before navigation
|
|
|
|
|
if (currentCall == null) {
|
|
|
|
|
log('❌ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => currentCall!.type == CallType.audio
|
|
|
|
|
? const AudioCallPage()
|
|
|
|
|
: const VideoCallPage(),
|
|
|
|
|
),
|
|
|
|
|
).then((_) {
|
|
|
|
|
log('✅ [CALL] Navigation completed', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Navigation error: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL] Navigated to call screen', name: 'ChatProvider');
|
|
|
|
|
} else {
|
|
|
|
|
log('⚠️ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Alternative: Use WidgetsBinding to schedule navigation after frame
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
// Wait for app to fully come to foreground
|
|
|
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
|
|
|
final ctx = navigatorKey.currentContext;
|
|
|
|
|
if (ctx != null && ctx.mounted) {
|
|
|
|
|
log('🔵 [CALL] Navigating via post-frame callback...', name: 'ChatProvider');
|
|
|
|
|
Navigator.of(ctx).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => currentCall!.type == CallType.audio
|
|
|
|
|
? const AudioCallPage()
|
|
|
|
|
: const VideoCallPage(),
|
|
|
|
|
),
|
|
|
|
|
).then((_) {
|
|
|
|
|
log('✅ [CALL] Post-frame navigation completed', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Post-frame navigation error: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
log('❌ [CALL] Still no context available - navigation failed', name: 'ChatProvider');
|
|
|
|
|
// Last resort: dismiss CallKit and clean up
|
|
|
|
|
_callKitService.endCall(currentCall!.callId);
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for caller to send SDP offer via OnOfferAsync event
|
|
|
|
|
log('⏳ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] Error starting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('❌ Error starting call: $e');
|
|
|
|
|
}
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
// Show appropriate error dialog
|
|
|
|
|
if (context.mounted) {
|
|
|
|
|
if (e.toString().contains('WebRTC') || e.toString().contains('getUserMedia')) {
|
|
|
|
|
CallErrorHandler.showWebRTCInitFailed(context);
|
|
|
|
|
} else if (e.toString().contains('SignalR') || e.toString().contains('connection')) {
|
|
|
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
|
|
|
} else {
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Failed to start call. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Play outgoing ringtone
|
|
|
|
|
Future<void> _playOutgoingRingtone() async {
|
|
|
|
|
if (_isRingingPlaying) {
|
|
|
|
|
log('⚠️ [RINGTONE] Already playing', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
log('🔔 [RINGTONE] Starting outgoing ringtone...', name: 'ChatProvider');
|
|
|
|
|
await _ringingPlayer.setAsset('assets/audio/outgoing_ringtone.mp3');
|
|
|
|
|
await _ringingPlayer.setLoopMode(LoopMode.one); // Loop the ringtone
|
|
|
|
|
await _ringingPlayer.play();
|
|
|
|
|
_isRingingPlaying = true;
|
|
|
|
|
log('✅ [RINGTONE] Outgoing ringtone playing', name: 'ChatProvider');
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔔 Outgoing ringtone playing');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [RINGTONE] Error playing ringtone: $e', name: 'ChatProvider');
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('❌ Error playing ringtone: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stop outgoing ringtone
|
|
|
|
|
Future<void> _stopOutgoingRingtone() async {
|
|
|
|
|
if (!_isRingingPlaying) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
log('🔕 [RINGTONE] Stopping outgoing ringtone...', name: 'ChatProvider');
|
|
|
|
|
await _ringingPlayer.stop();
|
|
|
|
|
await _ringingPlayer.pause();
|
|
|
|
|
await _ringingPlayer.seek(Duration.zero);
|
|
|
|
|
_isRingingPlaying = false;
|
|
|
|
|
log('✅ [RINGTONE] Ringtone stopped', name: 'ChatProvider');
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔕 Ringtone stopped');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ [RINGTONE] Error stopping ringtone: $e', name: 'ChatProvider');
|
|
|
|
|
// Force stop even on error
|
|
|
|
|
_isRingingPlaying = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Toggle microphone mute/unmute
|
|
|
|
|
Future<void> toggleMute() async {
|
|
|
|
|
log('🎤 [CALL CONTROL] toggleMute() called', name: 'ChatProvider');
|
|
|
|
|
log('🎤 [CALL CONTROL] Current mute state: $isMuted', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toggle local mute state
|
|
|
|
|
isMuted = !isMuted;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL CONTROL] Mute state changed to: $isMuted', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Update WebRTC audio track
|
|
|
|
|
_webrtcService!.setMicrophoneMuted(isMuted);
|
|
|
|
|
|
|
|
|
|
// Notify peer via SignalR
|
|
|
|
|
if (chatHubConnection?.state == HubConnectionState.Connected &&
|
|
|
|
|
sender != null &&
|
|
|
|
|
currentCall != null) {
|
|
|
|
|
try {
|
|
|
|
|
log('🔔 [CALL CONTROL] Notifying peer of mute toggle...', name: 'ChatProvider');
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'AudioToggle',
|
|
|
|
|
args: [sender!.employeeNumber ?? '', currentCall!.peerId],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL CONTROL] Peer notified of mute toggle', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CALL CONTROL] Error notifying peer of mute: $e', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🎤 Microphone ${isMuted ? "muted" : "unmuted"}');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Toggle speakerphone on/off
|
|
|
|
|
Future<void> toggleSpeaker() async {
|
|
|
|
|
log('🔊 [CALL CONTROL] toggleSpeaker() called', name: 'ChatProvider');
|
|
|
|
|
log('🔊 [CALL CONTROL] Current speaker state: $isSpeakerOn', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toggle speaker state
|
|
|
|
|
isSpeakerOn = !isSpeakerOn;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL CONTROL] Speaker state changed to: $isSpeakerOn', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Update audio output
|
|
|
|
|
await _webrtcService!.setSpeakerphoneEnabled(isSpeakerOn);
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔊 Speakerphone ${isSpeakerOn ? "on" : "off"}');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Toggle camera on/off (video calls only)
|
|
|
|
|
Future<void> toggleCamera() async {
|
|
|
|
|
log('📹 [CALL CONTROL] toggleCamera() called', name: 'ChatProvider');
|
|
|
|
|
log('📹 [CALL CONTROL] Current camera state: $isCameraOn', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentCall?.type != CallType.video) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] Not a video call', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toggle local camera state
|
|
|
|
|
isCameraOn = !isCameraOn;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL CONTROL] Camera state changed to: $isCameraOn', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Update WebRTC video track
|
|
|
|
|
_webrtcService!.setCameraEnabled(isCameraOn);
|
|
|
|
|
|
|
|
|
|
// Notify peer via SignalR
|
|
|
|
|
if (chatHubConnection?.state == HubConnectionState.Connected &&
|
|
|
|
|
sender != null &&
|
|
|
|
|
currentCall != null) {
|
|
|
|
|
try {
|
|
|
|
|
log('🔔 [CALL CONTROL] Notifying peer of camera toggle...', name: 'ChatProvider');
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'CameraToggle',
|
|
|
|
|
args: [sender!.employeeNumber ?? '', currentCall!.peerId],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL CONTROL] Peer notified of camera toggle', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CALL CONTROL] Error notifying peer of camera toggle: $e', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('📹 Camera ${isCameraOn ? "on" : "off"}');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Switch between front and rear camera (video calls only)
|
|
|
|
|
Future<void> switchCamera() async {
|
|
|
|
|
log('🔄 [CALL CONTROL] switchCamera() called', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] WebRTC service is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentCall?.type != CallType.video) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] Not a video call', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isCameraOn) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] Camera is off, cannot switch', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await _webrtcService!.switchCamera();
|
|
|
|
|
log('✅ [CALL CONTROL] Camera switched', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🔄 Camera switched');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CALL CONTROL] Error switching camera: $e', name: 'ChatProvider');
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('❌ Error switching camera: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// End the current call (hang up)
|
|
|
|
|
Future<void> hangUp() async {
|
|
|
|
|
log('📞 [CALL CONTROL] hangUp() called', name: 'ChatProvider');
|
|
|
|
|
log('📞 [CALL CONTROL] Current status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
log('📞 [CALL CONTROL] Current call: ${currentCall?.callId}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (currentCall == null) {
|
|
|
|
|
log('⚠️ [CALL CONTROL] No active call to hang up', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// End CallKit UI first
|
|
|
|
|
try {
|
|
|
|
|
await _callKitService.endCall(currentCall!.callId);
|
|
|
|
|
log('✅ [CallKit] Native call UI ended', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ [CallKit] Error ending native UI: $e', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop ringtone if playing
|
|
|
|
|
await _stopOutgoingRingtone();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Invoke SignalR HangUpAsync
|
|
|
|
|
if (chatHubConnection?.state == HubConnectionState.Connected && sender != null) {
|
|
|
|
|
log('🔔 [CALL CONTROL] Invoking HangUpAsync with args:', name: 'ChatProvider');
|
|
|
|
|
log(' - source: ${sender!.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
log(' - target: ${currentCall!.peerId}', name: 'ChatProvider');
|
|
|
|
|
log(' - moduleCode: $moduleID', name: 'ChatProvider');
|
|
|
|
|
log(' - referenceId: $referenceID', name: 'ChatProvider');
|
|
|
|
|
log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'HangUpAsync',
|
|
|
|
|
args: <Object>[
|
|
|
|
|
sender!.employeeNumber ?? '',
|
|
|
|
|
currentCall!.peerId,
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL CONTROL] HangUpAsync invoked successfully', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL CONTROL] Error invoking HangUpAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error hanging up call: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Teardown call resources
|
|
|
|
|
_teardownCall();
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('📞 Call ended');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle call timeout (60s no answer)
|
|
|
|
|
void _handleCallTimeout() {
|
|
|
|
|
log('⏱️ [CALL] _handleCallTimeout() - Call timeout after 60s', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Current status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⏱️ Call timeout - no answer');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Invoke CallMissedAsync
|
|
|
|
|
log('🔵 [CALL] Invoking CallMissedAsync with args:', name: 'ChatProvider');
|
|
|
|
|
log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
log(' - target: ${currentCall?.peerId}', name: 'ChatProvider');
|
|
|
|
|
log(' - moduleCode: $moduleID', name: 'ChatProvider');
|
|
|
|
|
log(' - referenceId: ${referenceID?.toString() ?? "null"}', name: 'ChatProvider');
|
|
|
|
|
log(' - conversationId: ${chatParticipantModel?.id?.toString() ?? "null"}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
chatHubConnection?.invoke(
|
|
|
|
|
'CallMissedAsync',
|
|
|
|
|
args: [
|
|
|
|
|
sender?.employeeNumber ?? '',
|
|
|
|
|
currentCall?.peerId ?? '',
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
],
|
|
|
|
|
).then((_) {
|
|
|
|
|
log('✅ [CALL] CallMissedAsync invoked successfully', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Error invoking CallMissedAsync: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Clean up call resources
|
|
|
|
|
void _teardownCall() {
|
|
|
|
|
log('🧹 [CALL] _teardownCall() - Cleaning up call resources', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Previous status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Call duration: ${callDuration.inSeconds}s', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// End CallKit UI if we have an active call
|
|
|
|
|
if (currentCall != null) {
|
|
|
|
|
try {
|
|
|
|
|
_callKitService.endCall(currentCall!.callId);
|
|
|
|
|
log('✅ [CallKit] Native call UI ended in teardown', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ [CallKit] Error ending native UI in teardown: $e', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop outgoing ringtone if playing
|
|
|
|
|
_stopOutgoingRingtone();
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
log('🔵 [CALL] Timeout timer cancelled', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
_callDurationTimer?.cancel();
|
|
|
|
|
log('🔵 [CALL] Duration timer cancelled', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Dispose WebRTC service to release media resources
|
|
|
|
|
if (_webrtcService != null) {
|
|
|
|
|
log('🔧 [CALL] Disposing WebRTC service...', name: 'ChatProvider');
|
|
|
|
|
_webrtcService!.dispose().then((_) {
|
|
|
|
|
log('✅ [CALL] WebRTC service disposed', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('⚠️ [CALL] Error disposing WebRTC service: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
_webrtcService = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
callDuration = Duration.zero;
|
|
|
|
|
isMuted = false;
|
|
|
|
|
isSpeakerOn = false;
|
|
|
|
|
isCameraOn = true;
|
|
|
|
|
isPeerMuted = false;
|
|
|
|
|
isPeerCameraOn = true;
|
|
|
|
|
_remoteMediaStream = null;
|
|
|
|
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('✅ [CALL] Call resources cleaned up - status reset to idle', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('🧹 Call resources cleaned up');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start call duration timer when connected
|
|
|
|
|
void _startCallDurationTimer() {
|
|
|
|
|
log('⏱️ [CALL] _startCallDurationTimer() - Starting call duration counter', name: 'ChatProvider');
|
|
|
|
|
_callDurationTimer?.cancel();
|
|
|
|
|
callDuration = Duration.zero;
|
|
|
|
|
|
|
|
|
|
_callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
|
|
|
callDuration = Duration(seconds: callDuration.inSeconds + 1);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
log('✅ [CALL] Duration timer started', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⏱️ Call duration timer started');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Accept incoming call
|
|
|
|
|
Future<void> acceptCall() async {
|
|
|
|
|
log('🔵 [CALL] acceptCall() called', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CALL] Current status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (currentCall == null || callStatus != CallStatus.incomingRinging) {
|
|
|
|
|
log('⚠️ [CALL] Cannot accept - no incoming call or wrong status', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IMPORTANT: Dismiss CallKit UI first
|
|
|
|
|
try {
|
|
|
|
|
await _callKitService.setCallConnected(currentCall!.callId);
|
|
|
|
|
log('✅ [CallKit] Native UI updated to connected state', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('⚠️ [CallKit] Error updating to connected state: $e', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get context - we'll retry if null
|
|
|
|
|
BuildContext? context = navigatorKey.currentContext;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Check microphone permission
|
|
|
|
|
log('🔵 [CALL] Checking microphone permission...', name: 'ChatProvider');
|
|
|
|
|
final micStatus = await Permission.microphone.request();
|
|
|
|
|
if (!micStatus.isGranted) {
|
|
|
|
|
log('❌ [CALL] Microphone permission denied - declining call', name: 'ChatProvider');
|
|
|
|
|
await declineCall('permission_denied');
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showMicrophonePermissionDenied(context);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check camera permission for video calls
|
|
|
|
|
if (currentCall!.type == CallType.video) {
|
|
|
|
|
log('🔵 [CALL] Checking camera permission...', name: 'ChatProvider');
|
|
|
|
|
final cameraStatus = await Permission.camera.request();
|
|
|
|
|
if (!cameraStatus.isGranted) {
|
|
|
|
|
log('❌ [CALL] Camera permission denied - declining call', name: 'ChatProvider');
|
|
|
|
|
await declineCall('permission_denied');
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showCameraPermissionDenied(context);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel timeout timer
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
// Update status to connecting
|
|
|
|
|
callStatus = CallStatus.connecting;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('🔵 [CALL] Status changed to: connecting', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Invoke AnswerCallAsync on SignalR FIRST
|
|
|
|
|
if (chatHubConnection?.state != HubConnectionState.Connected) {
|
|
|
|
|
log('❌ [CALL] SignalR not connected', name: 'ChatProvider');
|
|
|
|
|
_teardownCall();
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final myEmployeeNumber = sender?.employeeNumber;
|
|
|
|
|
if (myEmployeeNumber == null || myEmployeeNumber.isEmpty) {
|
|
|
|
|
log('❌ [CALL] No employee number found', name: 'ChatProvider');
|
|
|
|
|
_teardownCall();
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Unable to accept call. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('🔵 [CALL] Invoking AnswerCallAsync with args:', name: 'ChatProvider');
|
|
|
|
|
log(' - source: $myEmployeeNumber', name: 'ChatProvider');
|
|
|
|
|
log(' - target: ${currentCall!.peerId}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'AnswerCallAsync',
|
|
|
|
|
args: <Object>[
|
|
|
|
|
myEmployeeNumber,
|
|
|
|
|
currentCall!.peerId,
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL] AnswerCallAsync invoked successfully', name: 'ChatProvider');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log('❌ [CALL] Failed to invoke AnswerCallAsync: $e', name: 'ChatProvider');
|
|
|
|
|
_teardownCall();
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize WebRTC and setup callbacks
|
|
|
|
|
log('🔧 [CALL] Initializing WebRTC for incoming call...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
_webrtcService = WebRTCService();
|
|
|
|
|
_setupWebRTCCallbacks();
|
|
|
|
|
|
|
|
|
|
if (currentCall!.type == CallType.audio) {
|
|
|
|
|
await _webrtcService!.initializeForAudioCall();
|
|
|
|
|
} else {
|
|
|
|
|
await _webrtcService!.initializeForVideoCall();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL] WebRTC initialized', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] WebRTC initialization failed: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
|
|
|
|
// Cleanup and notify peer
|
|
|
|
|
await chatHubConnection?.invoke('HangUpAsync', args: [
|
|
|
|
|
myEmployeeNumber,
|
|
|
|
|
currentCall!.peerId,
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
]).catchError((err) {
|
|
|
|
|
log('❌ [CALL] Failed to send hangup after WebRTC init failure: $err', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_teardownCall();
|
|
|
|
|
// Retry getting context
|
|
|
|
|
context = navigatorKey.currentContext;
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showWebRTCInitFailed(context);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait a moment for the app to come to foreground if needed
|
|
|
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
|
|
|
|
|
|
|
|
// Retry getting context after delay
|
|
|
|
|
context = navigatorKey.currentContext;
|
|
|
|
|
|
|
|
|
|
// Navigate to call screen
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
log('🔵 [CALL] Navigating to call screen...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Double check currentCall is still valid before navigation
|
|
|
|
|
if (currentCall == null) {
|
|
|
|
|
log('❌ [CALL] Cannot navigate - currentCall is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => currentCall!.type == CallType.audio
|
|
|
|
|
? const AudioCallPage()
|
|
|
|
|
: const VideoCallPage(),
|
|
|
|
|
),
|
|
|
|
|
).then((_) {
|
|
|
|
|
log('✅ [CALL] Navigation completed', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Navigation error: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL] Navigated to call screen', name: 'ChatProvider');
|
|
|
|
|
} else {
|
|
|
|
|
log('⚠️ [CALL] No context for navigation - will try alternative approach', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Alternative: Use WidgetsBinding to schedule navigation after frame
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
// Wait for app to fully come to foreground
|
|
|
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
|
|
|
final ctx = navigatorKey.currentContext;
|
|
|
|
|
if (ctx != null && ctx.mounted) {
|
|
|
|
|
log('🔵 [CALL] Navigating via post-frame callback...', name: 'ChatProvider');
|
|
|
|
|
Navigator.of(ctx).push(
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => currentCall!.type == CallType.audio
|
|
|
|
|
? const AudioCallPage()
|
|
|
|
|
: const VideoCallPage(),
|
|
|
|
|
),
|
|
|
|
|
).then((_) {
|
|
|
|
|
log('✅ [CALL] Post-frame navigation completed', name: 'ChatProvider');
|
|
|
|
|
}).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Post-frame navigation error: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
log('❌ [CALL] Still no context available - navigation failed', name: 'ChatProvider');
|
|
|
|
|
// Last resort: dismiss CallKit and clean up
|
|
|
|
|
_callKitService.endCall(currentCall!.callId);
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for caller to send SDP offer via OnOfferAsync event
|
|
|
|
|
log('⏳ [CALL] Waiting for SDP offer from caller...', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] Error accepting call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error accepting call: $e');
|
|
|
|
|
}
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
context = navigatorKey.currentContext;
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Failed to accept call. Please try again.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decline an incoming call
|
|
|
|
|
Future<void> declineCall(String reason) async {
|
|
|
|
|
log('🔴 [CALL] declineCall() called - reason: $reason', name: 'ChatProvider');
|
|
|
|
|
log('🔴 [CALL] Current call: ${currentCall?.callId}', name: 'ChatProvider');
|
|
|
|
|
log('🔴 [CALL] Current status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (currentCall == null || callStatus != CallStatus.incomingRinging) {
|
|
|
|
|
log('⚠️ [CALL] Cannot decline - no incoming call or wrong status', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Invoke SignalR CallDeclinedAsync
|
|
|
|
|
log('🔴 [CALL] Invoking CallDeclinedAsync with args:', name: 'ChatProvider');
|
|
|
|
|
log(' - source: ${sender?.employeeNumber}', name: 'ChatProvider');
|
|
|
|
|
log(' - target: ${currentCall!.peerId}', name: 'ChatProvider');
|
|
|
|
|
log(' - moduleCode: $moduleID', name: 'ChatProvider');
|
|
|
|
|
log(' - referenceId: $referenceID', name: 'ChatProvider');
|
|
|
|
|
log(' - conversationId: ${chatParticipantModel?.id}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
await chatHubConnection?.invoke(
|
|
|
|
|
'CallDeclinedAsync',
|
|
|
|
|
args: <Object>[
|
|
|
|
|
sender?.employeeNumber ?? '',
|
|
|
|
|
currentCall!.peerId,
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL] CallDeclinedAsync invoked successfully', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
await _stopOutgoingRingtone();
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] Error invoking CallDeclinedAsync: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('⚠️ Error declining call: $e');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Register all call event handlers from SignalR
|
|
|
|
|
void _registerCallHandlers() {
|
|
|
|
|
if (chatHubConnection == null) {
|
|
|
|
|
log('⚠️ [CALL] Cannot register call handlers - connection is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
log('📞 [CALL] Starting call handler registration...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
chatHubConnection!.on('OnIncomingCallAsync', _handleIncomingCall);
|
|
|
|
|
chatHubConnection!.on('OnCallAcceptedAsync', _handleCallAccepted);
|
|
|
|
|
chatHubConnection!.on('OnCallDeclinedAsync', _handleCallDeclined);
|
|
|
|
|
chatHubConnection!.on('OnHangUpAsync', _handleHangUp);
|
|
|
|
|
chatHubConnection!.on('OnOfferAsync', _handleOffer);
|
|
|
|
|
chatHubConnection!.on('OnAnswerOfferAsync', _handleAnswer);
|
|
|
|
|
chatHubConnection!.on('OnIceCandidateAsync', _handleIceCandidate);
|
|
|
|
|
chatHubConnection!.on('OnAudioToggle', _handleAudioToggle);
|
|
|
|
|
chatHubConnection!.on('OnCameraToggle', _handleCameraToggle);
|
|
|
|
|
|
|
|
|
|
_callHandlersRegistered = true;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL] All call handlers registered successfully', name: 'ChatProvider');
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('✅ Call handlers registered');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Setup WebRTC callbacks
|
|
|
|
|
void _setupWebRTCCallbacks() {
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL] Cannot setup callbacks - WebRTC service is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log('🔧 [CALL] Setting up WebRTC callbacks...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onIceCandidate = (RTCIceCandidate candidate) {
|
|
|
|
|
log('🧊 [CALL] Local ICE candidate generated', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (chatHubConnection?.state == HubConnectionState.Connected && sender != null && currentCall != null) {
|
|
|
|
|
final candidateJson = jsonEncode({
|
|
|
|
|
'candidate': candidate.candidate,
|
|
|
|
|
'sdpMid': candidate.sdpMid,
|
|
|
|
|
'sdpMLineIndex': candidate.sdpMLineIndex,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatHubConnection!.invoke(
|
|
|
|
|
'IceCandidateAsync',
|
|
|
|
|
args: [currentCall!.peerId, candidateJson, currentCall!.sessionId ?? ''],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onRemoteStream = (MediaStream stream) {
|
|
|
|
|
log('📡 [CALL] Remote stream received in ChatProvider callback', name: 'ChatProvider');
|
|
|
|
|
log('📡 [CALL] Remote stream has ${stream.getVideoTracks().length} video tracks', name: 'ChatProvider');
|
|
|
|
|
log('📡 [CALL] Remote stream has ${stream.getAudioTracks().length} audio tracks', name: 'ChatProvider');
|
|
|
|
|
_remoteMediaStream = stream;
|
|
|
|
|
|
|
|
|
|
// Notify listeners to update UI when remote stream is received
|
|
|
|
|
notifyListeners();
|
|
|
|
|
log('✅ [CALL] UI notified about remote stream', name: 'ChatProvider');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onIceConnectionStateChange = (RTCIceConnectionState state) {
|
|
|
|
|
log('🔗 [CALL] ICE state: ${state.toString()}', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
|
|
|
|
_stopOutgoingRingtone();
|
|
|
|
|
|
|
|
|
|
// Cancel connection timeout since we're now connected
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
if (callStatus != CallStatus.connected) {
|
|
|
|
|
callStatus = CallStatus.connected;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
_startCallDurationTimer();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (state == RTCIceConnectionState.RTCIceConnectionStateChecking) {
|
|
|
|
|
log('🔍 [CALL] ICE checking - establishing connection...', name: 'ChatProvider');
|
|
|
|
|
// Start a longer timeout for connection establishment (30 seconds)
|
|
|
|
|
_startConnectionTimeout();
|
|
|
|
|
}
|
|
|
|
|
else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
|
|
|
|
log('❌ [CALL] ICE connection failed - ending call', name: 'ChatProvider');
|
|
|
|
|
// Connection failed, terminate the call
|
|
|
|
|
_handleConnectionFailure();
|
|
|
|
|
}
|
|
|
|
|
else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
|
|
|
|
|
log('⚠️ [CALL] ICE connection disconnected - waiting for reconnection', name: 'ChatProvider');
|
|
|
|
|
// Start a timeout to end call if it doesn't reconnect within 10 seconds
|
|
|
|
|
_startReconnectionTimeout();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
log('✅ [CALL] WebRTC callbacks setup complete', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create and send SDP offer
|
|
|
|
|
Future<void> _createAndSendOffer() async {
|
|
|
|
|
try {
|
|
|
|
|
log('🔧 [CALL] Creating SDP offer...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null || currentCall == null) {
|
|
|
|
|
throw Exception('WebRTC service or call session is null');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final offer = await _webrtcService!.createOffer();
|
|
|
|
|
log('✅ [CALL] SDP offer created', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'OfferAsync',
|
|
|
|
|
args: [currentCall!.peerId, offer.sdp ?? '', currentCall!.callId],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL] SDP offer sent', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL] Error creating/sending offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==================== CALL EVENT HANDLERS ====================
|
|
|
|
|
|
|
|
|
|
void _handleIncomingCall(List<Object?>? args) async {
|
|
|
|
|
log('📞 [CALL EVENT] OnIncomingCallAsync received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final callData = args[0] as Map<String, dynamic>;
|
|
|
|
|
final callerId = callData['sourceUserId'] as String?;
|
|
|
|
|
final callerName = callData['userName'] as String? ?? 'Unknown';
|
|
|
|
|
final isVideoCall = callData['isVideoCall'] as bool? ?? false;
|
|
|
|
|
final sdpOffer = callData['sdpOffer'] as String?;
|
|
|
|
|
|
|
|
|
|
// Check if already in a call - decline if busy
|
|
|
|
|
if (callStatus != CallStatus.idle) {
|
|
|
|
|
log('⚠️ [CALL] Already in a call, declining incoming call', name: 'ChatProvider');
|
|
|
|
|
chatHubConnection?.invoke('CallDeclinedAsync', args: <Object>[
|
|
|
|
|
sender?.employeeNumber ?? '',
|
|
|
|
|
callerId ?? '',
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate unique call ID
|
|
|
|
|
final callId = const Uuid().v4();
|
|
|
|
|
|
|
|
|
|
// Create call session
|
|
|
|
|
currentCall = CallSession(
|
|
|
|
|
callId: callId,
|
|
|
|
|
type: isVideoCall ? CallType.video : CallType.audio,
|
|
|
|
|
direction: CallDirection.incoming,
|
|
|
|
|
peerId: callerId ?? '',
|
|
|
|
|
peerName: callerName,
|
|
|
|
|
peerAvatar: null,
|
|
|
|
|
startTime: DateTime.now(),
|
|
|
|
|
sdpOffer: sdpOffer,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
callStatus = CallStatus.incomingRinging;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
log('📞 [CallKit] Showing native incoming call UI...', name: 'ChatProvider');
|
|
|
|
|
log(' Caller: $callerName', name: 'ChatProvider');
|
|
|
|
|
log(' Caller ID: $callerId', name: 'ChatProvider');
|
|
|
|
|
log(' Video: $isVideoCall', name: 'ChatProvider');
|
|
|
|
|
log(' Call ID: $callId', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Initialize CallKit if not already initialized
|
|
|
|
|
if (!_callKitInitialized) {
|
|
|
|
|
await _initializeCallKit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show native incoming call UI using CallKit
|
|
|
|
|
try {
|
|
|
|
|
await _callKitService.showIncomingCall(
|
|
|
|
|
callId: callId,
|
|
|
|
|
callerName: callerName,
|
|
|
|
|
callerNumber: callerId ?? '',
|
|
|
|
|
callerAvatar: null, // TODO: Get avatar from participant data if available
|
|
|
|
|
isVideo: isVideoCall,
|
|
|
|
|
extra: {
|
|
|
|
|
'peerId': callerId ?? '',
|
|
|
|
|
'moduleId': moduleID.toString(),
|
|
|
|
|
'referenceId': referenceID?.toString() ?? '',
|
|
|
|
|
'conversationId': chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CallKit] Native incoming call UI displayed', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CallKit] Error showing native UI, falling back to dialog: $e',
|
|
|
|
|
name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
|
|
|
|
// Fallback to custom dialog if CallKit fails
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
if (context != null) {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
barrierDismissible: false,
|
|
|
|
|
builder: (context) => IncomingCallDialog(call: currentCall!),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start call timeout timer (30 seconds for incoming calls)
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
|
|
|
|
if (callStatus == CallStatus.incomingRinging) {
|
|
|
|
|
log('⏱️ [CALL] Incoming call timeout - no answer after 30s', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// End CallKit UI
|
|
|
|
|
_callKitService.endCall(callId);
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
log('⏱️ [CALL] Incoming call timeout started (30s)', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL EVENT] Error handling incoming call: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
|
|
|
|
// Cleanup on error
|
|
|
|
|
callStatus = CallStatus.idle;
|
|
|
|
|
currentCall = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleCallAccepted(List<Object?>? args) {
|
|
|
|
|
log('📞 [CALL EVENT] OnCallAcceptedAsync received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (callStatus != CallStatus.outgoingRinging) return;
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
callStatus = CallStatus.connecting;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
_createAndSendOffer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleCallDeclined(List<Object?>? args) {
|
|
|
|
|
log('📞 [CALL EVENT] OnCallDeclinedAsync received', name: 'ChatProvider');
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleHangUp(List<Object?>? args) {
|
|
|
|
|
log('📞 [CALL EVENT] OnHangUpAsync received', name: 'ChatProvider');
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleOffer(List<Object?>? args) async {
|
|
|
|
|
log('📞 [CALL EVENT] OnOfferAsync received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final offerSdp = args[0] as String?;
|
|
|
|
|
if (offerSdp == null || _webrtcService == null) return;
|
|
|
|
|
|
|
|
|
|
final answer = await _webrtcService!.createAnswer(offerSdp);
|
|
|
|
|
|
|
|
|
|
await chatHubConnection!.invoke(
|
|
|
|
|
'AnswerOfferAsync',
|
|
|
|
|
args: [currentCall!.peerId, answer.sdp ?? '', currentCall!.callId],
|
|
|
|
|
);
|
|
|
|
|
log('✅ [CALL EVENT] SDP answer sent', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL EVENT] Error handling offer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleAnswer(List<Object?>? args) async {
|
|
|
|
|
log('📞 [CALL EVENT] OnAnswerOfferAsync received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final answerSdp = args[0] as String?;
|
|
|
|
|
if (answerSdp == null || _webrtcService == null) return;
|
|
|
|
|
|
|
|
|
|
await _webrtcService!.setRemoteAnswer(answerSdp);
|
|
|
|
|
log('✅ [CALL EVENT] Remote answer set', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL EVENT] Error handling answer: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleIceCandidate(List<Object?>? args) async {
|
|
|
|
|
log('📞 [CALL EVENT] OnIceCandidateAsync received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) {
|
|
|
|
|
log('⚠️ [CALL EVENT] ICE candidate args are null or empty', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final candidateJson = args[0] as String?;
|
|
|
|
|
if (candidateJson == null) {
|
|
|
|
|
log('⚠️ [CALL EVENT] ICE candidate JSON is null', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
log('⚠️ [CALL EVENT] WebRTC service not initialized yet, ignoring ICE candidate', name: 'ChatProvider');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final candidateData = jsonDecode(candidateJson) as Map<String, dynamic>;
|
|
|
|
|
final candidate = RTCIceCandidate(
|
|
|
|
|
candidateData['candidate'] as String?,
|
|
|
|
|
candidateData['sdpMid'] as String?,
|
|
|
|
|
candidateData['sdpMLineIndex'] as int?,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
log('🧊 [CALL EVENT] Parsed ICE candidate: ${candidateData['candidate']}', name: 'ChatProvider');
|
|
|
|
|
await _webrtcService!.addIceCandidate(candidate);
|
|
|
|
|
log('✅ [CALL EVENT] ICE candidate added successfully', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL EVENT] Error handling ICE candidate: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
// Don't rethrow - ICE candidate errors shouldn't crash the app
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleAudioToggle(List<Object?>? args) {
|
|
|
|
|
log('📞 [CALL EVENT] OnAudioToggle received', name: 'ChatProvider');
|
|
|
|
|
isPeerMuted = !isPeerMuted;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleCameraToggle(List<Object?>? args) {
|
|
|
|
|
log('📞 [CALL EVENT] OnCameraToggle received', name: 'ChatProvider');
|
|
|
|
|
isPeerCameraOn = !isPeerCameraOn;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onCallHistoryUpdated(List<Object?>? args) async {
|
|
|
|
|
log('📞 [CALL HISTORY] OnCallHistoryUpdated received', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (sender != null && recipient != null) {
|
|
|
|
|
userChatHistory = await ChatApiClient().loadChatHistory(
|
|
|
|
|
moduleID,
|
|
|
|
|
referenceID ?? 0,
|
|
|
|
|
sender!.employeeNumber ?? '',
|
|
|
|
|
recipient!.employeeNumber ?? '',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
chatResponseList = userChatHistory ?? [];
|
|
|
|
|
chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!));
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CALL HISTORY] Error reloading chat history: $e', name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle connection failure (ICE connection failed)
|
|
|
|
|
void _handleConnectionFailure() {
|
|
|
|
|
log('❌ [CALL] Connection failure detected', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
|
|
|
|
// Notify user about connection failure
|
|
|
|
|
if (context != null && context.mounted) {
|
|
|
|
|
CallErrorHandler.showConnectionFailed(context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send hangup signal to peer
|
|
|
|
|
if (chatHubConnection?.state == HubConnectionState.Connected &&
|
|
|
|
|
sender != null &&
|
|
|
|
|
currentCall != null) {
|
|
|
|
|
chatHubConnection!.invoke(
|
|
|
|
|
'HangUpAsync',
|
|
|
|
|
args: <Object>[
|
|
|
|
|
sender!.employeeNumber ?? '',
|
|
|
|
|
currentCall!.peerId,
|
|
|
|
|
moduleID.toString(),
|
|
|
|
|
referenceID?.toString() ?? '',
|
|
|
|
|
chatParticipantModel?.id?.toString() ?? '',
|
|
|
|
|
],
|
|
|
|
|
).catchError((e) {
|
|
|
|
|
log('❌ [CALL] Failed to send hangup after connection failure: $e', name: 'ChatProvider');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean up call resources
|
|
|
|
|
_teardownCall();
|
|
|
|
|
|
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
print('❌ Call ended due to connection failure');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start timeout for ICE connection establishment (60 seconds - increased for TURN relay)
|
|
|
|
|
void _startConnectionTimeout() {
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 60), () {
|
|
|
|
|
log('⏱️ [CALL] Connection timeout - ICE failed to establish within 60s', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (callStatus == CallStatus.connecting) {
|
|
|
|
|
log('❌ [CALL] Ending call due to connection timeout', name: 'ChatProvider');
|
|
|
|
|
_handleConnectionFailure();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
log('⏱️ [CALL] Connection timeout started (60s - extended for TURN relay)', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start timeout for reconnection (10 seconds)
|
|
|
|
|
void _startReconnectionTimeout() {
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
|
|
|
|
log('⏱️ [CALL] Reconnection timeout - connection did not recover', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
if (callStatus != CallStatus.connected && callStatus != CallStatus.idle) {
|
|
|
|
|
log('❌ [CALL] Ending call due to reconnection timeout', name: 'ChatProvider');
|
|
|
|
|
_handleConnectionFailure();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
log('⏱️ [CALL] Reconnection timeout started (10s)', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Initialize CallKit service
|
|
|
|
|
Future<void> _initializeCallKit() async {
|
|
|
|
|
if (_callKitInitialized) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
log('📞 [CallKit] Initializing CallKit service...', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
await _callKitService.initialize();
|
|
|
|
|
|
|
|
|
|
// Setup CallKit callbacks
|
|
|
|
|
_callKitService.onCallAccepted = _handleCallKitAccepted;
|
|
|
|
|
_callKitService.onCallDeclined = _handleCallKitDeclined;
|
|
|
|
|
_callKitService.onCallEnded = _handleCallKitEnded;
|
|
|
|
|
_callKitService.onCallTimeout = _handleCallKitTimeout;
|
|
|
|
|
|
|
|
|
|
_callKitInitialized = true;
|
|
|
|
|
log('✅ [CallKit] Service initialized successfully', name: 'ChatProvider');
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
log('❌ [CallKit] Error initializing: $e',
|
|
|
|
|
name: 'ChatProvider', error: e, stackTrace: stackTrace);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle CallKit accept event
|
|
|
|
|
void _handleCallKitAccepted(String callId) {
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
log('✅ [CallKit] Call accepted via native UI: $callId', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CallKit] Current call ID: ${currentCall?.callId}', name: 'ChatProvider');
|
|
|
|
|
log('🔵 [CallKit] Current call status: $callStatus', name: 'ChatProvider');
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Find the call by ID and accept it
|
|
|
|
|
if (currentCall != null && currentCall!.callId == callId) {
|
|
|
|
|
log('✅ [CallKit] Call IDs match - calling acceptCall()', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Schedule on next frame to ensure we're on main thread
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
log('🔵 [CallKit] Post-frame callback executing acceptCall()', name: 'ChatProvider');
|
|
|
|
|
acceptCall();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Also call immediately in case we're already on main thread
|
|
|
|
|
acceptCall();
|
|
|
|
|
} else {
|
|
|
|
|
log('⚠️ [CallKit] Call IDs do NOT match or currentCall is null', name: 'ChatProvider');
|
|
|
|
|
log(' Expected: $callId', name: 'ChatProvider');
|
|
|
|
|
log(' Current: ${currentCall?.callId}', name: 'ChatProvider');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle CallKit decline event
|
|
|
|
|
void _handleCallKitDeclined(String callId) {
|
|
|
|
|
log('❌ [CallKit] Call declined via native UI: $callId', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Find the call by ID and decline it
|
|
|
|
|
if (currentCall != null && currentCall!.callId == callId) {
|
|
|
|
|
declineCall('user_declined_native_ui');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle CallKit ended event
|
|
|
|
|
void _handleCallKitEnded(String callId) {
|
|
|
|
|
log('🔴 [CallKit] Call ended via native UI: $callId', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// End the call
|
|
|
|
|
if (currentCall != null && currentCall!.callId == callId) {
|
|
|
|
|
hangUp();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle CallKit timeout event
|
|
|
|
|
void _handleCallKitTimeout(String callId) {
|
|
|
|
|
log('⏱️ [CallKit] Call timeout: $callId', name: 'ChatProvider');
|
|
|
|
|
|
|
|
|
|
// Handle timeout
|
|
|
|
|
if (currentCall != null && currentCall!.callId == callId) {
|
|
|
|
|
_teardownCall();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|