import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:get/get.dart' hide Response; import 'package:permission_handler/permission_handler.dart'; import 'package:just_audio/just_audio.dart'; import '../../constant/box_name.dart'; import '../../constant/links.dart'; import '../../main.dart'; import '../../print.dart'; import '../../services/signaling_service.dart'; import '../../views/widgets/voice_call_bottom_sheet.dart'; import 'functions/crud.dart'; // EN: Enum representing the different states of a voice call. // AR: تعداد يمثل الحالات المختلفة للمكالمة الصوتية. enum VoiceCallState { idle, dialing, ringing, connecting, active, ended } class VoiceCallController extends GetxController with WidgetsBindingObserver { // EN: Instance of the signaling service to manage WebSocket communication. // AR: مثيل لخدمة الإشارات لإدارة الاتصال عبر الـ WebSocket. final SignalingService _signaling = SignalingService(); // --- Observable Variables (GetX) / المتغيرات التفاعلية --- // EN: Current state of the call. // AR: الحالة الحالية للمكالمة. var state = VoiceCallState.idle.obs; // EN: Unique identifier for the WebRTC session. // AR: المعرف الفريد لجلسة الاتصال. var sessionId = "".obs; // EN: ID of the current active ride. // AR: معرف الرحلة النشطة الحالية. var rideId = "".obs; // EN: Name of the other party (Driver/Passenger). // AR: اسم الطرف الآخر في المكالمة (سائق/راكب). var remoteName = "User".obs; // EN: Microphone mute status. // AR: حالة كتم الميكروفون. var isMuted = false.obs; // EN: Speakerphone status. // AR: حالة مكبر الصوت الخارجي. var isSpeakerOn = false.obs; // EN: Timer countdown variable, starts from 60 seconds. // AR: متغير العد التنازلي للمؤقت، يبدأ من 60 ثانية. var elapsedSeconds = 60.obs; // --- Core State Variables / متغيرات الحالة الأساسية --- // EN: Flag to determine if the current user initiated the call. // AR: مؤشر لتحديد ما إذا كان المستخدم الحالي هو من بدأ المكالمة. bool isCaller = false; // EN: ID of the current user. // AR: معرف المستخدم الحالي. String currentUserId = ""; // --- WebRTC Internal Variables / متغيرات WebRTC الداخلية --- // EN: The main connection object between peers. // AR: كائن الاتصال الرئيسي بين الطرفين. rtc.RTCPeerConnection? _peerConnection; // EN: The local audio stream captured from the microphone. // AR: دفق الصوت المحلي الملتقط من الميكروفون. rtc.MediaStream? _localStream; // EN: Timer to enforce the 60-second call limit. // AR: مؤقت لفرض حد الـ 60 ثانية للمكالمة. Timer? _countdownTimer; // EN: Timer to hang up if the call is not answered within 30 seconds. // AR: مؤقت لإنهاء المكالمة إذا لم يتم الرد خلال 30 ثانية. Timer? _ringingTimeoutTimer; // EN: Flag to indicate if the peer connection is currently attempting ICE reconnection. // AR: مؤشر يوضح ما إذا كان الاتصال يحاول إعادة بناء مسارات الشبكة حالياً. bool _isReconnecting = false; Timer? _reconnectTimer; List _dynamicIceServers = []; AudioPlayer? _ringtonePlayer; void _startRingtone() async { try { _ringtonePlayer ??= AudioPlayer(); await _ringtonePlayer!.setAsset('assets/start.wav'); await _ringtonePlayer!.setLoopMode(LoopMode.one); _ringtonePlayer!.play(); } catch (e) { Log.print("Error playing ringtone: $e"); } } void _stopRingtone() { try { _ringtonePlayer?.stop(); } catch (e) { Log.print("Error stopping ringtone: $e"); } } @override void onInit() { super.onInit(); // EN: Add lifecycle observer. // AR: إضافة مراقب لدورة حياة التطبيق. WidgetsBinding.instance.addObserver(this); // EN: Initialize WebSocket signaling listeners. // AR: تهيئة مستمعي إشارات الـ WebSocket. _initSignalingCallbacks(); } // EN: Lifecycle hook: handle app switching background/foreground. // AR: معالجة انتقال التطبيق إلى الخلفية أو العودة للواجهة. @override void didChangeAppLifecycleState(AppLifecycleState state) { Log.print("VoiceCall: didChangeAppLifecycleState -> $state"); if (state == AppLifecycleState.paused) { Log.print( "WARNING: App is in background. Microphone access might be suspended by the OS.", ); } else if (state == AppLifecycleState.resumed) { Log.print("App resumed. Verifying WebRTC connection health."); if (this.state.value == VoiceCallState.active) { _ensureMicrophoneActive(); _attemptIceRestart(); } } } // EN: Registers all event listeners for the signaling server. // AR: تسجيل جميع مستمعي الأحداث لخادم الإشارات. void _initSignalingCallbacks() { // EN: Triggered when successfully connected to the signaling server. // AR: يُستدعى عند الاتصال بنجاح بخادم الإشارات. _signaling.onConnected = (iceServers) { Log.print("WebRTC Signaling Connected & Authenticated"); _dynamicIceServers = iceServers; }; // EN: Triggered when the WebSocket connection drops. // AR: يُستدعى عند انقطاع اتصال الـ WebSocket. _signaling.onDisconnected = (reason) { Log.print("WebRTC Signaling Disconnected: $reason"); if (state.value != VoiceCallState.idle) { _endCallInternal("signaling_disconnected"); } }; // EN: Triggered when the remote user joins the room. // AR: يُستدعى عند انضمام الطرف الآخر إلى غرفة الاتصال. _signaling.onParticipantJoined = () async { Log.print("Remote participant joined signaling session"); // EN: If we are the caller, initiate the WebRTC handshake by creating an Offer. // AR: إذا كنا نحن المتصل، نبدأ مصافحة WebRTC بإنشاء عرض (Offer). if (isCaller && state.value == VoiceCallState.dialing) { state.value = VoiceCallState.connecting; await _createOffer(); } }; // EN: Triggered when an SDP Offer is received from the remote peer. // AR: يُستدعى عند استلام عرض اتصال (Offer) من الطرف الآخر. _signaling.onOffer = (sdpMap) async { Log.print("Received WebRTC SDP Offer"); if (!isCaller) { state.value = VoiceCallState.connecting; await _initializePeerConnection(); // EN: Set the remote peer's settings. // AR: تعيين إعدادات الطرف الآخر. final description = rtc.RTCSessionDescription( sdpMap['sdp'], sdpMap['type'], ); await _peerConnection!.setRemoteDescription(description); // EN: Respond with an Answer. // AR: الرد بإجابة (Answer). await _createAnswer(); } }; // EN: Triggered when an SDP Answer is received. // AR: يُستدعى عند استلام إجابة (Answer) من الطرف الآخر. _signaling.onAnswer = (sdpMap) async { Log.print("Received WebRTC SDP Answer"); if (isCaller && _peerConnection != null) { final description = rtc.RTCSessionDescription( sdpMap['sdp'], sdpMap['type'], ); await _peerConnection!.setRemoteDescription(description); } }; // EN: Triggered when ICE candidates (Network routing info) are exchanged. // AR: يُستدعى عند تبادل مسارات الشبكة (ICE Candidates) لتأسيس الاتصال. _signaling.onIceCandidate = (candidateMap) async { Log.print("Received Remote ICE Candidate"); if (_peerConnection != null) { final candidate = rtc.RTCIceCandidate( candidateMap['candidate'], candidateMap['sdpMid'], candidateMap['sdpMLineIndex'], ); await _peerConnection!.addCandidate(candidate); } }; // EN: Triggered when a hangup event is received from the server. // AR: يُستدعى عند استلام حدث إنهاء المكالمة من السيرفر. _signaling.onCallEnded = (reason) { Log.print("WebRTC Call Ended: $reason"); _endCallInternal(reason); }; } // --- CALL LIFECYCLE / دورة حياة المكالمة --- // EN: Initiates an outgoing call. // AR: يبدأ مكالمة صادرة. Future startCall({ required String rideIdVal, required String driverId, required String passengerId, required String remoteNameVal, }) async { if (state.value != VoiceCallState.idle) return; // EN: Setup call variables. // AR: إعداد متغيرات المكالمة. state.value = VoiceCallState.dialing; isCaller = true; currentUserId = passengerId; rideId.value = rideIdVal; remoteName.value = remoteNameVal; isMuted.value = false; isSpeakerOn.value = false; elapsedSeconds.value = 60; _isReconnecting = false; _showCallBottomSheet(); HapticFeedback.vibrate(); try { // 1. EN: Request Microphone Permission / AR: طلب صلاحية الميكروفون final permissionStatus = await Permission.microphone.request(); if (!permissionStatus.isGranted) { _endCallInternal("permission_denied"); Get.snackbar( "Error", "Microphone permission is required for voice calls".tr, ); return; } // 2. EN: Call PHP Backend to create Node.js session & notify Driver via FCM. // AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM. final response = await CRUD().post( link: "${AppLink.server}/ride/call/passenger/create_call_session.php", payload: {'ride_id': rideIdVal}, ); if (response == null || response == 'failure' || response['status'] != 'success') { _endCallInternal("session_creation_failed"); Get.snackbar( "Error", "Failed to initiate call session. Please try again.".tr, ); return; } final data = response['data']; sessionId.value = data['session_id']; // 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات await _signaling.connect(sessionId.value, currentUserId); // 4. EN: Initialize Local WebRTC Audio Stream / AR: تهيئة دفق الصوت المحلي await _initializeLocalStream(); // 5. EN: Start Ringing Timeout Timer (30s max wait for driver to answer). // AR: بدء مؤقت الرنين (أقصى انتظار 30 ثانية لرد السائق). _ringingTimeoutTimer = Timer(const Duration(seconds: 30), () { if (state.value == VoiceCallState.dialing) { _signaling.send("hangup", {"reason": "no_answer"}); _endCallInternal("no_answer"); } }); } catch (e) { Log.print("Error starting WebRTC call: $e"); _endCallInternal("error"); } } // EN: Handles incoming call requests via FCM/Socket. // AR: معالجة طلبات المكالمات الواردة. Future receiveCall({ required String sessionIdVal, required String remoteNameVal, required String rideIdVal, }) async { // EN: If already in a call, send busy signal. // AR: إذا كان في مكالمة بالفعل، إرسال إشارة مشغول. if (state.value != VoiceCallState.idle) { _signaling.send("hangup", {"reason": "busy"}); return; } state.value = VoiceCallState.ringing; isCaller = false; currentUserId = box.read(BoxName.passengerID).toString(); sessionId.value = sessionIdVal; rideId.value = rideIdVal; remoteName.value = remoteNameVal; isMuted.value = false; isSpeakerOn.value = false; elapsedSeconds.value = 60; _isReconnecting = false; _showCallBottomSheet(); _startRingtone(); HapticFeedback.vibrate(); // EN: Max 30s ringing timeout for receiver before auto-decline. // AR: أقصى مدة للرنين 30 ثانية قبل الرفض التلقائي. _ringingTimeoutTimer = Timer(const Duration(seconds: 30), () { if (state.value == VoiceCallState.ringing) { declineCall(); } }); } // EN: Accepts the incoming call. // AR: قبول المكالمة الواردة. Future acceptCall() async { if (state.value != VoiceCallState.ringing) return; _ringingTimeoutTimer?.cancel(); _stopRingtone(); state.value = VoiceCallState.connecting; try { // EN: Check Mic permissions / AR: التحقق من صلاحيات الميكروفون final permissionStatus = await Permission.microphone.request(); if (!permissionStatus.isGranted) { declineCall(); Get.snackbar( "Error", "Microphone permission is required for voice calls".tr, ); return; } await _signaling.connect(sessionId.value, currentUserId); await _initializeLocalStream(); // EN: Notify caller we accepted / AR: إشعار المتصل بأننا قبلنا المكالمة _signaling.send("join", {}); } catch (e) { Log.print("Error accepting call: $e"); declineCall(); } } // EN: Declines an incoming call. // AR: رفض المكالمة الواردة. void declineCall() { _ringingTimeoutTimer?.cancel(); _stopRingtone(); _signaling.send("hangup", {"reason": "declined"}); _endCallInternal("declined"); } // EN: Ends an active or dialing call. // AR: إنهاء المكالمة النشطة أو الجاري الاتصال بها. void hangup() { _signaling.send("hangup", {"reason": "normal"}); _endCallInternal("hangup"); } // --- WEBRTC CORE HELPERS / دوال WebRTC الأساسية --- // EN: Captures the audio from the microphone with optimization constraints. // AR: التقاط الصوت من الميكروفون مع قيود تحسين الجودة (إلغاء الصدى والضوضاء). Future _initializeLocalStream() async { final Map mediaConstraints = { 'audio': { 'echoCancellation': true, 'noiseSuppression': true, 'autoGainControl': true, }, 'video': false, // EN: Audio only / AR: صوت فقط }; _localStream = await rtc.navigator.mediaDevices.getUserMedia( mediaConstraints, ); rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value); } // EN: Verifies local microphone stream health on app resume and recreates/replaces track if suspended. // AR: التحقق من سلامة مسار الميكروفون المحلي عند استئناف التطبيق وإعادة إنشائه إذا تم تعليقه. Future _ensureMicrophoneActive() async { if (_localStream == null || _peerConnection == null) return; bool needsRecreation = false; if (_localStream!.active == false) { needsRecreation = true; } else { for (var track in _localStream!.getAudioTracks()) { if (!track.enabled && !isMuted.value) { needsRecreation = true; break; } } } if (needsRecreation) { Log.print( "Local audio track ended or disabled. Recreating local stream...", ); try { _localStream?.getTracks().forEach((track) => track.stop()); _localStream?.dispose(); _localStream = null; await _initializeLocalStream(); final senders = await _peerConnection!.getSenders(); for (var sender in senders) { final track = sender.track; if (track != null && track.kind == 'audio') { final newTracks = _localStream?.getAudioTracks(); if (newTracks != null && newTracks.isNotEmpty) { await sender.replaceTrack(newTracks.first); Log.print( "Replaced suspended/ended audio track with a new active one.", ); } break; } } } catch (e) { Log.print("Error recreating local stream on resume: $e"); } } else { _localStream!.getAudioTracks().forEach((track) { track.enabled = !isMuted.value; }); } } // EN: Creates the peer connection object and sets up ICE servers (STUN/TURN). // AR: إنشاء كائن الاتصال المباشر وإعداد خوادم STUN/TURN لاختراق الجدران النارية. Future _initializePeerConnection() async { if (_peerConnection != null) return; final List> iceServers = []; if (_dynamicIceServers.isNotEmpty) { for (var server in _dynamicIceServers) { if (server is Map) { iceServers.add({ "urls": server["urls"] ?? server["url"], if (server["username"] != null) "username": server["username"], if (server["credential"] != null) "credential": server["credential"], }); } } } else { // EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية iceServers.addAll([ {"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun1.l.google.com:19302"}, ]); } final Map configuration = {"iceServers": iceServers}; _peerConnection = await rtc.createPeerConnection(configuration); // EN: Gather local network routing info and send to remote peer. // AR: جمع بيانات مسارات الشبكة المحلية وإرسالها للطرف الآخر. _peerConnection!.onIceCandidate = (candidate) { if (candidate.candidate != null) { _signaling.send("ice_candidate", { "candidate": { "candidate": candidate.candidate, "sdpMid": candidate.sdpMid, "sdpMLineIndex": candidate.sdpMLineIndex, }, }); } }; // EN: Monitor connection status changes and handle disconnections. // AR: مراقبة تغيرات حالة الاتصال ومعالجة انقطاع الشبكة. _peerConnection!.onConnectionState = (connState) { Log.print("RTCPeerConnectionState: $connState"); if (connState == rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) { _onCallConnected(); } else if (connState == rtc.RTCPeerConnectionState.RTCPeerConnectionStateFailed || connState == rtc.RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { _handleIceConnectionFailure(); } }; // EN: Add local audio stream to the connection to send it to the other peer. // AR: إضافة دفق الصوت المحلي للاتصال لإرساله للطرف الآخر. if (_localStream != null) { _localStream!.getTracks().forEach((track) { _peerConnection!.addTrack(track, _localStream!); }); } } // EN: Attempts an ICE restart to reconnect the WebRTC session when disconnections occur. // AR: محاولة إعادة تأسيس الاتصال (ICE Restart) في حالة انقطاع الشبكة. void _handleIceConnectionFailure() { if (_isReconnecting) return; _isReconnecting = true; Log.print( "ICE connection dropped. Attempting ICE Restart reconnection for 5s...", ); if (isCaller) { _attemptIceRestart(); } _reconnectTimer?.cancel(); _reconnectTimer = Timer(const Duration(seconds: 5), () { if (state.value == VoiceCallState.active && _peerConnection?.connectionState != rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) { Log.print("ICE reconnection timed out. Hanging up."); _endCallInternal("connection_lost"); } else { _isReconnecting = false; Log.print("ICE Reconnection succeeded!"); } }); } // EN: Initiates ICE Restart SDP exchange. // AR: بدء تبادل حزم SDP لإعادة بناء مسارات الاتصال. Future _attemptIceRestart() async { if (_peerConnection == null || !isCaller) return; try { Log.print("Caller initiating WebRTC ICE Restart..."); final constraints = { 'mandatory': { 'OfferToReceiveAudio': true, 'OfferToReceiveVideo': false, }, 'optional': [ {'IceRestart': true}, ], }; final offer = await _peerConnection!.createOffer(constraints); await _peerConnection!.setLocalDescription(offer); _signaling.send("offer", { "sdp": {"sdp": offer.sdp, "type": offer.type}, }); } catch (e) { Log.print("Error initiating WebRTC ICE Restart: $e"); } } // EN: Generates an SDP Offer to initialize the connection. // AR: إنشاء عرض (Offer) لبدء الاتصال وتحديد قدرات الجهاز. Future _createOffer() async { await _initializePeerConnection(); final constraints = { 'mandatory': {'OfferToReceiveAudio': true, 'OfferToReceiveVideo': false}, 'optional': [], }; final offer = await _peerConnection!.createOffer(constraints); await _peerConnection!.setLocalDescription(offer); _signaling.send("offer", { "sdp": {"sdp": offer.sdp, "type": offer.type}, }); } // EN: Generates an SDP Answer in response to an Offer. // AR: الرد بإنشاء إجابة (Answer) بناءً على العرض المستلم. Future _createAnswer() async { final constraints = { 'mandatory': {'OfferToReceiveAudio': true, 'OfferToReceiveVideo': false}, 'optional': [], }; final answer = await _peerConnection!.createAnswer(constraints); await _peerConnection!.setLocalDescription(answer); _signaling.send("answer", { "sdp": {"sdp": answer.sdp, "type": answer.type}, }); } // EN: Triggered when connection is fully established. Starts the 60s timer. // AR: يُستدعى عند تأسيس الاتصال بنجاح، ويقوم ببدء مؤقت الـ 60 ثانية. void _onCallConnected() { _ringingTimeoutTimer?.cancel(); _reconnectTimer?.cancel(); _isReconnecting = false; if (state.value != VoiceCallState.active) { state.value = VoiceCallState.active; HapticFeedback.vibrate(); // EN: Start 120s countdown timer / AR: بدء العد التنازلي لمدة 120 ثانية _countdownTimer?.cancel(); elapsedSeconds.value = 120; _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (elapsedSeconds.value > 1) { elapsedSeconds.value--; } else { elapsedSeconds.value = 0; _countdownTimer?.cancel(); // EN: Force hangup when timer reaches 0 / AR: إغلاق إجباري عند وصول المؤقت لصفر hangup(); } }); } } // EN: Internal cleanup function. Closes all connections and streams. // AR: دالة التنظيف الداخلية. تقوم بإغلاق جميع الاتصالات وتفريغ الذاكرة. void _endCallInternal(String reason) { _countdownTimer?.cancel(); _ringingTimeoutTimer?.cancel(); _reconnectTimer?.cancel(); _stopRingtone(); state.value = VoiceCallState.ended; // EN: Close WebRTC connection / AR: إغلاق اتصال WebRTC _peerConnection?.close(); _peerConnection = null; // EN: Stop mic capture / AR: إيقاف التقاط الميكروفون _localStream?.getTracks().forEach((track) => track.stop()); _localStream?.dispose(); _localStream = null; // EN: Disconnect WebSockets / AR: إغلاق اتصال الـ WebSockets _signaling.disconnect(); // EN: Close UI BottomSheet after delay / AR: إغلاق واجهة المكالمة بعد فترة زمنية قصيرة Future.delayed(const Duration(milliseconds: 1500), () { if (state.value == VoiceCallState.ended) { state.value = VoiceCallState.idle; Get.back(); } }); } // --- ACTIONS (UI Controls) / إجراءات الواجهة --- // EN: Toggles microphone mute state. // AR: تبديل حالة كتم الميكروفون. void toggleMute() { isMuted.value = !isMuted.value; _localStream?.getAudioTracks().forEach((track) { track.enabled = !isMuted.value; }); } // EN: Toggles loudspeaker mode. // AR: تبديل حالة مكبر الصوت الخارجي. void toggleSpeaker() { isSpeakerOn.value = !isSpeakerOn.value; rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value); } // EN: Displays the call UI overlay. // AR: إظهار نافذة المكالمة السفلية. void _showCallBottomSheet() { Get.bottomSheet( const VoiceCallBottomSheet(), isScrollControlled: true, enableDrag: false, isDismissible: false, ); } // EN: Lifecycle hook: clean up resources when controller is destroyed. // AR: دورة الحياة: تفريغ الذاكرة وإغلاق الموارد عند تدمير المتحكم. @override void onClose() { WidgetsBinding.instance.removeObserver(this); _countdownTimer?.cancel(); _ringingTimeoutTimer?.cancel(); _reconnectTimer?.cancel(); _stopRingtone(); _ringtonePlayer?.dispose(); _peerConnection?.close(); _localStream?.dispose(); _signaling.disconnect(); super.onClose(); } }