Fixes & Updates - 2026-06-01: Integrate Back-End v3 updates, fix call/connection issues across apps

This commit is contained in:
Hamza-Ayed
2026-06-01 23:35:29 +03:00
parent 8f555691b9
commit cbf693c804
56 changed files with 6091 additions and 1217 deletions

View File

@@ -110,7 +110,7 @@ class PhoneAuthHelper {
}
/// Verifies the OTP and logs the user in.
static Future<void> verifyOtp(String phoneNumber) async {
static Future<void> verifyOtp(String phoneNumber, String otpCode) async {
try {
final fixedPhone = formatSyrianPhone(phoneNumber);
Log.print('fixedPhone: $fixedPhone');
@@ -118,6 +118,7 @@ class PhoneAuthHelper {
link: _verifyOtpUrl,
payload: {
'phone_number': fixedPhone,
'otp': otpCode,
},
);

View File

@@ -4,6 +4,7 @@ import 'package:sefer_driver/controller/home/captin/home_captain_controller.dart
import 'package:sefer_driver/views/home/Captin/orderCaptin/order_speed_request.dart';
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
import 'package:sefer_driver/views/widgets/mydialoug.dart';
import 'package:sefer_driver/controller/voice_call_controller.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@@ -75,6 +76,24 @@ class FirebaseMessagesController extends GetxController {
await fcmToken.subscribeToTopic("drivers"); // أو "users" حسب نوع المستخدم
print("Subscribed to 'drivers' topic ✅");
FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage? message) async {
if (message != null && message.data.isNotEmpty) {
Log.print("🔔 FCM getInitialMessage payload: ${message.data}");
String? category = message.data['category'] ?? message.data['type'];
if (category == 'ORDER' || category == 'Order' || category == 'OrderVIP' || message.data.containsKey('DriverList')) {
String? myListString = message.data['DriverList'];
if (myListString != null && myListString.isNotEmpty) {
await storage.write(key: 'pending_driver_list', value: myListString);
Log.print("💾 Saved pending driver list to secure storage from getInitialMessage");
}
} else {
Future.delayed(const Duration(milliseconds: 1500), () {
fireBaseTitles(message);
});
}
}
});
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
// If the app is in the background or terminated, show a system tray message
RemoteNotification? notification = message.notification;
@@ -113,11 +132,22 @@ class FirebaseMessagesController extends GetxController {
// if (Platform.isAndroid) {
// notificationController.showNotification(title, body, 'order', '');
// }
// 🔥 [Fix FCM-Guard] منع إعاقة الرحلة النشطة بطلبات جديدة عبر FCM
String currentRideStatus = box.read(BoxName.rideStatus) ?? '';
if (currentRideStatus == 'Begin' ||
currentRideStatus == 'Apply' ||
currentRideStatus == 'Arrived') {
Log.print(
"⛔ [FCM] Ignoring ORDER notification — driver has active ride ($currentRideStatus)");
break;
}
var myListString = message.data['DriverList'];
if (myListString != null) {
var myList = jsonDecode(myListString) as List<dynamic>;
driverToken = myList[14].toString();
Get.put(HomeCaptainController()).changeRideId();
Get.put(HomeCaptainController(), permanent: true).changeRideId();
update();
Get.toNamed('/OrderRequestPage', arguments: {
'myListString': myListString,
@@ -231,6 +261,20 @@ class FirebaseMessagesController extends GetxController {
mySnackbarSuccess("The order has been accepted by another driver.".tr);
break;
case 'incoming_call':
case 'INCOMING_CALL':
final sessionId = message.data['session_id'];
final callerName = message.data['caller_name'];
final rideId = message.data['ride_id'];
if (sessionId != null && callerName != null && rideId != null) {
Get.find<VoiceCallController>().receiveCall(
sessionIdVal: sessionId.toString(),
remoteNameVal: callerName.toString(),
rideIdVal: rideId.toString(),
);
}
break;
default:
Log.print('Received unhandled notification category: $category');
// Optionally show a generic notification

View File

@@ -104,6 +104,7 @@ class NotificationController extends GetxController {
String endLoc = _getVal(data, 30);
String paxName = _getVal(data, 8);
// String rating = _getVal(data, 33);
String isHaveSteps = _getVal(data, 20);
// تنسيق النص ليكون 4 أسطر واضحة
formattedBigText = "👤 $paxName\n"
@@ -111,6 +112,10 @@ class NotificationController extends GetxController {
"🟢 من: $startLoc\n"
"🏁 إلى: $endLoc";
if (isHaveSteps == 'true') {
formattedBigText += "\n🛑 هذه الرحلة تحتوي على نقاط توقف!";
}
summaryText = 'سعر الرحلة: $price';
} catch (e) {
print("Error formatting notification text: $e");
@@ -181,11 +186,16 @@ class NotificationController extends GetxController {
final details =
NotificationDetails(android: androidDetails, iOS: iosDetails);
String briefBody = "$price - مسافة $formattedBigText";
if (_getVal(jsonDecode(myListString), 20) == 'true') {
briefBody = "🛑 (متعددة التوقفات) $price - مسافة $formattedBigText";
}
// عرض الإشعار
await _flutterLocalNotificationsPlugin.show(
id: 1001, // ID ثابت لاستبدال الإشعار القديم
title: title,
body: "$price - مسافة $formattedBigText", // نص مختصر يظهر في البار العلوي
body: briefBody, // نص مختصر يظهر في البار العلوي
notificationDetails: details,
payload: jsonEncode({
'type': 'Order',
@@ -298,7 +308,7 @@ class NotificationController extends GetxController {
// حماية من الكراش: التأكد من وجود HomeCaptainController
if (!Get.isRegistered<HomeCaptainController>()) {
print("♻️ Reviving HomeCaptainController...");
Get.put(HomeCaptainController());
Get.put(HomeCaptainController(), permanent: true);
} else {
Get.find<HomeCaptainController>().changeRideId();
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:io';
import 'dart:convert';
import '../../constant/info.dart';
import '../../constant/links.dart';
import '../../constant/colors.dart';
import '../../print.dart';
import 'crud.dart';
class AppUpdateController extends GetxController {
@override
void onInit() {
super.onInit();
// الفحص التلقائي عند التشغيل لتحديثات المتجر
checkSmartUpdate();
}
// ======================================================================
// الدالة الذكية المدمجة (الآن تفحص المتجر فقط لأن Shorebird يعمل تلقائياً بالخلفية)
// ======================================================================
Future<void> checkSmartUpdate() async {
Log.print("🔄 بدء فحص تحديثات المتجر...");
// 1. فحص تحديث المتجر (Native Update)
await _checkStoreUpdate();
}
// ======================================================================
// 1. تحديث المتجر الأساسي
// ======================================================================
Future<bool> _checkStoreUpdate() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentBuildNumber = packageInfo.buildNumber;
// استخدام نفس الـ Endpoint والمعايير الموجودة في التطبيق
var response = await CRUD().get(link: AppLink.packageInfo, payload: {
"platform": Platform.isAndroid ? 'android' : 'ios',
"appName": AppInformation.appVersion,
});
if (response != 'failure') {
var decoded = jsonDecode(response);
if (decoded['status'] == 'success' && decoded['message'] != null && decoded['message'].isNotEmpty) {
String latestBuildNumber = decoded['message'][0]['version'].toString();
// مقارنة الـ Build Number
if (latestBuildNumber != currentBuildNumber) {
_showStoreUpdateDialog();
return true;
}
}
}
} catch (e) {
Log.print("❌ Store update check error: $e");
}
return false;
}
// ======================================================================
// دوال مساعدة
// ======================================================================
void _showStoreUpdateDialog() {
final String storeUrl = Platform.isAndroid
? 'https://play.google.com/store/apps/details?id=com.intaleq_driver'
: 'https://apps.apple.com/jo/app/intaleq-driver/id6482995159';
Get.defaultDialog(
title: "تحديث جديد متوفر".tr,
middleText: "يوجد إصدار جديد من التطبيق في المتجر، يرجى التحديث للحصول على الميزات الجديدة.".tr,
barrierDismissible: false,
onWillPop: () async => false,
confirm: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primaryColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))
),
onPressed: () async {
if (await canLaunchUrl(Uri.parse(storeUrl))) {
await launchUrl(Uri.parse(storeUrl), mode: LaunchMode.externalApplication);
}
},
child: Text("تحديث الآن".tr, style: const TextStyle(color: Colors.white)),
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:get/get.dart';
import 'package:just_audio/just_audio.dart';
import 'package:record/record.dart';
class AudioRecorderController extends GetxController {
AudioPlayer audioPlayer = AudioPlayer();
AudioRecorder recorder = AudioRecorder();
bool isRecording = false;
bool isPlaying = false;
bool isPaused = false;
String filePath = '';
String? selectedFilePath;
double currentPosition = 0;
double totalDuration = 0;
// Start recording
Future<void> startRecording({String? rideId}) async {
final bool isPermissionGranted = await recorder.hasPermission();
if (!isPermissionGranted) {
return;
}
final directory = await getApplicationDocumentsDirectory();
final String dateStr =
'${DateTime.now().year}-${DateTime.now().month.toString().padLeft(2, '0')}-${DateTime.now().day.toString().padLeft(2, '0')}';
// Generate a unique file name
String fileName = (rideId != null && rideId.isNotEmpty && rideId != 'yet' && rideId != 'null')
? '${dateStr}_$rideId.m4a'
: '$dateStr.m4a';
filePath = '${directory.path}/$fileName';
const config = RecordConfig(
encoder: AudioEncoder.aacLc,
sampleRate: 44100,
bitRate: 128000,
);
await recorder.start(config, path: filePath);
isRecording = true;
update();
}
// Stop recording
Future<void> stopRecording() async {
await recorder.stop();
isRecording = false;
isPaused = false;
update();
}
// Get a list of recorded files
Future<List<String>> getRecordedFiles() async {
final directory = await getApplicationDocumentsDirectory();
final files = await directory.list().toList();
return files
.map((file) => file.path)
.where((path) => path.endsWith('.m4a'))
.toList();
}
// Delete a specific recorded file
Future<void> deleteRecordedFile(String filePath) async {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
update();
}
}
@override
void onClose() {
audioPlayer.dispose();
recorder.dispose();
super.onClose();
}
}

View File

@@ -1,5 +1,7 @@
import 'package:url_launcher/url_launcher.dart';
import 'dart:io';
import 'package:get/get.dart';
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
void showInBrowser(String url) async {
if (await canLaunchUrl(Uri.parse(url))) {
@@ -7,34 +9,42 @@ void showInBrowser(String url) async {
} else {}
}
Future<void> makePhoneCall(String phoneNumber) async {
String cleanAndFormatPhoneNumber(String phoneNumber) {
// 1. Clean the number
String formattedNumber = phoneNumber.replaceAll(RegExp(r'\s+'), '');
// 2. Format logic (Syria/International)
// 2. Format logic (Syria/Egypt/International)
if (formattedNumber.length > 6) {
if (formattedNumber.startsWith('09')) {
formattedNumber = '+963${formattedNumber.substring(1)}';
} else if (formattedNumber.startsWith('01') && formattedNumber.length == 11) {
formattedNumber = '+20${formattedNumber.substring(1)}';
} else if (formattedNumber.startsWith('00')) {
formattedNumber = '+${formattedNumber.substring(2)}';
} else if (!formattedNumber.startsWith('+')) {
formattedNumber = '+$formattedNumber';
}
}
return formattedNumber;
}
// 3. Create URI
final Uri launchUri = Uri(
scheme: 'tel',
path: formattedNumber,
);
Future<void> makePhoneCall(String phoneNumber) async {
String formattedNumber = cleanAndFormatPhoneNumber(phoneNumber);
if (!formattedNumber.startsWith('+963')) {
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
return;
}
// Create URI directly from String to avoid double encoding '+' as '%2B'
final Uri launchUri = Uri.parse('tel:$formattedNumber');
// 4. Execute with externalApplication mode
try {
// Attempt to launch directly without checking canLaunchUrl first
// (Sometimes canLaunchUrl returns false on some devices even if it works)
if (!await launchUrl(launchUri, mode: LaunchMode.externalApplication)) {
throw 'Could not launch $launchUri';
}
} catch (e) {
// Fallback: Try checking canLaunchUrl if the direct launch fails
if (await canLaunchUrl(launchUri)) {
await launchUrl(launchUri);
} else {
@@ -45,23 +55,30 @@ Future<void> makePhoneCall(String phoneNumber) async {
void launchCommunication(
String method, String contactInfo, String message) async {
String formattedContact = cleanAndFormatPhoneNumber(contactInfo);
// WhatsApp prefers the phone number without the '+' prefix
String whatsappContact = formattedContact.replaceAll('+', '');
String url;
if (Platform.isIOS) {
switch (method) {
case 'phone':
url = 'tel:$contactInfo';
if (!formattedContact.startsWith('+963')) {
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
return;
}
url = 'tel:$formattedContact';
break;
case 'sms':
url = 'sms:$contactInfo?body=${Uri.encodeComponent(message)}';
url = 'sms:$formattedContact?body=${Uri.encodeComponent(message)}';
break;
case 'whatsapp':
url =
'https://api.whatsapp.com/send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
'https://api.whatsapp.com/send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
break;
case 'email':
url =
'mailto:$contactInfo?subject=Subject&body=${Uri.encodeComponent(message)}';
'mailto:$formattedContact?subject=Subject&body=${Uri.encodeComponent(message)}';
break;
default:
return;
@@ -69,27 +86,29 @@ void launchCommunication(
} else if (Platform.isAndroid) {
switch (method) {
case 'phone':
url = 'tel:$contactInfo';
if (!formattedContact.startsWith('+963')) {
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
return;
}
url = 'tel:$formattedContact';
break;
case 'sms':
url = 'sms:$contactInfo?body=${Uri.encodeComponent(message)}';
url = 'sms:$formattedContact?body=${Uri.encodeComponent(message)}';
break;
case 'whatsapp':
// Check if WhatsApp is installed
final bool whatsappInstalled =
await canLaunchUrl(Uri.parse('whatsapp://'));
if (whatsappInstalled) {
url =
'whatsapp://send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
'whatsapp://send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
} else {
// Provide an alternative action, such as opening the WhatsApp Web API
url =
'https://api.whatsapp.com/send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
'https://api.whatsapp.com/send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
}
break;
case 'email':
url =
'mailto:$contactInfo?subject=Subject&body=${Uri.encodeComponent(message)}';
'mailto:$formattedContact?subject=Subject&body=${Uri.encodeComponent(message)}';
break;
default:
return;

View File

@@ -54,8 +54,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
late final HomeCaptainController _homeCtrl;
late final CaptainWalletController _walletCtrl;
LatLng myLocation = const LatLng(0, 0);
double heading = 0.0;
LatLng myLocation = LatLng(
box.read('last_lat') ?? 0.0,
box.read('last_lng') ?? 0.0,
);
double heading = box.read('last_heading') ?? 0.0;
double speed = 0.0;
double totalDistance = 0.0;
bool _isReady = false;
@@ -379,7 +382,23 @@ class LocationController extends GetxController with WidgetsBindingObserver {
Log.print("Overlay check error: $e");
}
if (Get.currentRoute != '/OrderRequestPage') {
// 🔥 [Fix Active-Ride Guard] منع فتح صفحة الطلبات أثناء وجود السائق في رحلة نشطة
// هذا يمنع socket event جديد من تعطيل رحلة جارية
String? currentRideStatus = box.read(BoxName.rideStatus);
bool hasActiveRide = (currentRideStatus == 'Begin' ||
currentRideStatus == 'Apply' ||
currentRideStatus == 'Arrived');
String currentRoute = Get.currentRoute;
bool isOnMapPage = currentRoute.contains('MapPage') ||
currentRoute.contains('PassengerLocation');
if (hasActiveRide || isOnMapPage) {
Log.print(
"⛔ [LocationController] Ignoring new ride request — driver has active ride ($currentRideStatus) or is on map page ($currentRoute).");
return;
}
if (currentRoute != '/OrderRequestPage') {
Log.print("🚀 Socket: Navigating to OrderRequestPage...");
Get.toNamed('/OrderRequestPage', arguments: {
'myListString': jsonEncode(driverList),
@@ -398,6 +417,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
void _startHeartbeat() {
_socketHeartbeat?.cancel();
_socketHeartbeat = Timer.periodic(const Duration(seconds: 25), (timer) {
// [Fix 6] تخطي الإرسال إذا كان stream الموقع نشطاً.
// الـ _locSub يرسل update_location عند كل تحرك (كل 5-10 ثوانٍ) تلقائياً.
// الـ heartbeat يكون مفيداً فقط عندما يتوقف الـ stream (الجهاز ثابت أو أوقف الخدمة).
if (_locSub != null) return;
if (socket != null && isSocketConnected && myLocation.latitude != 0) {
emitLocationToSocket(myLocation, heading, speed);
}
@@ -491,6 +514,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
speed = loc.speed ?? 0.0;
heading = loc.heading ?? 0.0;
box.write('last_lat', pos.latitude);
box.write('last_lng', pos.longitude);
box.write('last_heading', heading);
if (_lastPosForDistance != null) {
final d = _calculateDistance(_lastPosForDistance!, pos);
if (d > 5.0) totalDistance += d;
@@ -499,10 +526,25 @@ class LocationController extends GetxController with WidgetsBindingObserver {
update();
emitLocationToSocket(pos, heading, speed);
if (Get.isRegistered<HomeCaptainController>()) {
final homeCtrl = Get.find<HomeCaptainController>();
if (homeCtrl.isActive &&
homeCtrl.mapHomeCaptainController != null &&
homeCtrl.isHomeMapActive &&
homeCtrl.isMapReadyForCommands) {
homeCtrl.mapHomeCaptainController?.animateCamera(
CameraUpdate.newLatLngZoom(pos, 17.5),
);
}
}
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
}, onError: (e) => Log.print('❌ Location Stream Error: $e'));
}
Timer? _socketWatchdogTimer;
Future<void> stopLocationUpdates() async {
Log.print("🛑 Stopping Location Updates...");
@@ -511,11 +553,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_socketHeartbeat?.cancel();
_socketWatchdogTimer?.cancel();
if (socket != null) {
socket!.clearListeners();
socket!
.dispose(); // استخدام dispose بدلاً من disconnect لضمان تحرير الموارد على iOS
socket!.dispose();
}
if (!Platform.isIOS) {
@@ -534,6 +576,7 @@ class LocationController extends GetxController with WidgetsBindingObserver {
void _startBatchTimers() {
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_socketWatchdogTimer?.cancel();
final recDur =
_isPowerSavingMode ? recordIntervalPowerSave : recordIntervalNormal;
@@ -544,6 +587,14 @@ class LocationController extends GetxController with WidgetsBindingObserver {
_recordTimer =
Timer.periodic(recDur, (_) => _recordCurrentLocationToBuffer());
_uploadBatchTimer = Timer.periodic(upDur, (_) => _flushBufferToServer());
// محاولة إعادة الاتصال بالسوكيت إذا انقطع كل 3 ثواني
_socketWatchdogTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!isSocketConnected && !_isInitializingSocket) {
Log.print("🔄 Socket Watchdog: Attempting to reconnect socket...");
initSocket();
}
});
}
void _recordCurrentLocationToBuffer() {
@@ -736,7 +787,30 @@ class LocationController extends GetxController with WidgetsBindingObserver {
Future<LocationData?> getLocation() async {
try {
if (await _ensureServiceAndPermission()) {
return await location.getLocation();
final locData = await location.getLocation();
if (locData != null && locData.latitude != null && locData.longitude != null) {
myLocation = LatLng(locData.latitude!, locData.longitude!);
heading = locData.heading ?? 0.0;
speed = locData.speed ?? 0.0;
box.write('last_lat', myLocation.latitude);
box.write('last_lng', myLocation.longitude);
box.write('last_heading', heading);
update();
if (Get.isRegistered<HomeCaptainController>()) {
final homeCtrl = Get.find<HomeCaptainController>();
if (homeCtrl.mapHomeCaptainController != null &&
homeCtrl.isMapReadyForCommands) {
Log.print("📍 [LocationController] Animating camera to single location update");
homeCtrl.mapHomeCaptainController?.animateCamera(
CameraUpdate.newLatLngZoom(myLocation, 17.5),
);
}
}
}
return locData;
}
} catch (e) {
Log.print('❌ FAILED to get single location: $e');

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:sefer_driver/constant/box_name.dart';
import 'dart:async';
@@ -29,7 +30,7 @@ class HomeCaptainController extends GetxController {
Timer? activeTimer;
Map data = {};
bool isHomeMapActive = true;
InlqBitmap carIcon = InlqBitmap.defaultMarker;
InlqBitmap carIcon = InlqBitmap.fromAsset('assets/images/car.png');
bool isMapReadyForCommands = false;
bool isLoading = true;
late double kazan = 0;
@@ -186,7 +187,8 @@ class HomeCaptainController extends GetxController {
_heatmapTimer?.cancel();
fetchAndDrawHeatmap();
_heatmapTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
// Refresh every 15 min instead of 5 to reduce data & battery usage
_heatmapTimer = Timer.periodic(const Duration(minutes: 15), (timer) {
if (isHeatmapVisible) {
print("🔄 [Heatmap] Periodic refresh started...");
fetchAndDrawHeatmap();
@@ -213,7 +215,8 @@ class HomeCaptainController extends GetxController {
}
String stringActiveDuration = '';
int _fatigueSeconds = 0; // عداد ثواني الإرهاق المؤقت
// ==========================================
// ====== 🛡️ Fatigue Monitoring System ======
// ==========================================
@@ -230,7 +233,8 @@ class HomeCaptainController extends GetxController {
}
}
if (totalSecondsToday >= 12 * 3600) { // 12 Hours
if (totalSecondsToday >= 12 * 3600) {
// 12 Hours
_forceOfflineDueToFatigue();
throw Exception('Fatigue Limit Exceeded');
}
@@ -244,12 +248,15 @@ class HomeCaptainController extends GetxController {
activeTimer?.cancel();
update();
}
Get.defaultDialog(
title: 'Safety First 🛑'.tr,
middleText: 'You have been driving for 12 hours. For your safety and compliance, please take a 6-hour break.'.tr,
middleText:
'You have been driving for 12 hours. For your safety and compliance, please take a 6-hour break.'
.tr,
barrierDismissible: false,
titleStyle: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
titleStyle:
const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
confirm: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Get.back(),
@@ -263,30 +270,34 @@ class HomeCaptainController extends GetxController {
Get.put(CaptainWalletController());
}
totalPoints = Get.find<CaptainWalletController>().totalPoints;
// Toggle Active State
isActive = !isActive;
if (isActive) {
try {
_checkFatigueBeforeOnline(); // Throws exception if tired
if (double.parse(totalPoints) > -200) {
locationController.startLocationUpdates();
HapticFeedback.heavyImpact();
activeStartTime = DateTime.now();
activeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
activeDuration = DateTime.now().difference(activeStartTime!);
stringActiveDuration = formatDuration(activeDuration);
// Increment Fatigue Counter
int totalSeconds = box.read('fatigue_total_seconds') ?? 0;
totalSeconds += 1;
box.write('fatigue_total_seconds', totalSeconds);
if (totalSeconds >= 12 * 3600) { // 12 hours
_forceOfflineDueToFatigue();
// Increment Fatigue Counter (write to box every 30s)
_fatigueSeconds++;
if (_fatigueSeconds % 30 == 0) {
int totalSeconds =
(box.read('fatigue_total_seconds') ?? 0) + _fatigueSeconds;
box.write('fatigue_total_seconds', totalSeconds);
_fatigueSeconds = 0;
if (totalSeconds >= 12 * 3600) {
// 12 hours
_forceOfflineDueToFatigue();
}
}
update();
@@ -311,7 +322,7 @@ class HomeCaptainController extends GetxController {
activeTimer?.cancel();
savePeriod(activeDuration);
activeDuration = Duration.zero;
// Save offline time for Fatigue Monitoring reset
box.write('fatigue_last_offline', DateTime.now().toIso8601String());
update();
@@ -486,6 +497,7 @@ class HomeCaptainController extends GetxController {
// late IntaleqMapController mapHomeCaptainController;
IntaleqMapController? mapHomeCaptainController;
LatLng? _lastCameraLoc; // لتتبع آخر موقع حرك الكاميرا
// --- FIX 2: Smart Map Creation ---
void onMapCreated(IntaleqMapController controller) {
@@ -504,7 +516,7 @@ class HomeCaptainController extends GetxController {
print(
"🔥 [HomeCaptain] Safely moving camera to: ${currentLoc.latitude}");
mapHomeCaptainController!.moveCamera(
CameraUpdate.newLatLngZoom(currentLoc, 15),
CameraUpdate.newLatLngZoom(currentLoc, 17.5),
);
} else {
print("🔥 [HomeCaptain] Safely moving to default Damascus");
@@ -680,19 +692,30 @@ class HomeCaptainController extends GetxController {
checkAndShowBlockDialog();
box.write(BoxName.statusDriverLocation, 'off');
// 2. عدل الليسنر ليصبح مشروطاً
// 2. مؤقت التتبع التلقائي (كل 5 ثوانٍ كما في الكود السابق)
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
// Camera follow timer — only moves when the driver has
// actually moved > 15 meters, saving GPU/battery on idle.
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 8), (timer) {
if (isClosed ||
!isHomeMapActive ||
mapHomeCaptainController == null ||
!isMapReadyForCommands ||
!isActive) return;
var loc = locationController.myLocation;
if (loc.latitude != 0 && loc.latitude != null && !loc.latitude.isNaN) {
// Skip if driver hasn't moved significantly
if (_lastCameraLoc != null) {
final double dist = Geolocator.distanceBetween(
_lastCameraLoc!.latitude,
_lastCameraLoc!.longitude,
loc.latitude,
loc.longitude,
);
if (dist < 15) return;
}
_lastCameraLoc = loc;
try {
// 🔥 Safety double-check before animating
if (mapHomeCaptainController != null) {
print("🔥 [HomeCaptain] Safely moving camera to: ${loc.latitude}");
mapHomeCaptainController?.animateCamera(
CameraUpdate.newLatLngZoom(loc, 17.5),
);

File diff suppressed because it is too large Load Diff

View File

@@ -219,14 +219,36 @@ class OrderRequestController extends GetxController
// ----------------------------------------------------------------------
Future<void> _calculateFullJourney() async {
if (mapController == null) return; // Wait for controller to draw
// Don't block on mapController being null - we'll draw routes
// and markers first, then zoom when controller is ready
bool canZoom = mapController != null;
try {
Position driverPos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
LatLng driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
// Reuse stored location from LocationController instead of
// making a duplicate GPS hardware call (already fetched in
// _initialMapSetup).
LatLng driverLatLng;
double driverHeading = 0.0;
if (Get.isRegistered<LocationController>()) {
final locCtrl = Get.find<LocationController>();
if (locCtrl.myLocation.latitude != 0 ||
locCtrl.myLocation.longitude != 0) {
driverLatLng = locCtrl.myLocation;
driverHeading = locCtrl.heading;
} else {
Position driverPos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
driverHeading = driverPos.heading;
}
} else {
Position driverPos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
driverHeading = driverPos.heading;
}
updateDriverLocation(driverLatLng, driverPos.heading);
updateDriverLocation(driverLatLng, driverHeading);
// Clear old polylines to avoid "ghost lines"
polylines.clear();
@@ -240,9 +262,9 @@ class OrderRequestController extends GetxController
var tripFuture = _fetchRouteData(
start: LatLng(latPassenger, lngPassenger),
end: LatLng(latDestination, lngDestination),
color: Colors.green,
color: Colors.black,
id: 'trip_route',
isDashed: true);
getSteps: true); // 🔥 نطلب الخطوات للمسار
var results = await Future.wait([pickupFuture, tripFuture]);
@@ -259,6 +281,11 @@ class OrderRequestController extends GetxController
totalTripDistance = tripResult['distance_text'];
totalTripDuration = tripResult['duration_text'];
polylines.add(tripResult['polyline']);
// 🔥 تخزين استجابة السيرفر كاملة (بما فيها الـ points والـ instructions)
if (tripResult['raw_response'] != null) {
box.write('cached_trip_route', tripResult['raw_response']);
}
}
await _updateMarkers(
@@ -267,8 +294,10 @@ class OrderRequestController extends GetxController
destTime: totalTripDuration,
destDist: totalTripDistance);
// Now zoom to fit all polylines and markers
zoomToFitRide();
// Now zoom to fit all polylines and markers (if controller available)
if (canZoom) {
zoomToFitRide();
}
update();
} catch (e) {
@@ -297,18 +326,19 @@ class OrderRequestController extends GetxController
required LatLng end,
required Color color,
required String id,
bool isDashed = false}) async {
bool getSteps = false}) async {
try {
if (start.latitude == 0 || end.latitude == 0) return null;
if (mapController == null) return null;
// Don't block on mapController — route data fetch is independent
final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: {
'fromLat': start.latitude.toString(),
'fromLng': start.longitude.toString(),
'toLat': end.latitude.toString(),
'toLng': end.longitude.toString(),
'steps': 'false',
'steps': getSteps ? 'true' : 'false',
'alternatives': 'false',
'locale': 'ar',
});
final response = await http.get(saasUrl, headers: {
@@ -347,7 +377,9 @@ class OrderRequestController extends GetxController
return {
'distance_text': distanceText,
'duration_text': durationText,
'polyline': polyline
'polyline': polyline,
'encoded_polyline': encodedPoints,
'raw_response': response.body, // 🔥 نمرر الـ JSON كاملاً
};
}
} catch (e) {

View File

@@ -0,0 +1,212 @@
<style>
* { box-sizing: border-box; }
.wrap { padding: 1.25rem 1rem; font-size: 14px; color: var(--color-text-primary); direction: rtl; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 3px; }
.sub { font-size: 13px; color: var(--color-text-secondary); margin: 0 0 1.25rem; }
.badge { display: inline-flex; align-items: center; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 20px; white-space: nowrap; }
.b-ok { background: var(--color-background-success); color: var(--color-text-success); }
.b-new { background: var(--color-background-danger); color: var(--color-text-danger); }
.b-med { background: var(--color-background-warning); color: var(--color-text-warning); }
.b-min { background: var(--color-background-info); color: var(--color-text-info); }
.progress-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 1.25rem; }
.pcard { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 12px 14px; }
.pcard .val { font-size: 28px; font-weight: 500; }
.pcard .lbl { font-size: 12px; color: var(--color-text-secondary); margin-top: 2px; }
.ok-val { color: var(--color-text-success); }
.bad-val { color: var(--color-text-danger); }
.new-val { color: var(--color-text-warning); }
.section { margin-bottom: 1.4rem; }
.section-hdr { font-size: 14px; font-weight: 500; margin: 0 0 8px; display: flex; align-items: center; gap: 8px; }
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); margin-bottom: 8px; overflow: hidden; }
.card.fixed { border-right: 3px solid var(--color-border-success); }
.card.broken{ border-right: 3px solid var(--color-border-danger); }
.card.new { border-right: 3px solid var(--color-border-warning); }
.card.minor { border-right: 3px solid var(--color-border-info); }
.ch { display: flex; align-items: flex-start; gap: 8px; padding: 10px 14px; cursor: pointer; }
.ch:hover { background: var(--color-background-secondary); }
.ch-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
.ch-title { font-size: 13.5px; font-weight: 500; flex: 1; line-height: 1.4; }
.ch-badge { flex-shrink: 0; }
.chev { font-size: 11px; color: var(--color-text-tertiary); transition: transform .2s; margin-right: auto; margin-left: 4px; }
.chev.open { transform: rotate(90deg); }
.cb { display: none; padding: 0 14px 14px; border-top: 0.5px solid var(--color-border-tertiary); }
.cb.open { display: block; }
.cb p { font-size: 13px; color: var(--color-text-secondary); line-height: 1.7; margin: 8px 0 6px; }
pre { font-family: var(--font-mono); font-size: 11.5px; background: var(--color-background-tertiary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 9px 11px; overflow-x: auto; margin: 6px 0; line-height: 1.6; white-space: pre; }
.fix { background: var(--color-background-success); border-radius: var(--border-radius-md); padding: 8px 11px; margin-top: 8px; font-size: 13px; line-height: 1.6; }
.fix strong { color: var(--color-text-success); font-size: 11px; display: block; margin-bottom: 2px; }
.warn { background: var(--color-background-warning); border-radius: var(--border-radius-md); padding: 8px 11px; margin-top: 8px; font-size: 13px; line-height: 1.6; }
.warn strong { color: var(--color-text-warning); font-size: 11px; display: block; margin-bottom: 2px; }
code { font-family: var(--font-mono); font-size: 12px; background: var(--color-background-secondary); padding: 0 4px; border-radius: 3px; }
.score-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 7px; }
.score-lbl { min-width: 160px; color: var(--color-text-secondary); }
.strack { flex: 1; height: 6px; background: var(--color-border-tertiary); border-radius: 3px; position: relative; }
.sfill { height: 100%; border-radius: 3px; }
.sval { min-width: 36px; font-size: 12px; color: var(--color-text-secondary); text-align: left; }
</style>
<div class="wrap">
<h1>مراجعة النسخة المحدّثة — V2</h1>
<p class="sub">مقارنة مع المراجعة السابقة · 16 مشكلة فُحصت</p>
<div class="progress-row">
<div class="pcard"><div class="val ok-val">11</div><div class="lbl">مشكلة مُصلحة ✅</div></div>
<div class="pcard"><div class="val bad-val">2</div><div class="lbl">مشكلة جديدة أدخلتها الإصلاحات ⚠️</div></div>
<div class="pcard"><div class="val new-val">3</div><div class="lbl">مشكلة لم تُعالج بعد</div></div>
<div class="pcard"><div class="val ok-val">69%</div><div class="lbl">تحسن من المراجعة الأولى</div></div>
</div>
<div class="score-row"><span class="score-lbl">صحة المنطق البرمجي</span><div class="strack"><div class="sfill" style="width:72%;background:#3B8BD4"></div></div><span class="sval">72% ↑</span></div>
<div class="score-row"><span class="score-lbl">نظافة الكود</span><div class="strack"><div class="sfill" style="width:63%;background:#1D9E75"></div></div><span class="sval">63% ↑</span></div>
<div class="score-row" style="margin-bottom:1.4rem"><span class="score-lbl">قابلية الصيانة</span><div class="strack"><div class="sfill" style="width:58%;background:#1D9E75"></div></div><span class="sval">58% ↑</span></div>
<!-- FIXED -->
<div class="section">
<div class="section-hdr"><span class="badge b-ok">✅ مُصلح</span> ما تم إصلاحه بشكل صحيح</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">C-1 — استبدال الحلقة التكرارية والاستدعاء الذاتي بـ <code>Timer.periodic</code></span><span class="ch-badge badge b-ok">ممتاز</span><span class="chev"></span></div>
<div class="cb"><p>تم حذف <code>updateLocation()</code> كاملاً واستبدالها بـ <code>startUpdateLocationTimer()</code> و <code>stopUpdateLocationTimer()</code>. التايمر مسجّل في <code>onClose()</code> و <code>_stopAllServices()</code>. إصلاح ممتاز.</p>
<div class="warn"><strong>ملاحظة مهمة</strong>لا يظهر في الكود استدعاء لـ <code>startUpdateLocationTimer()</code> من أي مكان. يجب التأكد أنها تُستدعى من الـ View أو من <code>startRideFromDriver()</code>.</div></div>
</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">C-4 — تحديث <code>myLocation</code> في <code>_handleLocationUpdate()</code></span><span class="chev"></span></div>
<div class="cb"><pre>void _handleLocationUpdate(geo.Position pos) {
final newLoc = LatLng(pos.latitude, pos.longitude);
myLocation = newLoc; // ← [Fix C-4] ✅ صحيح
// ...</pre></div>
</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">M-4 — دمج <code>checkForNextStep()</code> مع <code>_checkNavigationStep()</code></span><span class="chev"></span></div>
<div class="cb"><p><code>checkForNextStep</code> أصبحت wrapper بسيط يستدعي <code>_checkNavigationStep</code>. منطق واحد، لا تعارض.</p></div>
</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">M-5 — <code>disposeEverything()</code> لا تستدعي <code>onClose()</code> يدوياً</span><span class="chev"></span></div>
<div class="cb"><pre>void disposeEverything() {
_stopAllServices(); // ✅ بدون onClose()
}</pre></div>
</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">C-3 جزئي — دالة مساعدة <code>_parseDistanceToMeters()</code> مشتركة</span><span class="chev"></span></div>
<div class="cb"><p>تم استخراج منطق تحليل المسافة إلى دالة واحدة تستخدمها كلا <code>finishRideFromDriver()</code> و <code>_validateTripDistance()</code>. يحل مشكلة التضارب في الوحدات.</p>
<div class="warn"><strong>لم يُحل كاملاً</strong>التحقق من المسافة لا يزال يحدث مرتين (انظر مشكلة C-3 أدناه).</div></div>
</div>
<div class="card fixed">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">M-1 + M-2 + M-6 + N-1 + N-5 — إصلاحات طفيفة متعددة</span><span class="chev"></span></div>
<div class="cb">
<p><strong>M-1:</strong> <code>jitterMeters</code><code>jitterKm = 0.01</code></p>
<p><strong>M-2:</strong> <code>distance</code> المحلية → <code>distToPassenger</code></p>
<p><strong>M-6:</strong> تعليق يوضح أن الوحدة كيلومتر ✅</p>
<p><strong>N-1:</strong> <code>&directionsmode</code><code>?directionsmode</code></p>
<p><strong>N-5:</strong> إضافة <code>update()</code> في <code>getLocationArea()</code></p>
<p><strong>M-3:</strong> حذف <code>_performanceReadings</code> والمتغيرات الميتة ✅</p>
</div>
</div>
</div>
<!-- NEW BUGS -->
<div class="section">
<div class="section-hdr"><span class="badge b-new">🚨 جديد</span> مشاكل أدخلتها الإصلاحات</div>
<div class="card new">
<div class="ch" onclick="t(this)"><span class="ch-icon">🚨</span><span class="ch-title">BUG جديد — <code>Completer</code> في C-2 يُسبب Deadlock عند إغلاق الديالوج بـ Back</span><span class="ch-badge badge b-new">حرج</span><span class="chev"></span></div>
<div class="cb">
<p>الإصلاح استخدم <code>Completer</code> بشكل صحيح لحل مشكلة الـ callback الآني، لكنه أدخل مشكلة أخرى: لو أغلق المستخدم الديالوج بزر الرجوع (Back) في Android بدون ضغط OK، فإن <code>completer.future</code> لن تكتمل أبداً، والدالة ستبقى معلّقة (deadlock) لأن <code>_validateTripDistance()</code> هي <code>async</code> وتنتظر نتيجة لن تأتي:</p>
<pre>final completer = Completer&lt;bool&gt;();
MyDialog().getDialog('Exit Ride?'.tr, '', () {
if (!completer.isCompleted) completer.complete(true);
Get.back();
});
return await completer.future; // ← ينتظر للأبد إذا أُغلق بـ Back</pre>
<div class="fix"><strong>الحل</strong>أضف <code>barrierDismissible: false</code> للديالوج، أو استخدم <code>completer.complete(false)</code> عند إغلاق الديالوج بدون تأكيد (عبر <code>WillPopScope</code> أو <code>onDismissed</code> callback في <code>MyDialog</code>).</div>
</div>
</div>
<div class="card new">
<div class="ch" onclick="t(this)"><span class="ch-icon">🚨</span><span class="ch-title">C-3 لا يزال — المستخدم يرى ديالوجَي تأكيد متتاليَين عند إنهاء الرحلة بالزر</span><span class="ch-badge badge b-new">حرج</span><span class="chev"></span></div>
<div class="cb">
<p>رغم إضافة <code>_parseDistanceToMeters()</code>، تدفق الكود لا يزال يُقدّم ديالوجَين:</p>
<pre>// finishRideFromDriver(isFromSlider: false):
MyDialog().getDialog('Are you sure to exit ride?', '', () {
Get.back();
finishRideFromDriver1(); // ← isFromSlider = false افتراضياً
});
// finishRideFromDriver1():
if (!await _validateTripDistance(false)) return; // ← يُقدّم ديالوجاً ثانياً!</pre>
<p>المستخدم يرى "هل أنت متأكد؟" → يضغط OK → يرى "Exit Ride?" مرة ثانية → ينتظر مجدداً.</p>
<div class="fix"><strong>الحل</strong>احذف الديالوج من <code>finishRideFromDriver()</code> وأبقه في <code>_validateTripDistance()</code> فقط. أو مرّر <code>isFromSlider: true</code> لما يأتي من موافقة مسبقة.</div>
</div>
</div>
</div>
<!-- REMAINING -->
<div class="section">
<div class="section-hdr"><span class="badge b-med">⚠️ لم تُعالج</span> مشاكل لا تزال قائمة</div>
<div class="card broken">
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">M-7 — Null checks على <code>String</code> غير قابلة للـ null</span><span class="chev"></span></div>
<div class="cb">
<pre>if (isSocialPressed == true && passengerId != null && rideId != null) {
// ^^^^^^^^^^^ دائماً non-null</pre>
<p>لو <code>passengerId == ''</code> يمر الشرط ويُرسل بيانات فارغة للسيرفر. الفحص الصحيح: <code>passengerId.isNotEmpty && rideId.isNotEmpty</code>.</p>
</div>
</div>
<div class="card broken">
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">N-2 — تأخير 1 ثانية Hardcoded في <code>argumentLoading()</code></span><span class="chev"></span></div>
<div class="cb">
<pre>await Future.delayed(const Duration(seconds: 1));
await getRoute(...);</pre>
<p>لا يزال موجوداً. Race condition يجب معالجته بـ <code>Completer</code> بدلاً من تخمين الوقت.</p>
</div>
</div>
<div class="card broken">
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">N-4 — <code>step0</code> إلى <code>step4</code> بدلاً من <code>List&lt;String&gt;</code></span><span class="chev"></span></div>
<div class="cb">
<pre>String step0 = ''; String step1 = ''; // ...
step0 = Get.arguments['step0']?.toString() ?? '';
step1 = Get.arguments['step1']?.toString() ?? '';</pre>
<p>لا تزال 5 متغيرات منفصلة. <code>List&lt;String&gt; steps = List.filled(5, '')</code> أوضح وأسهل في المعالجة.</p>
</div>
</div>
</div>
<!-- STILL MINOR -->
<div class="section">
<div class="section-hdr"><span class="badge b-min"> بسيطة</span> ملاحظات إضافية على هذه النسخة</div>
<div class="card minor">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title"><code>_suggestOptimization()</code> لا تزال موجودة لكن لا يستدعيها أحد</span><span class="chev"></span></div>
<div class="cb"><p>بعد حذف <code>_performanceReadings</code> و <code>_analyzePerformance()</code>، بقيت <code>_suggestOptimization()</code> معزولة. إما أن تُستدعى من مكان ما أو تُحذف.</p></div>
</div>
<div class="card minor">
<div class="ch" onclick="t(this)"><span class="ch-icon"></span><span class="ch-title">الاستيرادات المكررة لـ <code>dart:math</code> و <code>geolocator</code> لا تزال</span><span class="chev"></span></div>
<div class="cb">
<pre>import 'dart:math';
import 'dart:math' as math; // مكرر
import 'package:geolocator/geolocator.dart' as geo;
import 'package:geolocator/geolocator.dart'; // مكرر</pre>
<p>يُسبب تحذيرات من المحلل ويُشوّش قراءة الكود. احذف النسخة غير المعرّفة.</p>
</div>
</div>
</div>
</div>
<script>
function t(header) {
const b = header.nextElementSibling;
const ch = header.querySelector('.chev');
const o = b.classList.contains('open');
b.classList.toggle('open', !o);
if (ch) ch.classList.toggle('open', !o);
}
</script>

View File

@@ -93,13 +93,13 @@ class NavigationController extends GetxController
String totalDistanceRemaining = "";
String estimatedTimeRemaining = "";
dynamic currentManeuverModifier = 0;
String arrivalTime = "--:--";
String arrivalTime = "--:--"; // NEW: For the active navigation HUD
double _routeTotalDistanceM = 0;
double _routeTotalDurationS = 0;
bool isNavigating = false;
bool isMuted = false;
bool isMuted = false; // Sound toggle state
String distanceWithUnit = "";
bool _cameraLockedToUser = true;
bool _mapReady = false;
@@ -114,6 +114,7 @@ class NavigationController extends GetxController
Future<void> submitNewPlace(String name, String category) async {
if (mapController == null || name.isEmpty || category.isEmpty) return;
// Get current center of the map as the picked location
final LatLng pickedPos = mapController!.cameraPosition!.target;
isLoading = true;
@@ -139,15 +140,21 @@ class NavigationController extends GetxController
isLoading = false;
if (response != null) {
HapticFeedback.lightImpact();
mySnackbarSuccess('Place added successfully! Thanks for your contribution.'.tr);
mySnackbarSuccess(box.read(BoxName.lang) == 'ar'
? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.'
: 'Place added successfully! Thanks for your contribution.');
isSelectingPlaceLocation = false;
} else {
mySnackbarWarning('Failed to add place. Please try again later.'.tr);
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'تعذر إضافة المكان. يرجى المحاولة لاحقاً.'
: 'Failed to add place. Please try again later.');
}
update();
} catch (e) {
isLoading = false;
mySnackbarWarning('An error occurred while connecting to the server.'.tr);
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'حدث خطأ أثناء الاتصال بالخادم.'
: 'An error occurred while connecting to the server.');
update();
}
}
@@ -181,6 +188,7 @@ class NavigationController extends GetxController
return 55.0;
}
// Categories list for the picker
static final List<Map<String, String>> placeCategories = [
{
'id': 'restaurant',
@@ -303,6 +311,7 @@ class NavigationController extends GetxController
_smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
if (isStyleLoaded) {
_updateCarMarker();
if (_cameraLockedToUser) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading,
@@ -365,7 +374,6 @@ class NavigationController extends GetxController
void onMapCreated(IntaleqMapController controller) async {
Log.print("DEBUG: NavigationController.onMapCreated called");
mapController = controller;
await onStyleLoaded();
}
Future<void> onStyleLoaded() async {
@@ -381,6 +389,7 @@ class NavigationController extends GetxController
if (myLocation != null) {
Log.print("DEBUG: Animating camera to initial location: $myLocation");
animateCameraToPosition(myLocation!);
_updateCarMarker();
}
if (_fullRouteCoordinates.isNotEmpty) {
Log.print("DEBUG: Updating initial polylines");
@@ -394,7 +403,7 @@ class NavigationController extends GetxController
if (isNavigating || routes.isEmpty) return;
int? bestIndex;
double minDistance = 100.0;
double minDistance = 100.0; // 100 meters threshold for tap
for (int i = 0; i < routes.length; i++) {
for (var coord in routes[i].coordinates) {
@@ -422,12 +431,12 @@ class NavigationController extends GetxController
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text('Start Navigation?'.tr,
style: const TextStyle(fontWeight: FontWeight.bold)),
content: Text('Do you want to go to this location?'.tr),
title: const Text('بدء الملاحة؟',
style: TextStyle(fontWeight: FontWeight.bold)),
content: const Text('هل تريد الذهاب إلى هذا الموقع؟'),
actions: [
TextButton(
child: Text('Cancel'.tr, style: const TextStyle(color: Colors.grey)),
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
onPressed: () => Get.back()),
ElevatedButton(
style: ElevatedButton.styleFrom(
@@ -435,10 +444,10 @@ class NavigationController extends GetxController
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12))),
child:
Text('Go Now'.tr, style: const TextStyle(color: Colors.white)),
const Text('اذهب الآن', style: TextStyle(color: Colors.white)),
onPressed: () {
Get.back();
startNavigationTo(tappedPoint, infoWindowTitle: 'Selected Location'.tr);
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
},
),
],
@@ -458,6 +467,7 @@ class NavigationController extends GetxController
_smoothedHeading = position.heading;
update();
if (isStyleLoaded) animateCameraToPosition(myLocation!);
// Start the Location Stream for real-time updates
_startLocationStream();
_startBatchTimers();
} catch (e) {
@@ -467,10 +477,12 @@ class NavigationController extends GetxController
void _startLocationStream() {
_locationStreamSubscription?.cancel();
// Listen to location updates with minimum distance filter of 2 meters
// This provides real-time updates without the 3-4 second delay
_locationStreamSubscription = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 2,
distanceFilter: 2, // Update every 2 meters
),
).listen(
(Position position) {
@@ -489,8 +501,9 @@ class NavigationController extends GetxController
try {
final newLoc = LatLng(position.latitude, position.longitude);
currentSpeed = position.speed * 3.6;
currentSpeed = position.speed * 3.6; // Convert m/s to km/h
// Skip if movement is too small
if (_lastProcessedLocation != null) {
final d = Geolocator.distanceBetween(
newLoc.latitude,
@@ -507,6 +520,7 @@ class NavigationController extends GetxController
Log.print(
"DEBUG: Location update - Speed: ${currentSpeed.toStringAsFixed(1)} km/h, Loc: $newLoc");
// Update total distance
if (_lastDistanceLocation != null) {
final d = Geolocator.distanceBetween(
_lastDistanceLocation!.latitude,
@@ -536,6 +550,8 @@ class NavigationController extends GetxController
_animController?.forward(from: 0.0);
_lastProcessedLocation = newLoc;
if (isStyleLoaded) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
_updateTraveledPolylineSmart(newLoc);
_checkNavigationStep(newLoc);
@@ -556,7 +572,7 @@ class NavigationController extends GetxController
}
void _checkOffRoute(LatLng pos) {
if (_autoRecalcInProgress || isLoading) return;
if (!isNavigating || _autoRecalcInProgress || isLoading) return;
if (_fullRouteCoordinates.isEmpty) return;
const int searchWindow = 80;
@@ -591,33 +607,11 @@ class NavigationController extends GetxController
}
}
/// Recalculate immediately from the latest GPS point to the destination.
Future<void> _smartRecalculateRoute(LatLng currentPos) async {
try {
if (routes.isNotEmpty && selectedRouteIndex < routes.length - 1) {
final nextIndex = selectedRouteIndex + 1;
final nextRoute = routes[nextIndex];
double minDist = double.infinity;
for (var coord in nextRoute.coordinates) {
final d = Geolocator.distanceBetween(
currentPos.latitude,
currentPos.longitude,
coord.latitude,
coord.longitude,
);
if (d < minDist) minDist = d;
}
if (minDist < 100) {
selectRoute(nextIndex);
Log.print("DEBUG: Switched to alternative route due to deviation");
_autoRecalcInProgress = false;
return;
}
}
if (_finalDestination != null) {
await recalculateRoute();
await recalculateRoute(origin: currentPos, keepNavigationActive: true);
}
_autoRecalcInProgress = false;
} catch (e) {
@@ -669,13 +663,13 @@ class NavigationController extends GetxController
if (_trackBuffer.isEmpty) return;
final batch = List<Map<String, dynamic>>.from(_trackBuffer);
_trackBuffer.clear();
final String driverId = (box.read(BoxName.driverID) ?? '').toString();
final String passengerId = (box.read(BoxName.passengerID) ?? '').toString();
try {
await CRUD().post(
link: '${AppLink.locationServerSide}/add_batch.php',
payload: {
'driver_id': driverId,
'driver_id': passengerId,
'batch_data': jsonEncode(batch),
'session_dist': totalDistance.toStringAsFixed(1),
},
@@ -685,6 +679,10 @@ class NavigationController extends GetxController
}
}
Future<void> _updateCarMarker() async {
// Car marker is now handled natively by myLocationEnabled: true.
}
void animateCameraToPosition(LatLng position,
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
if (!_mapReady || mapController == null) return;
@@ -774,8 +772,11 @@ class NavigationController extends GetxController
Future<void> _updatePolylinesSets(
List<LatLng> traveled, List<LatLng> remaining) async {
Log.print(
"DEBUG: Updating polylines. Traveled: ${traveled.length}, Remaining: ${remaining.length}");
Set<Polyline> newPolylines = {};
// Render Alternative Routes first
for (int i = 0; i < routes.length; i++) {
if (i == selectedRouteIndex) continue;
newPolylines.add(Polyline(
@@ -840,7 +841,7 @@ class NavigationController extends GetxController
if (dest != null && myLocation != null) {
getRoute(myLocation!, dest);
} else {
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'الموقع غير متاح حالياً.' : 'Location not available.');
mySnackbarWarning('الموقع غير متاح حالياً.');
}
}
@@ -865,12 +866,13 @@ class NavigationController extends GetxController
LatLng getAirportLatLng() {
final String country = box.read(BoxName.countryCode) ?? 'JO';
if (country == 'SY') {
return const LatLng(33.4111, 36.5147);
return const LatLng(33.4111, 36.5147); // Damascus Airport
}
return const LatLng(31.7225, 35.9933);
return const LatLng(31.7225, 35.9933); // Queen Alia Airport (JO)
}
Future<void> getRoute(LatLng origin, LatLng destination) async {
Future<void> getRoute(LatLng origin, LatLng destination,
{bool keepNavigationActive = false}) async {
isLoading = true;
update();
@@ -899,12 +901,13 @@ class NavigationController extends GetxController
if (response.statusCode != 200) {
isLoading = false;
update();
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'تعذر الاتصال بخدمة التوجيه.' : 'Failed to connect to routing service.');
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
return;
}
final data = jsonDecode(response.body);
// ── Parse primary route (top-level in response) ──
routes.clear();
final primaryPts = data['points']?.toString() ?? "";
if (primaryPts.isNotEmpty) {
@@ -919,8 +922,10 @@ class NavigationController extends GetxController
));
}
// ── Parse alternative routes (in data['alternatives']) ──
// إذا كان هناك routes بديلة متاحة من API
if (data['alternatives'] != null && data['alternatives'] is List) {
_hasAlternativeRoutes = (data['alternatives'] as List).isNotEmpty;
_hasAlternativeRoutes = data['alternatives'].isNotEmpty;
for (var alt in data['alternatives']) {
final altPts = alt['points']?.toString() ?? "";
if (altPts.isEmpty) continue;
@@ -934,6 +939,9 @@ class NavigationController extends GetxController
points: altPts,
));
}
if (_hasAlternativeRoutes) {
Log.print("DEBUG: ${routes.length - 1} alternative routes available");
}
} else {
_hasAlternativeRoutes = false;
}
@@ -941,7 +949,7 @@ class NavigationController extends GetxController
if (routes.isEmpty) {
isLoading = false;
update();
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'لم يتم العثور على مسار.' : 'No route found.');
mySnackbarWarning('لم يتم العثور على مسار.');
return;
}
@@ -965,8 +973,8 @@ class NavigationController extends GetxController
currentStepIndex = 0;
_nextInstructionSpoken = false;
isNavigating = false;
_cameraLockedToUser = false;
isNavigating = keepNavigationActive;
_cameraLockedToUser = keepNavigationActive;
_offRouteStartTime = null;
isLoading = false;
@@ -986,7 +994,13 @@ class NavigationController extends GetxController
}
}
if (_fullRouteCoordinates.length >= 2) {
// Re-add car marker after polyline updates (ensures it stays on top)
if (isStyleLoaded) _updateCarMarker();
if (keepNavigationActive && myLocation != null) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
} else if (_fullRouteCoordinates.length >= 2) {
final bounds =
data['bbox'] != null && (data['bbox'] as List).length == 4
? LatLngBounds(
@@ -1012,17 +1026,22 @@ class NavigationController extends GetxController
final remainingM = _routeTotalDistanceM * fraction;
final remainingS = _routeTotalDurationS * fraction;
// Distance
final String langCode = box.read(BoxName.lang) ?? 'ar';
if (remainingM > 1000) {
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
// We will handle the unit in the view or provide a unit string here
} else {
totalDistanceRemaining = remainingM.toStringAsFixed(0);
}
// New variable to hold formatted distance with unit
distanceWithUnit = _formatDistance(remainingM, langCode);
// Time Remaining
final minutes = (remainingS / 60).round();
estimatedTimeRemaining = minutes.toString();
// Arrival Time Calculation
final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt()));
final h = arrival.hour > 12
? arrival.hour - 12
@@ -1040,6 +1059,7 @@ class NavigationController extends GetxController
_finalDestination = destination;
await clearRoute(isNewRoute: true);
// Preserve car marker if it exists
markers = markers.where((m) => m.markerId.value == 'car').toSet();
markers.add(Marker(
@@ -1065,12 +1085,23 @@ class NavigationController extends GetxController
}
}
Future<void> recalculateRoute() async {
if (myLocation == null || _finalDestination == null || isLoading) return;
Future<void> recalculateRoute(
{LatLng? origin, bool keepNavigationActive = false}) async {
final LatLng? routeOrigin = origin ?? myLocation;
if (routeOrigin == null || _finalDestination == null || isLoading) return;
isLoading = true;
update();
mySnackbarInfo(box.read(BoxName.lang) == 'ar' ? 'جاري حساب مسار جديد...' : 'Calculating new route...');
await getRoute(myLocation!, _finalDestination!);
markers = markers.where((m) => m.markerId.value != 'origin').toSet();
markers.add(Marker(
markerId: const MarkerId('origin'),
position: routeOrigin,
icon: InlqBitmap.fromStyleImage('start_icon'),
));
await getRoute(routeOrigin, _finalDestination!,
keepNavigationActive: keepNavigationActive);
isLoading = false;
update();
}
@@ -1087,8 +1118,11 @@ class NavigationController extends GetxController
isNavigating = true;
_cameraLockedToUser = true;
// Ensure ETA and distances are up-to-date
_lastTraveledIndexInFullRoute = _lastTraveledIndexInFullRoute;
_recomputeETA();
// Initialize current instruction if available
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
@@ -1105,6 +1139,7 @@ class NavigationController extends GetxController
}
}
// Center camera on user for navigation mode
if (myLocation != null) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
@@ -1145,22 +1180,21 @@ class NavigationController extends GetxController
_routeTotalDistanceM = 0;
_routeTotalDurationS = 0;
if (!isNewRoute) {
await _updateCarMarker();
}
update();
}
Future<void> _loadCustomIcons() async {
if (mapController == null) return;
try {
final carBytes = await rootBundle.load('assets/images/car.png');
final startBytes = await rootBundle.load('assets/images/A.png');
final destBytes = await rootBundle.load('assets/images/b.png');
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
await mapController!
.addImage('start_icon', startBytes.buffer.asUint8List());
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
} catch (e) {
Log.print("Error loading custom icons: $e");
}
final carBytes = await rootBundle.load('assets/images/car.png');
final startBytes = await rootBundle.load('assets/images/A.png');
final destBytes = await rootBundle.load('assets/images/b.png');
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
await mapController!
.addImage('start_icon', startBytes.buffer.asUint8List());
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
}
void _checkNavigationStep(LatLng pos) {
@@ -1233,18 +1267,21 @@ class NavigationController extends GetxController
if (mapController == null) return;
try {
// ✅ Use searchPlaces from intaleq_maps SDK
final results = await mapController!.searchPlaces(q);
if (myLocation != null) {
for (final p in results) {
final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0;
final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0;
p['distanceKm'] = _haversineKm(myLocation!.latitude, myLocation!.longitude, plat, plng);
final plng =
double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0;
p['distanceKm'] = _haversineKm(
myLocation!.latitude, myLocation!.longitude, plat, plng);
}
results.sort((a, b) =>
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
}
placesDestination = results;
update();
} catch (e) {
@@ -1258,7 +1295,7 @@ class NavigationController extends GetxController
final lat = double.parse(place['latitude'].toString());
final lng = double.parse(place['longitude'].toString());
await startNavigationTo(LatLng(lat, lng),
infoWindowTitle: place['name'] ?? (box.read(BoxName.lang) == 'ar' ? 'وجهة' : 'Destination'));
infoWindowTitle: place['name'] ?? 'وجهة');
}
void onSearchChanged(String query) {
@@ -1278,6 +1315,9 @@ class NavigationController extends GetxController
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
}
double _kmToLatDelta(double km) => km / 111.32;
double _kmToLngDelta(double km, double lat) =>
km / (111.32 * cos(lat * pi / 180));
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
double? x0, x1, y0, y1;
for (final ll in list) {
@@ -1328,12 +1368,12 @@ class NavigationController extends GetxController
'name': name,
'lat': myLocation!.latitude.toString(),
'lng': myLocation!.longitude.toString(),
'driver_id': box.read(BoxName.driverID),
'passenger_id': box.read(BoxName.passengerID),
};
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
mySnackbarInfo(box.read(BoxName.lang) == 'ar'
? "تم استلام اقتراحك! شكراً لمساهمتك."
: "Suggestion received! Thanks for your contribution.");
? "تم استلام اقتراحك! مكافأتك: +٥٠ نقطة"
: "Suggestion received! Reward: +50 points");
} finally {
isLoading = false;
update();

View File

@@ -0,0 +1,201 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:sefer_driver/constant/box_name.dart';
import 'package:sefer_driver/constant/links.dart';
import 'package:sefer_driver/controller/functions/crud.dart';
import 'package:sefer_driver/main.dart';
import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';
import 'package:sefer_driver/controller/functions/encrypt_decrypt.dart';
import 'package:sefer_driver/env/env.dart';
import 'package:sefer_driver/print.dart';
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
import 'package:sefer_driver/views/widgets/mydialoug.dart';
class ComplaintController extends GetxController {
bool isLoading = false;
final formKey = GlobalKey<FormState>();
final complaintController = TextEditingController();
List<dynamic> ridesList = [];
Map<String, dynamic>? selectedRide;
Map<String, dynamic>? passengerReport;
Map<String, dynamic>? driverReport;
var isUploading = false.obs;
var uploadSuccess = false.obs;
String audioLink = ''; // سيتم تخزين رابط الصوت هنا بعد الرفع
String attachedFileName = '';
@override
void onInit() {
super.onInit();
getLatestRidesForDriver();
}
void _showCustomSnackbar(String title, String message,
{bool isError = false}) {
if (title.toLowerCase() == 'success') {
mySnackbarSuccess(message.tr);
} else if (isError) {
mySnackeBarError(message.tr);
} else {
mySnackbarWarning(message.tr);
}
}
Future<void> getLatestRidesForDriver() async {
isLoading = true;
update();
try {
var res = await CRUD().get(link: AppLink.getRides, payload: {
'driver_id': box.read(BoxName.driverID).toString(),
});
if (res != 'failure' && res != 'no_internet') {
var decoded = jsonDecode(res);
if (decoded['status'] == 'success') {
ridesList = decoded['data'] ?? [];
if (ridesList.isNotEmpty) {
selectedRide = ridesList[0];
}
}
}
} catch (e) {
Log.print("Error getting driver rides: $e");
} finally {
isLoading = false;
update();
}
}
void selectRide(Map<String, dynamic> ride) {
selectedRide = ride;
audioLink = '';
attachedFileName = '';
update();
}
Future<void> uploadAudioFile(File audioFile) async {
try {
isUploading.value = true;
update();
var uri = Uri.parse(AppLink.uploadAudio);
var request = http.MultipartRequest('POST', uri);
String token = r(box.read(BoxName.jwt)).toString().split(Env.addd)[0];
final String fingerPrint = box.read(BoxName.deviceFingerprint)?.toString() ?? '';
var mimeType = lookupMimeType(audioFile.path);
request.headers.addAll({
'Authorization': 'Bearer $token',
'X-Device-FP': fingerPrint,
});
request.files.add(
await http.MultipartFile.fromPath(
'audio',
audioFile.path,
contentType: mimeType != null ? MediaType.parse(mimeType) : null,
),
);
var response = await request.send();
var responseBody = await http.Response.fromStream(response);
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(responseBody.body);
if (jsonResponse['status'] == 'Audio file uploaded successfully.') {
uploadSuccess.value = true;
audioLink = jsonResponse['link'];
attachedFileName = audioFile.path.split('/').last;
_showCustomSnackbar('Success', 'Audio uploaded successfully.');
} else {
uploadSuccess.value = false;
_showCustomSnackbar('Error', 'Failed to upload audio file.',
isError: true);
}
} else {
uploadSuccess.value = false;
_showCustomSnackbar('Error', 'Server error: ${response.statusCode}',
isError: true);
}
} catch (e) {
uploadSuccess.value = false;
_showCustomSnackbar(
'Error', 'An application error occurred during upload.',
isError: true);
} finally {
isUploading.value = false;
update();
}
}
Future<void> submitComplaintToServer() async {
if (!formKey.currentState!.validate() || complaintController.text.isEmpty) {
_showCustomSnackbar(
'Error', 'Please describe your issue before submitting.',
isError: true);
return;
}
if (selectedRide == null) {
_showCustomSnackbar('Error', 'Please select a ride before submitting.',
isError: true);
return;
}
isLoading = true;
update();
try {
final rideId = selectedRide!['id'].toString();
final complaint = complaintController.text;
final responseData = await CRUD().post(
link: AppLink.add_solve_all,
payload: {
'ride_id': rideId,
'complaint_text': complaint,
'audio_link': audioLink,
},
);
if (responseData == 'failure' || responseData == 'no_internet' || responseData == 'token_expired') {
_showCustomSnackbar(
'Error', 'Failed to connect to the server. Please try again.',
isError: true);
return;
}
if (responseData['status'] == 'success') {
passengerReport = responseData['data']['passenger_response'];
driverReport = responseData['data']['driver_response'];
update();
MyDialogContent().getDialog(
'Success'.tr, Text('Your complaint has been submitted.'.tr), () {
Get.back();
complaintController.clear();
audioLink = '';
attachedFileName = '';
formKey.currentState?.reset();
});
} else {
String errorMessage =
responseData['message'] ?? 'An unknown server error occurred'.tr;
_showCustomSnackbar('Submission Failed', errorMessage, isError: true);
}
} catch (e) {
Log.print("Submit Complaint Error: $e");
_showCustomSnackbar('Error', 'An application error occurred.'.tr,
isError: true);
} finally {
isLoading = false;
update();
}
}
}

View File

@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:sefer_driver/controller/auth/captin/login_captin_controller.dart';
import 'package:sefer_driver/views/auth/captin/login_captin.dart';
import 'package:sefer_driver/views/home/on_boarding_page.dart';
import '../functions/app_update_controller.dart';
import '../../constant/box_name.dart';
import '../../main.dart';
@@ -33,6 +34,7 @@ class SplashScreenController extends GetxController
@override
void onInit() {
super.onInit();
Get.put(AppUpdateController()); // تهيئة متحكم التحديثات الذكي
_setupAnimations();
_initializeAndNavigate();
checkSecurity();

View File

@@ -15,6 +15,7 @@ class MyTranslation extends Translations {
"Intaleq Wallet": "محفظة انطلق",
"KM": "كم",
"Minutes": "دقايق",
"You haven't moved sufficiently!": 'لم تتحرك بالقدر الكافي',
"Next as Cash !": "الرحلة الجاية كاش!",
"You Earn today is": "أرباحك اليوم هي",
"You Have in": "عندك بـ",
@@ -29,6 +30,7 @@ class MyTranslation extends Translations {
"below, I have reviewed and agree to the Terms of Use and acknowledge the Privacy Notice. I am at least 18 years of age.":
"تحت، راجعت ووافقت على شروط الاستخدام وبوافق على سياسة الخصوصية. عمري 18 سنة أو أكثر.",
"in your wallet": "بمحفظتك",
"is calling you": "عم يتصل فيك",
"is ON for this month": "مفعّلة هالشهر",
"tips\nTotal is": "الإكراميات\nالإجمالي هو",
"to arrive you.": "ليوصل لعندك.",
@@ -46,17 +48,22 @@ class MyTranslation extends Translations {
". The app will connect you with a nearby driver.":
". التطبيق رح يربطك بسائق قريب منك.",
"1. Describe Your Issue": "1. صف مشكلتك",
"1. Select Ride": "1. اختر المشوار",
"10 and get 4% discount": "10 واحصل على خصم 4%",
"100 and get 11% discount": "100 واحصل على خصم 11%",
"1999": "1999",
"2. Attach Recorded Audio": "2. أرفق التسجيل الصوتي",
"2. Attach Recorded Audio (Optional)":
"2. أرفق التسجيل الصوتي (اختياري)",
"2. Describe Your Issue": "2. اكتب وصف للمشكلة",
"20 and get 6% discount": "20 واحصل على خصم 6%",
"27\\": "27\\",
"3. Attach Recorded Audio (Optional)":
"3. أرفق التسجيل الصوتي (اختياري)",
"3. Review Details & Response": "3. راجع التفاصيل والرد",
"300 LE": "300 ل.م",
"3000 LE": "3000 ل.م",
"4. Review Details & Response": "4. راجع التفاصيل والرد",
"40 and get 8% discount": "40 واحصل على خصم 8%",
"5 digit": "5 أرقام",
"<< BACK": "<< رجوع",
@@ -73,6 +80,7 @@ class MyTranslation extends Translations {
"About Us": "من نحن",
"Abu Dhabi Commercial Bank Egypt": "بنك أبوظبي التجاري مصر",
"Abu Dhabi Islamic Bank Egypt": "بنك أبوظبي الإسلامي مصر",
"Accept": "قبول",
"Accept Order": "اقبل الطلب",
"Accept Ride": "اقبل الرحلة",
"Accepted Ride": "الرحلة انقبلت",
@@ -147,6 +155,7 @@ class MyTranslation extends Translations {
"An unexpected error occurred. Please try again.":
"صار خطأ غير متوقع. جرب مرة تانية.",
"An unexpected error occurred:": "صار خطأ غير متوقع:",
"An unknown server error occurred": "صار خطأ غير معروف بالسيرفر.",
"Any comments about the passenger?": "في أي تعليق على الراكب؟",
"App Dark Mode": "الوضع الليلي للتطبيق",
"App Preferences": "تفضيلات التطبيق",
@@ -270,6 +279,7 @@ class MyTranslation extends Translations {
"No data yet": "لا توجد بيانات بعد",
"h": "ساعة",
"Trip": "رحلة",
"Ride": "المشوار",
"Rides": "رحلات",
"Hours": "ساعات",
"Total Trips": "إجمالي الرحلات",
@@ -316,7 +326,10 @@ class MyTranslation extends Translations {
"But you have a negative salary of": "بس عندك راتب سلبي بقيمة",
"CODE": "الكود",
"Calculating...": "عم نحسب...",
"Calling non-Syrian numbers is not supported":
"الاتصال بالأرقام غير السورية غير مدعوم",
"Call": "اتصل",
"Call Connected": "تم فتح الاتصال",
"Call Driver": "اتصل بالسائق",
"Call End": "انتهت المكالمة",
"Call Income": "مكالمة واردة",
@@ -326,6 +339,7 @@ class MyTranslation extends Translations {
"Call Page": "صفحة الاتصال",
"Call Passenger": "اتصل بالراكب",
"Call Support": "اتصل بالدعم",
"Calling": "عم نتصل بـ",
"Camera Access Denied.": "تم رفض الوصول للكاميرا.",
"Camera not initialized yet": "الكاميرا ما تجهزت بعد",
"Camera not initilaized yet": "الكاميرا ما تجهزت بعد",
@@ -342,6 +356,7 @@ class MyTranslation extends Translations {
"Canceled": "ملغية",
"Canceled Orders": "الطلبات الملغاة",
"Cannot apply further discounts.": "ما في تطبيق خصومات إضافية.",
"Captain": "الكابتن",
"Capture an Image of Your Criminal Record":
"التقط صورة لصحيفة الحالة الجنائية",
"Capture an Image of Your Driver License": "التقط صورة لرخصة السائق",
@@ -443,6 +458,7 @@ class MyTranslation extends Translations {
"Confirm your Email": "تأكيد بريدك الإلكتروني",
"Confirmation": "تأكيد",
"Connected": "متصل",
"Connecting...": "عم يتم الاتصال...",
"Contact Options": "خيارات التواصل",
"Contact Support": "تواصل مع الدعم",
"Contact Support to Recharge": "تواصل مع الدعم للشحن",
@@ -488,6 +504,7 @@ class MyTranslation extends Translations {
"رقم هاتف العميل ما فيه محفظة عميل",
"Customer not found": "ما لقينا العميل",
"Customer phone is not active": "هاتف العميل مش شغال",
"Decline": "رفض",
"DISCOUNT": "خصم",
"DRIVER123": "DRIVER123",
"Date": "التاريخ",
@@ -686,6 +703,7 @@ class MyTranslation extends Translations {
"لرحلات انطلق والتوصيل، السعر بيحسب ديناميكياً. لرحلات الكومفورت، السعر بيعتمد على الوقت والمسافة.",
"For Intaleq and scooter trips, the price is calculated dynamically. For Comfort trips, the price is based on time and distance":
"لرحلات انطلاق والسكوتر، السعر بيحسب ديناميكياً. لرحلات الكومفورت، السعر بيعتمد على الوقت والمسافة.",
"Free Call": "مكالمة مجانية",
"Frequently Asked Questions": "الأسئلة الشائعة",
"Frequently Questions": "أسئلة متكررة",
"From": "من",
@@ -774,6 +792,7 @@ class MyTranslation extends Translations {
"I Arrive": "وصلت",
"I Arrive your site": "وصلت لموقعك",
"I Have Arrived": "أنا وصلت",
"I've arrived.": "لقد وصلت.",
"I added the wrong pick-up/drop-off location":
"حطيت مكان الالتقاط/التنزيل غلط",
"I arrive you": "وصلت لعندك",
@@ -839,6 +858,7 @@ class MyTranslation extends Translations {
"Intaleq Over": "انطلق انتهى",
"Intaleq Reminder": "تذكير انطلق",
"Intaleq Wallet Features:": "ميزات محفظة انطلق:",
"Intaleq's Response": "رد انطلق",
"Intaleq is a ride-sharing app designed with your safety and affordability in mind. We connect you with reliable drivers in your area, ensuring a convenient and stress-free travel experience.\nHere are some of the key features that set us apart:":
"انطلق تطبيق مشاركة رحلات مصمم لسلامتك وتوفير فلوسك. بنربطك بسواقين موثوقين بمنطقتك...",
"Intaleq is committed to safety, and all of our captains are carefully screened and background checked.":
@@ -967,6 +987,7 @@ class MyTranslation extends Translations {
"My location is correct. You can search for me using the navigation app":
"موقعي صحيح. تقدر تبحث عليي بتطبيق الملاحة",
"MyLocation": "موقعي",
"Mute": "كتم الصوت",
"N/A": "غير متاح",
"NEXT >>": "التالي >>",
"NEXT STEP": "الخطوة التالية",
@@ -1011,6 +1032,8 @@ class MyTranslation extends Translations {
"No accepted orders? Try raising your trip fee to attract riders.":
"ما في طلبات منقبولة؟ جرّب ترفع رسوم رحلتك عشان تجذب ركاب.",
"No audio files found.": "ما لقينا ملفات صوتية.",
"No audio files found for this ride.":
"ما لقينا تسجيلات صوتية لهاد المشوار.",
"No audio files recorded.": "ما في ملفات صوتية مسجلة.",
"No cars are available at the moment. Please try again later.":
"ما في سيارات متاحة هلق. تفضل جرّب مرة تانية لاحقاً.",
@@ -1047,6 +1070,8 @@ class MyTranslation extends Translations {
"No rides available for your vehicle type.":
"ما في رحلات متاحة لنوع سيارتك.",
"No rides available right now.": "ما في رحلات متاحة هلق.",
"No rides found to complain about.":
"ما لقينا أي مشاوير لحتى تقدم شكوى عليها.",
"No transactions this week": "ما في معاملات بهالأسبوع",
"No transactions yet": "ما في معاملات لسا",
"No trip data available": "ما في بيانات رحلة متاحة",
@@ -1233,6 +1258,8 @@ class MyTranslation extends Translations {
"Please enter your phone number": "يرجى إدخال رقم هاتفك",
"Please enter your phone number.": "تفضل أدخل رقم هاتفك.",
"Please enter your question": "تفضل أدخل سؤالك",
"Please select a ride before submitting.":
"تفضل اختر المشوار قبل ما ترسل.",
"Please go closer to the passenger location (less than 150m)":
"تفضل قرب من موقع الراكب (أقل من 150 متر)",
"Please go to Car Driver": "يرجى الذهاب إلى سائق السيارة",

View File

@@ -0,0 +1,749 @@
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;
// EN: Error message to display in UI when call setup fails.
// AR: رسالة الخطأ لعرضها في الواجهة عندما يفشل إعداد المكالمة.
var errorMessage = "".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<dynamic> _dynamicIceServers = [];
AudioPlayer? _ringtonePlayer;
void _startRingtone() async {
try {
_ringtonePlayer ??= AudioPlayer();
await _ringtonePlayer!.setAsset('assets/order.mp3');
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<void> 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 = driverId;
rideId.value = rideIdVal;
remoteName.value = remoteNameVal;
isMuted.value = false;
isSpeakerOn.value = false;
elapsedSeconds.value = 60;
_isReconnecting = false;
errorMessage.value = "";
_showCallBottomSheet();
HapticFeedback.vibrate();
try {
// 1. EN: Request Microphone Permission / AR: طلب صلاحية الميكروفون
if (!GetPlatform.isIOS) {
final permissionStatus = await Permission.microphone.request();
if (!permissionStatus.isGranted) {
errorMessage.value =
"Microphone permission is required for voice calls".tr;
_endCallInternal("permission_denied");
return;
}
}
// 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM.
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM.
final response = await CRUD().post(
link: "${AppLink.server}/ride/call/driver/create_call_session.php",
payload: {'ride_id': rideIdVal},
);
if (response == null ||
response == 'failure' ||
response['status'] != 'success') {
errorMessage.value =
"Failed to initiate call session. Please try again.".tr;
_endCallInternal("session_creation_failed");
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 passenger 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");
final errStr = e.toString().toLowerCase();
if (errStr.contains("permission") || errStr.contains("denied")) {
errorMessage.value =
"Microphone permission is required for voice calls".tr;
} else {
errorMessage.value = "Error starting voice call".tr;
}
_endCallInternal("error");
}
}
// EN: Handles incoming call requests via FCM/Socket.
// AR: معالجة طلبات المكالمات الواردة.
Future<void> 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.driverID).toString();
sessionId.value = sessionIdVal;
rideId.value = rideIdVal;
remoteName.value = remoteNameVal;
isMuted.value = false;
isSpeakerOn.value = false;
elapsedSeconds.value = 60;
_isReconnecting = false;
errorMessage.value = "";
_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<void> acceptCall() async {
if (state.value != VoiceCallState.ringing) return;
_ringingTimeoutTimer?.cancel();
_stopRingtone();
state.value = VoiceCallState.connecting;
errorMessage.value = "";
try {
// EN: Check Mic permissions / AR: التحقق من صلاحيات الميكروفون
if (!GetPlatform.isIOS) {
final permissionStatus = await Permission.microphone.request();
if (!permissionStatus.isGranted) {
errorMessage.value =
"Microphone permission is required for voice calls".tr;
declineCall();
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");
final errStr = e.toString().toLowerCase();
if (errStr.contains("permission") || errStr.contains("denied")) {
errorMessage.value =
"Microphone permission is required for voice calls".tr;
} else {
errorMessage.value = "Error connecting call".tr;
}
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<void> _initializeLocalStream() async {
final Map<String, dynamic> 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<void> _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<void> _initializePeerConnection() async {
if (_peerConnection != null) return;
final List<Map<String, dynamic>> 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<String, dynamic> 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<void> _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<void> _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<void> _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();
}
}