Fixes & Updates - 2026-06-01: Integrate Back-End v3 updates, fix call/connection issues across apps
This commit is contained in:
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
92
lib/controller/functions/app_update_controller.dart
Normal file
92
lib/controller/functions/app_update_controller.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/controller/functions/audio_recorder_controller.dart
Normal file
80
lib/controller/functions/audio_recorder_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
212
lib/controller/home/captin/v2_review_delta.html
Normal file
212
lib/controller/home/captin/v2_review_delta.html
Normal 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<bool>();
|
||||
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<String></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<String> 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>
|
||||
@@ -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();
|
||||
|
||||
201
lib/controller/home/profile/complaint_controller.dart
Normal file
201
lib/controller/home/profile/complaint_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "يرجى الذهاب إلى سائق السيارة",
|
||||
|
||||
749
lib/controller/voice_call_controller.dart
Normal file
749
lib/controller/voice_call_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user