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

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

View File

@@ -97,6 +97,8 @@ flutter {
}
dependencies {
implementation "androidx.car.app:app:1.4.0"
implementation "org.maplibre.gl:android-sdk:11.0.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// تمت الترقية لتطابق تطبيق الراكب
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'

View File

@@ -36,3 +36,15 @@
-keep class com.sefer_driver.RootDetection {
native <methods>;
}
# Android Auto Car App Library
-keep class androidx.car.app.** { *; }
-keep class com.intaleq_driver.MyCarAppService { *; }
-keep class com.intaleq_driver.MyCarSession { *; }
-keep class com.intaleq_driver.MyCarScreen { *; }
-keep class com.intaleq_driver.CarNavigationData { *; }
-keep class com.intaleq_driver.MapPresentation { *; }
# MapLibre Native SDK
-keep class org.maplibre.android.** { *; }
-dontwarn org.maplibre.android.**

View File

@@ -70,6 +70,27 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="intaleqapp" />
</intent-filter>
<!-- Navigation Intents -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="google.navigation" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/map" />
</intent-filter>
</activity>
<!-- أنشطة ومكوّنات إضافية -->
<activity android:name="com.yalantis.ucrop.UCropActivity" android:screenOrientation="portrait"
@@ -146,6 +167,22 @@
</receiver>
<!-- مستقبل برودكاست خاص بك -->
<receiver android:name=".YourBroadcastReceiver" android:exported="false" />
<!-- Android Auto Support -->
<meta-data
android:name="androidx.car.app.minCarAppApiLevel"
android:value="1" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<service
android:name=".MyCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>
</application>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,44 @@
package com.intaleq_driver
import android.os.Handler
import android.os.Looper
/**
* كائن مشترك (Singleton) يحمل بيانات التوجيه في الوقت الحقيقي.
* يتم تحديثه من فلاتر عبر MethodChannel في MainActivity،
* ويتم قراءته من MyCarScreen و MapPresentation لعرض البيانات على شاشة السيارة.
*/
object CarNavigationData {
// --- بيانات الموقع ---
var currentLat: Double = 0.0
var currentLng: Double = 0.0
var currentBearing: Double = 0.0
// --- بيانات التوجيه ---
var currentInstruction: String = "في انتظار بدء الرحلة..."
var distanceToNextStepMeters: Double = 0.0
var totalDistanceRemainingMeters: Double = 0.0
var estimatedTimeRemainingSeconds: Double = 0.0
var maneuverType: Int = 0 // يطابق قيم Maneuver.TYPE_* من Car App Library
// --- حالة التوجيه ---
var isNavigating: Boolean = false
var currentSpeed: Double = 0.0 // km/h
// --- نظام المستمعين ---
private val listeners = mutableListOf<() -> Unit>()
fun addListener(listener: () -> Unit) {
listeners.add(listener)
}
fun removeListener(listener: () -> Unit) {
listeners.remove(listener)
}
fun notifyListeners() {
Handler(Looper.getMainLooper()).post {
listeners.forEach { it.invoke() }
}
}
}

View File

@@ -60,6 +60,50 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented()
}
}
// ✅ Channel for Android Auto Navigation Updates
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.intaleq_driver/car_navigation")
.setMethodCallHandler { call, result ->
when (call.method) {
"updateNavState" -> {
// تحديث شامل لجميع بيانات التوجيه دفعة واحدة
CarNavigationData.currentLat = call.argument<Double>("lat") ?: CarNavigationData.currentLat
CarNavigationData.currentLng = call.argument<Double>("lng") ?: CarNavigationData.currentLng
CarNavigationData.currentBearing = call.argument<Double>("bearing") ?: CarNavigationData.currentBearing
CarNavigationData.currentSpeed = call.argument<Double>("speed") ?: CarNavigationData.currentSpeed
CarNavigationData.currentInstruction = call.argument<String>("instruction") ?: CarNavigationData.currentInstruction
CarNavigationData.distanceToNextStepMeters = call.argument<Double>("distanceToStep") ?: CarNavigationData.distanceToNextStepMeters
CarNavigationData.totalDistanceRemainingMeters = call.argument<Double>("totalDistance") ?: CarNavigationData.totalDistanceRemainingMeters
CarNavigationData.estimatedTimeRemainingSeconds = call.argument<Double>("eta") ?: CarNavigationData.estimatedTimeRemainingSeconds
CarNavigationData.maneuverType = call.argument<Int>("maneuver") ?: CarNavigationData.maneuverType
CarNavigationData.isNavigating = call.argument<Boolean>("isNavigating") ?: CarNavigationData.isNavigating
CarNavigationData.notifyListeners()
result.success(true)
}
"updateLocation" -> {
CarNavigationData.currentLat = call.argument<Double>("lat") ?: 0.0
CarNavigationData.currentLng = call.argument<Double>("lng") ?: 0.0
CarNavigationData.currentBearing = call.argument<Double>("bearing") ?: CarNavigationData.currentBearing
CarNavigationData.currentSpeed = call.argument<Double>("speed") ?: CarNavigationData.currentSpeed
CarNavigationData.notifyListeners()
result.success(true)
}
"updateInstruction" -> {
CarNavigationData.currentInstruction = call.argument<String>("instruction") ?: ""
CarNavigationData.maneuverType = call.argument<Int>("maneuver") ?: 0
CarNavigationData.distanceToNextStepMeters = call.argument<Double>("distanceToStep") ?: 0.0
CarNavigationData.notifyListeners()
result.success(true)
}
"stopNavigation" -> {
CarNavigationData.isNavigating = false
CarNavigationData.currentInstruction = "تمت الرحلة بنجاح!"
CarNavigationData.notifyListeners()
result.success(true)
}
else -> result.notImplemented()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -0,0 +1,119 @@
package com.intaleq_driver
import android.app.Presentation
import android.content.Context
import android.os.Bundle
import android.view.Display
import android.view.ViewGroup
import android.widget.FrameLayout
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
/**
* شاشة عرض وهمية (Presentation) تُرسم على VirtualDisplay الخاص بشاشة السيارة.
* تستخدم MapLibre Native SDK لعرض الخريطة بنفس ستايل تطبيق انطلق.
*/
class MapPresentation(outerContext: Context, display: Display) : Presentation(outerContext, display) {
lateinit var mapView: MapView
private var mapboxMap: MapLibreMap? = null
private var isMapReady = false
private val locationListener: () -> Unit = {
updateCameraPosition()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MapLibre.getInstance(context)
val root = FrameLayout(context)
root.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
mapView = MapView(context)
mapView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
root.addView(mapView)
setContentView(root)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
mapboxMap = map
// تحميل ستايل خرائط انطلق من أصول فلاتر
val styleUrl = "asset://flutter_assets/assets/style.json"
map.setStyle(Style.Builder().fromUri(styleUrl)) {
isMapReady = true
updateCameraPosition()
}
// إعدادات مناسبة لشاشة السيارة
map.uiSettings.isCompassEnabled = false
map.uiSettings.isLogoEnabled = false
map.uiSettings.isAttributionEnabled = false
}
// الاستماع لتحديثات الموقع القادمة من فلاتر
CarNavigationData.addListener(locationListener)
}
private fun updateCameraPosition() {
if (!isMapReady || mapboxMap == null) return
val lat = CarNavigationData.currentLat
val lng = CarNavigationData.currentLng
val bearing = CarNavigationData.currentBearing
val speed = CarNavigationData.currentSpeed
if (lat == 0.0 && lng == 0.0) return
// حساب الزوم المناسب بناءً على السرعة (نفس المنطق في فلاتر)
val zoom = when {
speed < 15 -> 17.0
speed < 40 -> 16.5
speed < 70 -> 15.5
speed < 100 -> 15.0
else -> 14.0
}
// حساب الميل (Tilt) بناءً على السرعة لتأثير ثلاثي الأبعاد
val tilt = when {
speed < 10 -> 0.0
speed < 40 -> 40.0
else -> 55.0
}
val position = CameraPosition.Builder()
.target(LatLng(lat, lng))
.zoom(zoom)
.bearing(bearing)
.tilt(tilt)
.build()
mapboxMap?.animateCamera(
CameraUpdateFactory.newCameraPosition(position),
1000 // انتقال سلس خلال ثانية
)
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onStop() { super.onStop(); mapView.onStop() }
fun onResume() { mapView.onResume() }
fun onPause() { mapView.onPause() }
fun onDestroy() {
CarNavigationData.removeListener(locationListener)
mapView.onDestroy()
}
}

View File

@@ -0,0 +1,24 @@
package com.intaleq_driver
import android.content.pm.ApplicationInfo
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.validation.HostValidator
class MyCarAppService : CarAppService() {
override fun createHostValidator(): HostValidator {
// في وضع التطوير: نسمح لجميع المستضيفين (DHU + أي تطبيق)
// في وضع الإنتاج: نسمح فقط لتطبيقات Android Auto و Google الرسمية
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
.build()
}
}
override fun onCreateSession(): Session {
return MyCarSession()
}
}

View File

@@ -0,0 +1,80 @@
package com.intaleq_driver
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
class MyCarScreen(carContext: CarContext) : Screen(carContext) {
init {
CarNavigationData.addListener {
invalidate()
}
}
override fun onGetTemplate(): Template {
// إذا لم يكن التوجيه نشطاً بعد، نعرض شاشة ترحيبية
if (!CarNavigationData.isNavigating) {
return MessageTemplate.Builder("مرحباً بك في انطلق درايفر\nبانتظار بدء رحلة جديدة...")
.setTitle("Intaleq Driver")
.setHeaderAction(Action.APP_ICON)
.build()
}
// --- بناء معلومات التوجيه (Turn-by-Turn) ---
val maneuverType = mapIntaleqManeuverToCarManeuver(CarNavigationData.maneuverType)
val maneuver = Maneuver.Builder(maneuverType).build()
val step = Step.Builder(CarNavigationData.currentInstruction)
.setManeuver(maneuver)
.build()
val distanceToStep = Distance.create(
CarNavigationData.distanceToNextStepMeters,
Distance.UNIT_METERS
)
val routingInfo = RoutingInfo.Builder()
.setCurrentStep(step, distanceToStep)
.build()
// --- بناء قالب التوجيه ---
return NavigationTemplate.Builder()
.setNavigationInfo(routingInfo)
.setActionStrip(
androidx.car.app.model.ActionStrip.Builder()
.addAction(Action.APP_ICON)
.build()
)
.setBackgroundColor(CarColor.PRIMARY)
.build()
}
/**
* تحويل أكواد الانعطاف الخاصة بتطبيق انطلق (NavigationController.currentManeuverModifier)
* إلى أكواد Maneuver الرسمية من مكتبة Android for Cars.
*/
private fun mapIntaleqManeuverToCarManeuver(intaleqCode: Int): Int {
return when (intaleqCode) {
0 -> Maneuver.TYPE_STRAIGHT // مستقيم
2 -> Maneuver.TYPE_TURN_NORMAL_RIGHT // يمين
3 -> Maneuver.TYPE_TURN_SLIGHT_RIGHT // يمين خفيف
-2 -> Maneuver.TYPE_TURN_NORMAL_LEFT // يسار
-1 -> Maneuver.TYPE_TURN_SLIGHT_LEFT // يسار خفيف
4 -> Maneuver.TYPE_DESTINATION // وصلت
6 -> Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW // دوار
7 -> Maneuver.TYPE_KEEP_RIGHT // ابق يمين
-7 -> Maneuver.TYPE_KEEP_LEFT // ابق يسار
else -> Maneuver.TYPE_UNKNOWN
}
}
}

View File

@@ -0,0 +1,69 @@
package com.intaleq_driver
import android.content.Intent
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.Handler
import android.os.Looper
import androidx.car.app.AppManager
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.SurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class MyCarSession : Session(), DefaultLifecycleObserver {
private var virtualDisplay: VirtualDisplay? = null
private var presentation: MapPresentation? = null
override fun onCreateScreen(intent: Intent): Screen {
lifecycle.addObserver(this)
val appManager = carContext.getCarService(AppManager::class.java)
appManager.setSurfaceCallback(object : SurfaceCallback {
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
val surface = surfaceContainer.surface ?: return
val width = surfaceContainer.width
val height = surfaceContainer.height
val dpi = surfaceContainer.dpi
val displayManager = carContext.getSystemService(DisplayManager::class.java)
virtualDisplay = displayManager.createVirtualDisplay(
"CarAppMapDisplay",
width,
height,
dpi,
surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
)
virtualDisplay?.display?.let { display ->
Handler(Looper.getMainLooper()).post {
presentation = MapPresentation(carContext, display)
presentation?.show()
}
}
}
override fun onVisibleAreaChanged(visibleArea: android.graphics.Rect) {
// تحديث المساحة المرئية إذا لزم الأمر
}
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
Handler(Looper.getMainLooper()).post {
presentation?.dismiss()
presentation = null
}
virtualDisplay?.release()
virtualDisplay = null
}
})
return MyCarScreen(carContext)
}
override fun onResume(owner: LifecycleOwner) { presentation?.onResume() }
override fun onPause(owner: LifecycleOwner) { presentation?.onPause() }
override fun onDestroy(owner: LifecycleOwner) { presentation?.onDestroy() }
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="navigation" />
</automotiveApp>

View File

@@ -127,6 +127,7 @@ class AppLink {
static String getPlacesSyria = "$rideServer/places_syria/get.php";
static String getMishwari = "$rideServer/mishwari/get.php";
static String getMishwariDriver = "$rideServer/mishwari/getDriver.php";
static String sendChatMessage = "$server/ride/chat/send_message.php";
static String getTripCountByCaptain =
"$rideServer/rides/getTripCountByCaptain.php";
static String getRideOrderID = "$rideServer/rides/getRideOrderID.php";
@@ -229,6 +230,8 @@ class AppLink {
static String addFeedBack = "$ride/feedBack/add.php";
static String getFeedBack = "$ride/feedBack/get.php";
static String updateFeedBack = "$ride/feedBack/updateFeedBack.php";
static String add_solve_all = "$server/ride/feedBack/add_solve_all.php";
static String uploadAudio = "$server/upload_audio.php";
//-----------------Tips------------------
static String addTips = "$ride/tips/add.php";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:sefer_driver/constant/box_name.dart';
import 'dart:async';
@@ -29,7 +30,7 @@ class HomeCaptainController extends GetxController {
Timer? activeTimer;
Map data = {};
bool isHomeMapActive = true;
InlqBitmap carIcon = InlqBitmap.defaultMarker;
InlqBitmap carIcon = InlqBitmap.fromAsset('assets/images/car.png');
bool isMapReadyForCommands = false;
bool isLoading = true;
late double kazan = 0;
@@ -186,7 +187,8 @@ class HomeCaptainController extends GetxController {
_heatmapTimer?.cancel();
fetchAndDrawHeatmap();
_heatmapTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
// Refresh every 15 min instead of 5 to reduce data & battery usage
_heatmapTimer = Timer.periodic(const Duration(minutes: 15), (timer) {
if (isHeatmapVisible) {
print("🔄 [Heatmap] Periodic refresh started...");
fetchAndDrawHeatmap();
@@ -213,6 +215,7 @@ 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');
}
@@ -247,9 +251,12 @@ class HomeCaptainController extends GetxController {
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(),
@@ -280,14 +287,18 @@ class HomeCaptainController extends GetxController {
activeDuration = DateTime.now().difference(activeStartTime!);
stringActiveDuration = formatDuration(activeDuration);
// Increment Fatigue Counter
int totalSeconds = box.read('fatigue_total_seconds') ?? 0;
totalSeconds += 1;
// 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);
if (totalSeconds >= 12 * 3600) { // 12 hours
_fatigueSeconds = 0;
if (totalSeconds >= 12 * 3600) {
// 12 hours
_forceOfflineDueToFatigue();
}
}
update();
});
@@ -486,6 +497,7 @@ class HomeCaptainController extends GetxController {
// late IntaleqMapController mapHomeCaptainController;
IntaleqMapController? mapHomeCaptainController;
LatLng? _lastCameraLoc; // لتتبع آخر موقع حرك الكاميرا
// --- FIX 2: Smart Map Creation ---
void onMapCreated(IntaleqMapController controller) {
@@ -504,7 +516,7 @@ class HomeCaptainController extends GetxController {
print(
"🔥 [HomeCaptain] Safely moving camera to: ${currentLoc.latitude}");
mapHomeCaptainController!.moveCamera(
CameraUpdate.newLatLngZoom(currentLoc, 15),
CameraUpdate.newLatLngZoom(currentLoc, 17.5),
);
} else {
print("🔥 [HomeCaptain] Safely moving to default Damascus");
@@ -680,19 +692,30 @@ class HomeCaptainController extends GetxController {
checkAndShowBlockDialog();
box.write(BoxName.statusDriverLocation, 'off');
// 2. عدل الليسنر ليصبح مشروطاً
// 2. مؤقت التتبع التلقائي (كل 5 ثوانٍ كما في الكود السابق)
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
// Camera follow timer — only moves when the driver has
// actually moved > 15 meters, saving GPU/battery on idle.
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 8), (timer) {
if (isClosed ||
!isHomeMapActive ||
mapHomeCaptainController == null ||
!isMapReadyForCommands ||
!isActive) return;
var loc = locationController.myLocation;
if (loc.latitude != 0 && loc.latitude != null && !loc.latitude.isNaN) {
// Skip if driver hasn't moved significantly
if (_lastCameraLoc != null) {
final double dist = Geolocator.distanceBetween(
_lastCameraLoc!.latitude,
_lastCameraLoc!.longitude,
loc.latitude,
loc.longitude,
);
if (dist < 15) return;
}
_lastCameraLoc = loc;
try {
// 🔥 Safety double-check before animating
if (mapHomeCaptainController != null) {
print("🔥 [HomeCaptain] Safely moving camera to: ${loc.latitude}");
mapHomeCaptainController?.animateCamera(
CameraUpdate.newLatLngZoom(loc, 17.5),
);

File diff suppressed because it is too large Load Diff

View File

@@ -219,14 +219,36 @@ class OrderRequestController extends GetxController
// ----------------------------------------------------------------------
Future<void> _calculateFullJourney() async {
if (mapController == null) return; // Wait for controller to draw
// Don't block on mapController being null - we'll draw routes
// and markers first, then zoom when controller is ready
bool canZoom = mapController != null;
try {
// 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);
LatLng driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
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
// Now zoom to fit all polylines and markers (if controller available)
if (canZoom) {
zoomToFitRide();
}
update();
} catch (e) {
@@ -297,18 +326,19 @@ class OrderRequestController extends GetxController
required LatLng end,
required Color color,
required String id,
bool isDashed = false}) async {
bool getSteps = false}) async {
try {
if (start.latitude == 0 || end.latitude == 0) return null;
if (mapController == null) return null;
// Don't block on mapController — route data fetch is independent
final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: {
'fromLat': start.latitude.toString(),
'fromLng': start.longitude.toString(),
'toLat': end.latitude.toString(),
'toLng': end.longitude.toString(),
'steps': 'false',
'steps': getSteps ? 'true' : 'false',
'alternatives': 'false',
'locale': 'ar',
});
final response = await http.get(saasUrl, headers: {
@@ -347,7 +377,9 @@ class OrderRequestController extends GetxController
return {
'distance_text': distanceText,
'duration_text': durationText,
'polyline': polyline
'polyline': polyline,
'encoded_polyline': encodedPoints,
'raw_response': response.body, // 🔥 نمرر الـ JSON كاملاً
};
}
} catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ import 'splash_screen_page.dart';
import 'views/home/Captin/orderCaptin/order_request_page.dart';
import 'views/home/Captin/driver_map_page.dart';
import 'controller/profile/setting_controller.dart';
import 'controller/voice_call_controller.dart';
final box = GetStorage();
const storage = FlutterSecureStorage();
@@ -325,6 +326,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
if (!Get.isRegistered<SettingController>()) {
Get.put(SettingController());
}
if (!Get.isRegistered<VoiceCallController>()) {
Get.lazyPut(() => VoiceCallController(), fenix: true);
}
await FirebaseMessaging.instance.requestPermission();
await NotificationController().initNotifications();
@@ -394,6 +398,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
String? savedTrip = await storage.read(key: 'pending_driver_list');
if (savedTrip != null && savedTrip.isNotEmpty) {
if (Get.currentRoute == '/') {
print('⏳ App is still on Splash screen. Postponing notification trip navigation to HomeCaptainController.');
return;
}
await storage.delete(key: 'pending_driver_list');
List<dynamic> driverList = jsonDecode(savedTrip);
@@ -461,7 +469,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
}
if (!Get.isRegistered<HomeCaptainController>()) {
Get.put(HomeCaptainController());
Get.put(HomeCaptainController(), permanent: true);
} else {
Get.find<HomeCaptainController>().changeRideId();
}

View File

@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:sefer_driver/print.dart';
class SignalingService {
WebSocket? _socket;
final String _url = "wss://calls.intaleqapp.com/ws";
// Callbacks
Function(List<dynamic> iceServers)? onConnected;
Function(String reason)? onDisconnected;
Function(Map<String, dynamic> offer)? onOffer;
Function(Map<String, dynamic> answer)? onAnswer;
Function(Map<String, dynamic> candidate)? onIceCandidate;
Function(String reason)? onCallEnded;
Function()? onParticipantJoined;
bool get isConnected => _socket != null && _socket!.readyState == WebSocket.open;
Future<void> connect(String sessionId, String userId) async {
if (isConnected) return;
try {
Log.print("Signaling: Connecting to $_url");
_socket = await WebSocket.connect(_url)
.timeout(const Duration(seconds: 8));
_socket!.listen(
(data) {
_handleMessage(data);
},
onError: (err) {
Log.print("Signaling socket error: $err");
disconnect("socket_error");
},
onDone: () {
Log.print("Signaling socket closed by server");
disconnect("socket_closed");
},
cancelOnError: true,
);
// Send the authenticate message as the first message
send("authenticate", {
"session_id": sessionId,
"user_id": userId,
});
} catch (e) {
Log.print("Signaling connection failed: $e");
onDisconnected?.call("connection_failed");
}
}
void _handleMessage(dynamic data) {
try {
Log.print("Signaling received raw: $data");
final message = jsonDecode(data);
if (message is! Map<String, dynamic>) return;
final type = message['type'];
switch (type) {
case 'authenticated':
final iceServers = message['ice_servers'] as List<dynamic>? ?? [];
onConnected?.call(iceServers);
break;
case 'participant_joined':
onParticipantJoined?.call();
break;
case 'offer':
if (message['sdp'] != null) {
onOffer?.call(message['sdp']);
}
break;
case 'answer':
if (message['sdp'] != null) {
onAnswer?.call(message['sdp']);
}
break;
case 'ice_candidate':
if (message['candidate'] != null) {
onIceCandidate?.call(message['candidate']);
}
break;
case 'call_ended':
onCallEnded?.call(message['reason'] ?? 'normal');
break;
}
} catch (e) {
Log.print("Error handling signaling message: $e");
}
}
void send(String type, Map<String, dynamic> data) {
if (!isConnected) return;
final msg = jsonEncode({
'type': type,
...data,
});
Log.print("Signaling sending: $msg");
_socket!.add(msg);
}
void disconnect([String reason = "user_hangup"]) {
if (_socket != null) {
_socket!.close();
_socket = null;
onDisconnected?.call(reason);
}
}
}

View File

@@ -51,7 +51,8 @@
"The order Accepted by another Driver": "",
"Submit Rating": "",
"\\.tr\\(\\)\"), // ": "",
"I Arrive": "",
"I Arrive": "وصلت",
"I've arrived.": "لقد وصلت.",
"Distance is": "",
"What are the order details we provide to you?": "",
"Toggle Traffic": "",
@@ -307,7 +308,7 @@
"Choose Language": "",
"car_license_back": "",
"You have transfer to your wallet from": "",
"Hi ,I Arrive your site": "",
"Hi ,I Arrive your site": "مرحباً، لقد وصلت لموقعك",
"Your Journey Begins Here": "",
"Please enter your City.": "",
"Show My Trip Count": "",
@@ -542,7 +543,8 @@
"Country": "",
"),\n Text(\n ": "",
"rating_count": "",
"Cancel": "",
"Cancel": "إلغاء",
"Calling non-Syrian numbers is not supported": "الاتصال بالأرقام غير السورية غير مدعوم",
"Name (English)": "",
"Passport No": "",
"Videos Tutorials": "",
@@ -644,7 +646,7 @@
"How would you rate our app?": "",
"Capture an Image of Your ID Document Back": "",
"HSBC Bank Egypt S.A.E": "",
"I Arrive at your site": "",
"I Arrive at your site": "لقد وصلت إلى موقعك",
"for your first registration!": "",
"Feed Back": "",
"Call Page": "",

View File

@@ -51,7 +51,8 @@
"The order Accepted by another Driver": "",
"Submit Rating": "",
"\\.tr\\(\\)\"), // ": "",
"I Arrive": "",
"I Arrive": "I Have Arrived",
"I've arrived.": "I've arrived.",
"Distance is": "",
"What are the order details we provide to you?": "",
"Toggle Traffic": "",
@@ -263,7 +264,7 @@
"Choose Language": "",
"car_license_back": "",
"You have transfer to your wallet from": "",
"Hi ,I Arrive your site": "",
"Hi ,I Arrive your site": "Hi, I have arrived at your location",
"Your Journey Begins Here": "",
"Please enter your City.": "",
"Show My Trip Count": "",
@@ -498,7 +499,8 @@
"Country": "",
"),\n Text(\n ": "",
"rating_count": "",
"Cancel": "",
"Cancel": "Cancel",
"Calling non-Syrian numbers is not supported": "Calling non-Syrian numbers is not supported",
"Name (English)": "",
"Passport No": "",
"Videos Tutorials": "",
@@ -600,7 +602,7 @@
"How would you rate our app?": "",
"Capture an Image of Your ID Document Back": "",
"HSBC Bank Egypt S.A.E": "",
"I Arrive at your site": "",
"I Arrive at your site": "I have arrived at your location",
"for your first registration!": "",
"Feed Back": "",
"Call Page": "",

View File

@@ -307,7 +307,7 @@ class _PhoneNumberScreenState extends State<PhoneNumberScreen> {
Log.print('📱 _submit rawPhone: "$rawPhone" (from _completePhone: "$_completePhone")');
final success = await PhoneAuthHelper.sendOtp(rawPhone);
if (success && mounted) {
await PhoneAuthHelper.verifyOtp(rawPhone);
Get.to(() => OtpVerificationScreen(phoneNumber: rawPhone));
}
if (mounted) setState(() => _isLoading = false);
}
@@ -416,7 +416,7 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
void _submit() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
await PhoneAuthHelper.verifyOtp(widget.phoneNumber);
await PhoneAuthHelper.verifyOtp(widget.phoneNumber, _otpController.text.trim());
if (mounted) setState(() => _isLoading = false);
}
}
@@ -431,7 +431,7 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Enter the 5-digit code',
'Enter the 3-digit code',
style: TextStyle(color: Colors.black87, fontSize: 16),
textAlign: TextAlign.center,
),
@@ -442,7 +442,7 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
controller: _otpController,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 5,
maxLength: 3,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -451,7 +451,7 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
),
decoration: InputDecoration(
counterText: "",
hintText: '-----',
hintText: '---',
hintStyle: TextStyle(
color: Colors.black.withOpacity(0.2),
letterSpacing: 18,
@@ -459,7 +459,7 @@ class _OtpVerificationScreenState extends State<OtpVerificationScreen> {
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
validator: (v) => v == null || v.length < 5 ? '' : null,
validator: (v) => v == null || v.length < 3 ? '' : null,
),
),
const SizedBox(height: 30),

View File

@@ -23,9 +23,9 @@ class OtpVerificationPage extends StatefulWidget {
class _OtpVerificationPageState extends State<OtpVerificationPage> {
late final OtpVerificationController controller;
final List<FocusNode> _focusNodes = List.generate(6, (index) => FocusNode());
final List<FocusNode> _focusNodes = List.generate(3, (index) => FocusNode());
final List<TextEditingController> _textControllers =
List.generate(6, (index) => TextEditingController());
List.generate(3, (index) => TextEditingController());
@override
void initState() {
@@ -50,7 +50,7 @@ class _OtpVerificationPageState extends State<OtpVerificationPage> {
void _onOtpChanged(String value, int index) {
if (value.isNotEmpty) {
if (index < 5) {
if (index < 2) {
_focusNodes[index + 1].requestFocus();
} else {
_focusNodes[index].unfocus(); // إلغاء التركيز بعد آخر حقل
@@ -67,7 +67,7 @@ class _OtpVerificationPageState extends State<OtpVerificationPage> {
textDirection: TextDirection.ltr, // لضمان ترتيب الحقول من اليسار لليمين
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(5, (index) {
children: List.generate(3, (index) {
return SizedBox(
width: 45,
height: 55,

View File

@@ -48,10 +48,11 @@ class PassengerLocationMapPage extends StatelessWidget {
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.arguments != null && Get.arguments is Map<String, dynamic>) {
// 🔥 [Fix] argumentLoading ضرورية هنا للعودة للرحلة من صفحة الهوم
// (عند العودة لا يُستدعى onInit() لأن الكنترولر موجود مسبقاً)
// الحماية من التكرار موجودة داخل argumentLoading بواسطة _isRouteRequested flag
mapDriverController.argumentLoading();
mapDriverController.startTimerToShowPassengerInfoWindowFromDriver();
// 2. فرض التحديث لكل المعرفات (IDs) لضمان ظهورها
// لأن argumentLoading قد تستدعي update() العادية التي لا تؤثر على هؤلاء
mapDriverController
.update(['PassengerInfo', 'DriverEndBar', 'SosConnect']);
}
@@ -152,7 +153,6 @@ class InstructionsOfRoads extends StatelessWidget {
if (controller.currentInstruction.isEmpty) return const SizedBox();
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
@@ -165,7 +165,8 @@ class InstructionsOfRoads extends StatelessWidget {
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.cardColor.withOpacity(0.95), // Adaptive background
color: theme.cardColor
.withOpacity(0.95), // Adaptive background
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
@@ -173,7 +174,8 @@ class InstructionsOfRoads extends StatelessWidget {
blurRadius: 15,
offset: const Offset(0, 5)),
],
border: Border.all(color: theme.dividerColor.withOpacity(0.1)),
border: Border.all(
color: theme.dividerColor.withOpacity(0.1)),
),
child: Row(
children: [
@@ -204,8 +206,7 @@ class InstructionsOfRoads extends StatelessWidget {
Text(
controller.currentInstruction,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.2),
fontWeight: FontWeight.w600, height: 1.2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
@@ -213,7 +214,6 @@ class InstructionsOfRoads extends StatelessWidget {
),
),
// فاصل عمودي
Container(
width: 1,
@@ -279,10 +279,11 @@ class CancelWidget extends StatelessWidget {
color: Theme.of(context).cardColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(color: Theme.of(context).shadowColor.withOpacity(0.1), blurRadius: 8)
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.1),
blurRadius: 8)
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -388,7 +389,6 @@ class PricesWindow extends StatelessWidget {
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -416,7 +416,6 @@ class PricesWindow extends StatelessWidget {
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,

View File

@@ -75,7 +75,7 @@ class HomeCaptain extends StatelessWidget {
final LocationController locationController =
Get.put(LocationController(), permanent: true);
final HomeCaptainController homeCaptainController =
Get.put(HomeCaptainController());
Get.put(HomeCaptainController(), permanent: true);
@override
Widget build(BuildContext context) {

View File

@@ -23,7 +23,9 @@ class GoogleDriverMap extends StatelessWidget {
final double mapPaddingBottom = MediaQuery.of(context).size.height * 0.3;
return GetBuilder<MapDriverController>(
builder: (controller) => IntaleqMap(
builder: (controller) => Listener(
onPointerDown: (_) => controller.onUserMapInteraction(),
child: IntaleqMap(
apiKey: AK.mapAPIKEY,
onMapCreated: (mapController) {
controller.onMapCreated(mapController);
@@ -35,38 +37,46 @@ class GoogleDriverMap extends StatelessWidget {
: IntaleqMapType.light,
zoomControlsEnabled: false,
initialCameraPosition: CameraPosition(
target: locationController.myLocation,
target: controller.smoothedLocation ?? locationController.myLocation,
zoom: 17,
bearing: locationController.heading,
bearing: controller.smoothedHeading,
tilt: 60,
),
// padding: EdgeInsets.only(bottom: 50, top: Get.height * 0.7),
// minMaxZoomPreference: const MinMaxZoomPreference(8, 18),
myLocationEnabled: false,
myLocationButtonEnabled: true,
compassEnabled: true,
myLocationButtonEnabled: false,
compassEnabled: false,
polylines: controller.polyLines.toSet(),
markers: {
// 🔥 Car icon — always visible, moves with GPS location on map.
// MarkerId matches exactly with updateMarker() in controller.
Marker(
markerId: MarkerId('MyLocation'.tr),
position: controller.smoothedLocation ?? locationController.myLocation,
markerId: const MarkerId('MyLocation'),
position: controller.smoothedLocation ?? controller.myLocation,
rotation: controller.smoothedHeading,
flat: true,
anchor: const Offset(0.5, 0.5),
icon: controller.carIcon,
zIndex: 100,
),
if (!controller.isRideStarted &&
controller.latLngPassengerLocation.latitude != 0)
Marker(
markerId: MarkerId('start'.tr),
markerId: const MarkerId('start'),
position: controller.latLngPassengerLocation,
icon: controller.startIcon,
),
if (controller.latLngPassengerDestination.latitude != 0 ||
controller.latLngPassengerDestination.longitude != 0)
Marker(
markerId: MarkerId('end'.tr),
markerId: const MarkerId('end'),
position: controller.latLngPassengerDestination,
icon: controller.endIcon,
),
},
),
),
);
}
}

View File

@@ -2,11 +2,19 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sefer_driver/constant/colors.dart';
import 'package:sefer_driver/controller/home/captin/map_driver_controller.dart';
import '../../../../constant/box_name.dart';
import '../../../../constant/style.dart';
import 'package:sefer_driver/views/widgets/mydialoug.dart';
import '../../../../controller/voice_call_controller.dart';
import '../../../../controller/functions/launch.dart';
import '../../../../controller/functions/location_controller.dart';
import '../../../../main.dart';
import '../../../widgets/error_snakbar.dart';
import 'package:flutter_font_icons/flutter_font_icons.dart';
import '../../../../controller/firebase/notification_service.dart';
import '../../../../controller/functions/crud.dart';
import '../../../../constant/links.dart';
import '../../../widgets/my_textField.dart';
class PassengerInfoWindow extends StatelessWidget {
const PassengerInfoWindow({super.key});
@@ -132,12 +140,19 @@ class PassengerInfoWindow extends StatelessWidget {
Icon(Icons.location_on,
size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${controller.distance} km',
// 🔥 [Fix Overflow] Flexible لمنع الـ overflow + تحويل المسافة
// السيرفر يُرجع المسافة بالأمتار (5864.022)
Flexible(
child: Text(
_formatDistanceDisplay(
controller.distance),
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 10),
Icon(Icons.access_time_filled,
@@ -172,8 +187,8 @@ class PassengerInfoWindow extends StatelessWidget {
await controller.driverCallPassenger();
if (canCall) {
makePhoneCall(
controller.passengerPhone.toString());
_showCallSelectionDialog(
context, controller);
} else {
// هنا ممكن تظهر رسالة: تم منع الاتصال بسبب كثرة الإلغاءات
mySnackeBarError(
@@ -194,6 +209,26 @@ class PassengerInfoWindow extends StatelessWidget {
color: Colors.green, size: 22),
),
),
const SizedBox(width: 8),
InkWell(
onTap: () =>
_showMessageOptions(context, controller),
borderRadius: BorderRadius.circular(50),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey.shade100,
shape: BoxShape.circle,
border:
Border.all(color: Colors.grey.shade300),
),
child: Icon(
MaterialCommunityIcons
.message_text_outline,
color: AppColor.primaryColor,
size: 22),
),
),
],
),
],
@@ -372,13 +407,40 @@ class PassengerInfoWindow extends StatelessWidget {
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
MyDialog().getDialog(
"Start Trip?".tr,
"Ensure the passenger is in the car.".tr,
() async {
await controller.startRideFromDriver();
// 🔥 [Fix Start-Ride] استخدام Get.defaultDialog بدلاً من MyDialog
// لأن MyDialog يستخدم Navigator.of(context, rootNavigator: true).pop()
// الذي يتعارض مع Get.dialog() المستخدم في startRideFromDriver()
// وقد يُسبب Get.back() اللاحق إغلاق صفحة الماب بدلاً من الـ loading dialog
Get.defaultDialog(
title: "Start Trip?".tr,
titleStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
middleText: "Ensure the passenger is in the car.".tr,
barrierDismissible: true,
radius: 14,
confirm: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF27AE60),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
onPressed: () async {
// نُغلق الديالوج بـ Get.back() لضمان أن GetX يعرف أنه أُغلق
Get.back();
// ثم نُنفذ startRideFromDriver الذي يستخدم Get.dialog و Get.back بأمان
await controller.startRideFromDriver();
},
child: Text('Start'.tr,
style: const TextStyle(fontWeight: FontWeight.bold)),
),
cancel: TextButton(
onPressed: () => Get.back(),
child: Text('Cancel'.tr,
style: const TextStyle(color: Colors.grey)),
),
);
},
icon: const Icon(Icons.play_circle_fill_rounded),
@@ -389,4 +451,167 @@ class PassengerInfoWindow extends StatelessWidget {
);
}
}
void _showCallSelectionDialog(
BuildContext context, MapDriverController controller) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Call Options'.tr,
style: AppStyle.title
.copyWith(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text(
'Choose how you want to call the passenger'.tr,
style: const TextStyle(color: Colors.grey, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
ListTile(
leading: CircleAvatar(
backgroundColor: Colors.green.withOpacity(0.1),
child: const Icon(Icons.phone_android_rounded,
color: Colors.green),
),
title: Text('Standard Call'.tr,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('Uses cellular network'.tr,
style: const TextStyle(fontSize: 12)),
onTap: () {
Get.back();
makePhoneCall(controller.passengerPhone.toString());
},
),
const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: AppColor.primaryColor.withOpacity(0.1),
child: Icon(Icons.wifi_calling_3_rounded,
color: AppColor.primaryColor),
),
title: Text('Free Call'.tr,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('Voice call over internet'.tr,
style: const TextStyle(fontSize: 12)),
onTap: () {
Get.back();
final voiceCtrl = Get.find<VoiceCallController>();
final driverId = box.read(BoxName.driverID).toString();
voiceCtrl.startCall(
rideIdVal: controller.rideId,
driverId: driverId,
passengerId: controller.passengerId,
remoteNameVal: controller.passengerName ?? "Passenger",
);
},
),
],
),
),
),
);
}
void _showMessageOptions(
BuildContext context, MapDriverController controller) {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(25)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Quick Messages'.tr,
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 15),
_buildQuickMessageItem("Where are you, sir?".tr, controller),
_buildQuickMessageItem("I've arrived.".tr, controller),
const Divider(),
Row(
children: [
Expanded(
child: TextField(
controller: controller.messageToPassenger,
decoration:
InputDecoration(hintText: 'Type a message...'.tr),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage(controller, controller.messageToPassenger.text,
'cancel');
controller.messageToPassenger.clear();
Get.back();
},
),
],
),
],
),
),
);
}
Widget _buildQuickMessageItem(String text, MapDriverController controller) {
return ListTile(
title: Text(text),
onTap: () {
_sendMessage(controller, text, 'ding');
Get.back();
},
);
}
void _sendMessage(
MapDriverController controller, String body, String tone) async {
try {
await CRUD().post(
link: AppLink.sendChatMessage,
payload: {
'ride_id': controller.rideId.toString(),
'sender_id': box.read(BoxName.driverID).toString(),
'receiver_id': controller.passengerId.toString(),
'sender_type': 'driver',
'message_content': body,
},
);
} catch (e) {
// Ignore or log error
}
NotificationService.sendNotification(
target: controller.tokenPassenger.toString(),
title: 'Driver Message'.tr,
body: body,
isTopic: false,
tone: tone,
driverList: [],
category: 'message From Driver',
);
}
}
/// تحويل المسافة من الأمتار إلى عرض مقروء
/// السيرفر يُرجع المسافة بالأمتار (مثال: 5864.022)
/// النتيجة: "5.9 km" أو "250 م"
String _formatDistanceDisplay(String rawDistance) {
final meters = double.tryParse(rawDistance) ?? 0.0;
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(1)} km';
} else if (meters > 0) {
return '${meters.toStringAsFixed(0)} م';
}
return rawDistance; // fallback للقيمة الأصلية
}

View File

@@ -21,14 +21,10 @@ class SosConnect extends StatelessWidget {
return GetBuilder<MapDriverController>(
id: 'SosConnect', // Keep ID for updates
builder: (controller) {
// Check visibility logic
bool showPassengerContact =
!controller.isRideBegin && controller.isPassengerInfoWindow;
bool showSos = controller.isRideStarted;
if (!showPassengerContact && !showSos) return const SizedBox();
if (!showSos) return const SizedBox();
// REMOVED: Positioned widget
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -45,38 +41,7 @@ class SosConnect extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// === Call Button ===
if (showPassengerContact)
_buildModernActionButton(
icon: Icons.phone_in_talk,
color: Colors.white,
bgColor: AppColor.blueColor,
tooltip: 'Call Passenger',
onTap: () async {
controller.isSocialPressed = true;
bool canCall = await controller.driverCallPassenger();
if (canCall) {
makePhoneCall(controller.passengerPhone.toString());
} else {
mySnackeBarError("Policy restriction on calls".tr);
}
},
),
if (showPassengerContact) const SizedBox(height: 12),
// === Message Button ===
if (showPassengerContact)
_buildModernActionButton(
icon: MaterialCommunityIcons.message_text_outline,
color: AppColor.primaryColor,
bgColor: Colors.grey.shade100,
tooltip: 'Message Passenger',
onTap: () => _showMessageOptions(context, controller),
),
// === SOS Button ===
if (showSos)
_buildModernActionButton(
icon: MaterialIcons.warning,
color: Colors.white,
@@ -140,7 +105,7 @@ class SosConnect extends StatelessWidget {
child: MyTextForm(
controller: mapDriverController.sosEmergincyNumberCotroller,
label: 'Phone Number'.tr,
hint: '01xxxxxxxxx',
hint: '0923456789',
type: TextInputType.phone,
),
),
@@ -163,71 +128,4 @@ class SosConnect extends StatelessWidget {
launchCommunication('phone', box.read(BoxName.sosPhoneDriver), '');
}
}
void _showMessageOptions(
BuildContext context, MapDriverController controller) {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(25)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Quick Messages'.tr,
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 15),
_buildQuickMessageItem("Where are you, sir?".tr, controller),
_buildQuickMessageItem("I've arrived.".tr, controller),
const Divider(),
Row(
children: [
Expanded(
child: TextField(
controller: controller.messageToPassenger,
decoration:
InputDecoration(hintText: 'Type a message...'.tr),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage(controller, controller.messageToPassenger.text,
'cancel');
controller.messageToPassenger.clear();
Get.back();
},
),
],
),
],
),
),
);
}
Widget _buildQuickMessageItem(String text, MapDriverController controller) {
return ListTile(
title: Text(text),
onTap: () {
_sendMessage(controller, text, 'ding');
Get.back();
},
);
}
void _sendMessage(MapDriverController controller, String body, String tone) {
NotificationService.sendNotification(
target: controller.tokenPassenger.toString(),
title: 'Driver Message'.tr,
body: body,
isTopic: false,
tone: tone,
driverList: [],
category: 'message From Driver',
);
}
}

View File

@@ -229,8 +229,8 @@ class _OrderOverlayState extends State<OrderOverlay>
// بيانات أساسية
'driver_id': driverId,
'status': 'Apply',
'passengerLocation': _getData(0),
'passengerDestination': _getData(1),
'passengerLocation': '${_getData(0)},${_getData(1)}',
'passengerDestination': '${_getData(3)},${_getData(4)}',
'Duration': _getData(4),
'totalCost': _getData(26),
'Distance': _getData(5),

View File

@@ -12,32 +12,58 @@ class SchedulePage extends StatelessWidget {
return Scaffold(
backgroundColor: FinanceDesignSystem.backgroundColor,
appBar: AppBar(
title: Text('My Schedule'.tr, style: TextStyle(fontWeight: FontWeight.bold, color: FinanceDesignSystem.primaryDark)),
backgroundColor: Colors.transparent, elevation: 0, centerTitle: true,
leading: IconButton(icon: Icon(Icons.arrow_back_ios_new_rounded, color: FinanceDesignSystem.primaryDark, size: 20), onPressed: () => Get.back()),
title: Text('My Schedule'.tr,
style: TextStyle(
fontWeight: FontWeight.bold,
color: FinanceDesignSystem.primaryDark)),
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new_rounded,
color: FinanceDesignSystem.primaryDark, size: 20),
onPressed: () => Get.back()),
),
body: GetBuilder<ScheduleController>(builder: (sc) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Summary Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: FinanceDesignSystem.balanceGradient,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
borderRadius:
BorderRadius.circular(FinanceDesignSystem.cardRadius),
),
child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Weekly Plan'.tr, style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 14)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Weekly Plan'.tr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14)),
const SizedBox(height: 8),
Text('${sc.totalWeeklyHours.toStringAsFixed(1)}h', style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w900, color: Colors.white)),
Text('${sc.activeDays} ${'Days'.tr}', style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 13)),
Text('${sc.totalWeeklyHours.toStringAsFixed(1)}h',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w900,
color: Colors.white)),
Text('${sc.activeDays} ${'Days'.tr}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 13)),
])),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(14)),
child: const Icon(Icons.calendar_today_rounded, color: Colors.white, size: 28),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14)),
child: const Icon(Icons.calendar_today_rounded,
color: Colors.white, size: 28),
),
]),
),
@@ -53,7 +79,8 @@ class SchedulePage extends StatelessWidget {
);
}
Widget _buildDayCard(BuildContext context, WorkSlot slot, ScheduleController sc) {
Widget _buildDayCard(
BuildContext context, WorkSlot slot, ScheduleController sc) {
final isAr = Get.locale?.languageCode == 'ar';
return Container(
margin: const EdgeInsets.only(bottom: 10),
@@ -61,7 +88,14 @@ class SchedulePage extends StatelessWidget {
decoration: BoxDecoration(
color: slot.isActive ? Colors.white : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
boxShadow: slot.isActive ? [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 3))] : null,
boxShadow: slot.isActive
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: const Offset(0, 3))
]
: null,
),
child: Row(children: [
// Toggle
@@ -69,34 +103,57 @@ class SchedulePage extends StatelessWidget {
onTap: () => sc.toggleDay(slot.dayOfWeek),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
width: 44,
height: 44,
decoration: BoxDecoration(
color: slot.isActive ? FinanceDesignSystem.accentBlue.withValues(alpha: 0.1) : Colors.grey.shade200,
color: slot.isActive
? FinanceDesignSystem.accentBlue.withValues(alpha: 0.1)
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: Text(
child: Center(
child: Text(
isAr ? slot.dayNameAr.substring(0, 2) : slot.dayName,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: slot.isActive ? FinanceDesignSystem.accentBlue : Colors.grey.shade400),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: slot.isActive
? FinanceDesignSystem.accentBlue
: Colors.grey.shade400),
)),
),
),
const SizedBox(width: 14),
// Day name
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(isAr ? slot.dayNameAr : slot.dayName.tr, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
color: slot.isActive ? FinanceDesignSystem.primaryDark : Colors.grey.shade400)),
Expanded(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(isAr ? slot.dayNameAr : slot.dayName.tr,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: slot.isActive
? FinanceDesignSystem.primaryDark
: Colors.grey.shade400)),
if (slot.isActive)
Text(slot.timeRange, style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
Text(slot.timeRange,
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
if (!slot.isActive)
Text('Day Off'.tr, style: TextStyle(fontSize: 12, color: Colors.grey.shade400, fontStyle: FontStyle.italic)),
Text('Day Off'.tr,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
fontStyle: FontStyle.italic)),
])),
// Time pickers
if (slot.isActive) ...[
_timePicker(context, slot.startTime, (t) => sc.updateStartTime(slot.dayOfWeek, t)),
Padding(padding: const EdgeInsets.symmetric(horizontal: 4), child: Text('-', style: TextStyle(color: Colors.grey.shade400))),
_timePicker(context, slot.endTime, (t) => sc.updateEndTime(slot.dayOfWeek, t)),
_timePicker(context, slot.startTime,
(t) => sc.updateStartTime(slot.dayOfWeek, t)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('-', style: TextStyle(color: Colors.grey.shade400))),
_timePicker(context, slot.endTime,
(t) => sc.updateEndTime(slot.dayOfWeek, t)),
],
// Toggle switch
Switch(
@@ -108,17 +165,25 @@ class SchedulePage extends StatelessWidget {
);
}
Widget _timePicker(BuildContext context, TimeOfDay time, Function(TimeOfDay) onChanged) {
Widget _timePicker(
BuildContext context, TimeOfDay time, Function(TimeOfDay) onChanged) {
return GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: time);
final picked =
await showTimePicker(context: context, initialTime: time);
if (picked != null) onChanged(picked);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Text('${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: FinanceDesignSystem.primaryDark)),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8)),
child: Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: FinanceDesignSystem.primaryDark)),
),
);
}

View File

@@ -0,0 +1,278 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sefer_driver/constant/style.dart';
import 'package:sefer_driver/controller/home/profile/complaint_controller.dart';
import 'package:sefer_driver/views/widgets/my_scafold.dart';
import 'package:sefer_driver/views/widgets/mycircular.dart';
import 'package:sefer_driver/views/widgets/mydialoug.dart';
import 'package:sefer_driver/views/widgets/elevated_btn.dart';
import '../../../constant/colors.dart';
import '../../../controller/functions/audio_recorder_controller.dart';
class ComplaintPage extends StatelessWidget {
ComplaintPage({super.key});
final ComplaintController complaintController =
Get.put(ComplaintController());
final AudioRecorderController audioRecorderController =
Get.put(AudioRecorderController());
@override
Widget build(BuildContext context) {
return MyScafolld(
title: 'Submit a Complaint'.tr,
isleading: true,
body: [
GetBuilder<ComplaintController>(
builder: (controller) {
if (controller.isLoading && controller.ridesList.isEmpty) {
return const MyCircularProgressIndicator();
}
return Stack(
children: [
Form(
key: controller.formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// --- 1. Select Ride Section ---
_buildSectionCard(
title: '1. Select Ride'.tr,
child: controller.ridesList.isEmpty
? Text('No rides found to complain about.'.tr,
style: AppStyle.subtitle)
: DropdownButtonFormField<Map<String, dynamic>>(
value: controller.selectedRide,
dropdownColor: AppColor.surfaceColor,
items: controller.ridesList.map((ride) {
return DropdownMenuItem<Map<String, dynamic>>(
value: ride,
child: Text(
'${'Ride'.tr} #${ride['id']} (${ride['date']})',
style: AppStyle.subtitle,
),
);
}).toList(),
onChanged: (ride) {
if (ride != null) {
controller.selectRide(ride);
}
},
decoration: InputDecoration(
filled: true,
fillColor:
AppColor.secondaryColor.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
),
),
),
// --- 2. Describe Your Issue Section ---
_buildSectionCard(
title: '2. Describe Your Issue'.tr,
child: TextFormField(
controller: controller.complaintController,
decoration: InputDecoration(
hintText: 'Enter your complaint here...'.tr,
filled: true,
fillColor:
AppColor.secondaryColor.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(16),
),
maxLines: 6,
style: AppStyle.subtitle,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a description of the issue.'
.tr;
}
return null;
},
),
),
// --- 3. Attach Recorded Audio Section ---
if (controller.selectedRide != null)
_buildSectionCard(
title: '3. Attach Recorded Audio (Optional)'.tr,
child: FutureBuilder<List<String>>(
future: audioRecorderController.getRecordedFiles(),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator());
}
final rideId =
controller.selectedRide!['id'].toString();
// Filter files to only show the audio file associated with the selected Ride ID
final matchingFiles = snapshot.data
?.where((path) =>
path.endsWith('_${rideId}.m4a'))
.toList() ??
[];
if (snapshot.hasError || matchingFiles.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0),
child: Text(
'No audio files found for this ride.'.tr,
style: AppStyle.subtitle),
),
);
}
return Column(
children: matchingFiles.map((audioFilePath) {
final audioFile = File(audioFilePath);
final isUploaded =
controller.audioLink.isNotEmpty &&
controller.attachedFileName ==
audioFilePath.split('/').last;
return ListTile(
leading: Icon(
isUploaded
? Icons.check_circle
: Icons.mic,
color: isUploaded
? AppColor.greenColor
: AppColor.redColor),
title: Text(audioFilePath.split('/').last,
style: AppStyle.subtitle,
overflow: TextOverflow.ellipsis),
subtitle: isUploaded
? Text('Uploaded'.tr,
style: const TextStyle(
color: AppColor.greenColor))
: null,
onTap: isUploaded
? null
: () {
MyDialogContent().getDialog(
'Confirm Attachment'.tr,
Text(
'Attach this audio file?'
.tr), () async {
await controller
.uploadAudioFile(audioFile);
});
},
);
}).toList(),
);
},
),
),
// --- 4. Review Details & Response Section ---
if (controller.selectedRide != null)
_buildSectionCard(
title: '4. Review Details & Response'.tr,
child: Column(
children: [
_buildDetailRow(
Icons.calendar_today_outlined,
'Date'.tr,
controller.selectedRide!['date'] ?? ''),
_buildDetailRow(
Icons.monetization_on_outlined,
'Price'.tr,
'${controller.selectedRide!['price'] ?? ''}'),
const Divider(height: 24),
ListTile(
leading: const Icon(
Icons.support_agent_outlined,
color: AppColor.primaryColor),
title: Text("Intaleq's Response".tr,
style: AppStyle.title),
subtitle: Text(
controller.driverReport?['body']
?.toString() ??
'Awaiting response...'.tr,
style:
AppStyle.subtitle.copyWith(height: 1.5),
),
),
],
),
),
// --- 5. Submit Button ---
const SizedBox(height: 24),
MyElevatedButton(
onPressed: () async {
await controller.submitComplaintToServer();
},
title: 'Submit Complaint'.tr,
),
const SizedBox(height: 24),
],
),
),
if (controller.isLoading)
Container(
color: Colors.black.withOpacity(0.5),
child: const MyCircularProgressIndicator(),
),
],
);
},
),
],
);
}
Widget _buildSectionCard({required String title, required Widget child}) {
return Card(
margin: const EdgeInsets.only(bottom: 20),
elevation: 4,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppStyle.headTitle.copyWith(fontSize: 18)),
const SizedBox(height: 12),
child,
],
),
),
);
}
Widget _buildDetailRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Icon(icon, color: AppColor.writeColor.withOpacity(0.6), size: 20),
const SizedBox(width: 12),
Text('${label.tr}:',
style: AppStyle.subtitle
.copyWith(color: AppColor.writeColor.withOpacity(0.7))),
const Spacer(),
Text(value,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
),
);
}
}

View File

@@ -4,14 +4,12 @@ import 'package:get/get.dart';
import 'package:sefer_driver/constant/box_name.dart';
import 'package:sefer_driver/controller/profile/captain_profile_controller.dart';
import 'package:sefer_driver/main.dart';
import 'package:sefer_driver/views/auth/captin/criminal_documents_page.dart';
import 'package:sefer_driver/views/widgets/my_scafold.dart';
import 'package:sefer_driver/views/widgets/mycircular.dart';
import 'package:sefer_driver/views/widgets/mydialoug.dart';
import '../../../constant/links.dart';
import '../../../controller/functions/crud.dart';
import 'behavior_page.dart';
import 'captains_cars.dart';
import 'complaint_page.dart';
// الصفحة الرئيسية الجديدة
class ProfileCaptain extends StatelessWidget {
@@ -121,8 +119,8 @@ class ProfileHeader extends StatelessWidget {
const SizedBox(width: 4),
Text(
'${rating.toStringAsFixed(1)} (${'reviews'.tr} $ratingCount)',
style: theme.textTheme.titleMedium
?.copyWith(color: theme.hintColor),
style:
theme.textTheme.titleMedium?.copyWith(color: theme.hintColor),
),
],
),
@@ -131,7 +129,6 @@ class ProfileHeader extends StatelessWidget {
}
}
/// 2. ويدجت شبكة الأزرار
class ActionsGrid extends StatelessWidget {
const ActionsGrid({super.key});
@@ -171,6 +168,11 @@ class ActionsGrid extends StatelessWidget {
icon: Icons.checklist_rtl,
onTap: () => Get.to(() => BehaviorPage()),
),
_ActionTile(
title: 'Submit a Complaint'.tr,
icon: Icons.note_add_rounded,
onTap: () => Get.to(() => ComplaintPage()),
),
],
);
}
@@ -198,14 +200,15 @@ void showShamCashInput() {
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.2), blurRadius: 10, offset: const Offset(0, -2))
color: theme.shadowColor.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, -2))
],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// --- 1. المقبض العلوي ---
Center(
@@ -219,7 +222,6 @@ void showShamCashInput() {
),
),
// --- 2. العنوان والأيقونة ---
Image.asset(
'assets/images/shamCash.png',
@@ -262,8 +264,8 @@ void showShamCashInput() {
border: InputBorder.none,
prefixIcon: const Icon(Icons.person_outline_rounded,
color: Colors.blueGrey),
contentPadding:
const EdgeInsets.symmetric(vertical: 15, horizontal: 10),
contentPadding: const EdgeInsets.symmetric(
vertical: 15, horizontal: 10),
),
),
),
@@ -291,8 +293,8 @@ void showShamCashInput() {
border: InputBorder.none,
prefixIcon: const Icon(Icons.qr_code_2_rounded,
color: Colors.blueGrey),
contentPadding:
const EdgeInsets.symmetric(vertical: 15, horizontal: 10),
contentPadding: const EdgeInsets.symmetric(
vertical: 15, horizontal: 10),
// زر لصق الكود
suffixIcon: IconButton(
@@ -398,7 +400,6 @@ void showShamCashInput() {
);
}
/// ويدجت داخلية لزر في الشبكة
class _ActionTile extends StatelessWidget {
final String title;
@@ -438,7 +439,6 @@ class _ActionTile extends StatelessWidget {
}
}
/// 3. بطاقة المعلومات الشخصية
class PersonalInfoCard extends StatelessWidget {
final Map<String, dynamic> data;
@@ -584,4 +584,3 @@ class _InfoRow extends StatelessWidget {
);
}
}

View File

@@ -151,6 +151,9 @@ class RideAvailableCard extends StatelessWidget {
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -164,6 +167,32 @@ class RideAvailableCard extends StatelessWidget {
.copyWith(color: AppColor.greenColor, fontSize: 13),
),
),
if (rideInfo['has_steps']?.toString() == 'true') ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange),
),
child: Row(
children: [
Icon(Icons.alt_route, color: Colors.orange.shade800, size: 14),
const SizedBox(width: 4),
Text(
'متعددة التوقفات',
style: AppStyle.subtitle.copyWith(
color: Colors.orange.shade800,
fontSize: 11,
fontWeight: FontWeight.bold),
),
],
),
),
]
],
),
],
);
}
@@ -337,8 +366,12 @@ class RideAvailableCard extends StatelessWidget {
'direction':
'http://googleusercontent.com/maps.google.com/maps?saddr=${rideInfo['start_location']}&daddr=${rideInfo['end_location']}',
'timeOfOrder': DateTime.now().toString(),
'isHaveSteps': 'false', // لو كان عندك خطوات في الـ waitingRides ضيفها
'step0': '', 'step1': '', 'step2': '', 'step3': '', 'step4': '',
'isHaveSteps': rideInfo['has_steps']?.toString() ?? 'false',
'step0': rideInfo['step0'] ?? '',
'step1': rideInfo['step1'] ?? '',
'step2': rideInfo['step2'] ?? '',
'step3': rideInfo['step3'] ?? '',
'step4': rideInfo['step4'] ?? '',
};
// حفظ البيانات في الصندوق احتياطياً (Crash Recovery)

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../controller/voice_call_controller.dart';
class VoiceCallBottomSheet extends StatelessWidget {
const VoiceCallBottomSheet({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Get.find<VoiceCallController>();
final double screenHeight = MediaQuery.of(context).size.height;
final bool isDark = Theme.of(context).brightness == Brightness.dark;
// Harmonious curated colors
final Color bgColor = isDark ? const Color(0xFF121212) : Colors.white;
final Color cardColor = isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F7);
final Color textColor = isDark ? Colors.white : const Color(0xFF1C1C1E);
final Color subTextColor = isDark ? Colors.white70 : Colors.black54;
return WillPopScope(
onWillPop: () async => false,
child: Container(
height: screenHeight * 0.9,
decoration: BoxDecoration(
color: bgColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, -5),
)
],
),
child: Obx(() {
final state = controller.state.value;
final seconds = controller.elapsedSeconds.value;
final remoteName = controller.remoteName.value;
final isMuted = controller.isMuted.value;
final isSpeakerOn = controller.isSpeakerOn.value;
final errorMsg = controller.errorMessage.value;
// Progress ring logic
final double progress = seconds / 60.0;
final Color ringColor = (errorMsg.isNotEmpty || seconds <= 10)
? const Color(0xFFE74C3C)
: const Color(0xFF2ECC71);
// Status text translations
String statusText = "";
if (errorMsg.isNotEmpty) {
statusText = errorMsg;
} else {
switch (state) {
case VoiceCallState.dialing:
statusText = "${'Calling'.tr} $remoteName...";
break;
case VoiceCallState.ringing:
statusText = "${'Incoming Call...'.tr}";
break;
case VoiceCallState.connecting:
statusText = "Connecting...".tr;
break;
case VoiceCallState.active:
statusText = "Call Connected".tr;
break;
case VoiceCallState.ended:
statusText = "Call Ended".tr;
break;
case VoiceCallState.idle:
statusText = "";
break;
}
}
return Column(
children: [
// Top Drag Handle Indicator
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 24),
width: 44,
height: 5,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.black12,
borderRadius: BorderRadius.circular(10),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Header Info
Column(
children: [
Text(
"Free Call".tr,
style: TextStyle(
color: ringColor,
fontWeight: FontWeight.w800,
fontSize: 14,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
remoteName,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w900,
fontSize: 26,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
statusText,
style: TextStyle(
color: errorMsg.isNotEmpty
? const Color(0xFFE74C3C)
: subTextColor,
fontWeight: FontWeight.w600,
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
),
// Avatar & Animated Progress Ring
Stack(
alignment: Alignment.center,
children: [
// Progress ring around avatar (Active state only)
if (state == VoiceCallState.active)
SizedBox(
width: 172,
height: 172,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 5,
backgroundColor: isDark ? Colors.white10 : Colors.black12,
valueColor: AlwaysStoppedAnimation<Color>(ringColor),
),
),
// Main Avatar Card
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cardColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 8),
)
],
),
child: Center(
child: remoteName.isNotEmpty
? Text(
remoteName[0].toUpperCase(),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
fontSize: 54,
),
)
: Icon(
Icons.person,
color: textColor.withOpacity(0.6),
size: 64,
),
),
),
],
),
// Timer Counter Display
if (state == VoiceCallState.active)
Text(
"0:${seconds.toString().padLeft(2, '0')}",
style: TextStyle(
color: seconds > 10 ? textColor : const Color(0xFFE74C3C),
fontWeight: FontWeight.bold,
fontSize: 22,
fontFamily: 'monospace',
),
)
else
const SizedBox(height: 24),
// Action Controls Block
if (state == VoiceCallState.ringing)
// Incoming Ringing Controls: Accept / Decline
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildCircleActionButton(
icon: Icons.call_end_rounded,
color: Colors.white,
bgColor: const Color(0xFFE74C3C),
onTap: () => controller.declineCall(),
label: "Decline".tr,
),
_buildCircleActionButton(
icon: Icons.call_rounded,
color: Colors.white,
bgColor: const Color(0xFF2ECC71),
onTap: () => controller.acceptCall(),
label: "Accept".tr,
),
],
)
else
// Dialing or Connected Controls: Speaker / Mute / Hangup
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Speakerphone toggle
_buildCircleActionButton(
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.volume_down_rounded,
color: isSpeakerOn ? Colors.white : textColor,
bgColor: isSpeakerOn ? const Color(0xFF2ECC71) : cardColor,
onTap: () => controller.toggleSpeaker(),
label: "Speaker".tr,
),
// Hangup Call
_buildCircleActionButton(
icon: Icons.call_end_rounded,
color: Colors.white,
bgColor: const Color(0xFFE74C3C),
onTap: () => controller.hangup(),
label: "End".tr,
),
// Mute Microphone
_buildCircleActionButton(
icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded,
color: isMuted ? Colors.white : textColor,
bgColor: isMuted ? const Color(0xFFE74C3C) : cardColor,
onTap: () => controller.toggleMute(),
label: "Mute".tr,
),
],
),
],
),
),
),
],
);
}),
),
);
}
Widget _buildCircleActionButton({
required IconData icon,
required Color color,
required Color bgColor,
required VoidCallback onTap,
required String label,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(18),
backgroundColor: bgColor,
foregroundColor: color,
elevation: 2,
),
child: Icon(icon, size: 28),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
],
);
}
}

View File

@@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -18,6 +19,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
flutter_webrtc
record_linux
url_launcher_linux
)

View File

@@ -19,6 +19,7 @@ import flutter_inappwebview_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import flutter_tts
import flutter_webrtc
import geolocator_apple
import google_sign_in_ios
import just_audio
@@ -49,6 +50,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))

View File

@@ -0,0 +1,918 @@
# Intaleq Driver App — Complete Ride Lifecycle Analysis Report
## Table of Contents
1. [System Architecture Overview](#1-system-architecture-overview)
2. [Ride State Machine](#2-ride-state-machine)
3. [Phase 1: Ride Request Ingress (Socket → Order Request)](#3-phase-1-ride-request-ingress)
4. [Phase 2: Accept Order & Navigation to Pickup](#4-phase-2-accept-order--navigation-to-pickup)
5. [Phase 3: Arrival & Begin Ride](#5-phase-3-arrival--begin-ride)
6. [Phase 4: In-Ride Navigation & Polyline System](#6-phase-4-in-ride-navigation--polyline-system)
7. [Phase 5: Finish Ride & Payment](#7-phase-5-finish-ride--payment)
8. [Phase 6: Post-Ride (Rating, Review)](#8-phase-6-post-ride-rating-review)
9. [Pricing Engine](#9-pricing-engine)
10. [Socket.IO Communication](#10-socketio-communication)
11. [HTTP Backend API Endpoints](#11-http-backend-api-endpoints)
12. [Polyline Engine Deep Dive](#12-polyline-engine-deep-dive)
13. [Location Tracking System](#13-location-tracking-system)
14. [Voice Call Signaling](#14-voice-call-signaling)
15. [Key Architectural Patterns & Fixes](#15-key-architectural-patterns--fixes)
16. [Data Flow Diagrams](#16-data-flow-diagrams)
---
## 1. System Architecture Overview
### High-Level Component Map
```mermaid
graph TB
subgraph "Driver App sefer_driver"
A[LocationController] -->|Socket.IO| B[Location Server]
C[OrderRequestController] -->|HTTP| D[Ride Server]
E[MapDriverController] -->|HTTP| D
F[NavigationController] -->|HTTP| G[Map SaaS Server]
H[SignalingService] -->|WebSocket| I[Call Server]
J[WalletController] -->|HTTP| K[Payment Server]
end
subgraph "Passenger App Intaleq"
L[RideLifecycleController] -->|Polling/HTTP| D
M[MapEngineController] -->|HTTP| G
end
B -->|Socket.IO Events| A
B <-->|update_location| A
D <-->|Ride CRUD| E
D <-->|Ride CRUD| L
subgraph "Backend Servers"
B[Location Server: location.intaleq.xyz]
D[Ride Server: rides.intaleq.xyz]
G[Map SaaS: map-saas.intaleqapp.com]
I[Call Server: calls.intaleqapp.com]
K[Payment Server: walletintaleq.intaleq.xyz]
end
style A fill:#4a90d9,color:#fff
style C fill:#4a90d9,color:#fff
style E fill:#4a90d9,color:#fff
style F fill:#4a90d9,color:#fff
style L fill:#e67e22,color:#fff
style M fill:#e67e22,color:#fff
```
### Server Infrastructure
| Server | Base URL | Purpose |
|--------|----------|---------|
| API Server | `https://api.intaleq.xyz/intaleq_v3` | Auth, CRUD operations, ride management |
| Ride Server | `https://rides.intaleq.xyz/intaleq/ride` | Ride-specific CRUD |
| Location Server | `https://location.intaleq.xyz` | Socket.IO real-time location, batch uploads, behavior recording |
| Map SaaS | `https://map-saas.intaleqapp.com/api/maps/route` | Route/polyline generation |
| Payment Server | `https://walletintaleq.intaleq.xyz/v1/main` | Wallet management, payment processing |
| Call Server | `wss://calls.intaleqapp.com/ws` | WebRTC signaling for voice/video calls |
### Key Technologies
- **State Management**: GetX (`GetxController`)
- **Real-time**: Socket.IO (`socket_io_client: 1.0.2`) at `https://location.intaleq.xyz`
- **Map**: Custom `intaleq_maps` package (local path: `../map-saas/packages/flutter-sdk/`)
- **Routing API**: OSRM-compatible response format via SaaS
- **Navigation**: Step-by-step with TTS (`flutter_tts: ^4.0.2`)
- **Background**: `flutter_background_service: ^5.1.0`, `flutter_overlay_window: ^0.5.0`
---
## 2. Ride State Machine
The driver app uses an implicit state machine managed via a master `status` variable in [`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart).
```mermaid
stateDiagram-v2
[*] --> NoRide: App start / Return to home
NoRide --> Searching: Socket new_ride_request received
Searching --> DriverApplied: Driver taps Accept Order
DriverApplied --> Searching: Ride taken by another driver
DriverApplied --> DriverArrived: Driver arrives at pickup point
DriverArrived --> InProgress: Driver taps Start Ride / Begin
InProgress --> Finished: Driver taps Finish Ride
Finished --> PreCheckReview: Driver begins review process
PreCheckReview --> NoRide: Review complete / Rate passenger
Finished --> NoRide: Skip review
note right of NoRide: Location streaming active, listening for orders
note right of Searching: OrderRequestPage shown, route calculation
note right of DriverApplied: Navigation to passenger pickup point
note right of DriverArrived: Waiting timer counting, passenger notified
note right of InProgress: Navigation to destination, pricing timer active
```
**State Management Pattern**: The status is stored as a string variable (`status`) and checked throughout with switch/if blocks. Additionally, a `Box` (GetStorage) is used for persistence across app restarts. Key fields in Box include `box.read('status')`, `box.read('ride_id')`, `box.read('tokenPassenger')`.
---
## 3. Phase 1: Ride Request Ingress
### Flow Diagram
```mermaid
sequenceDiagram
participant S as Location Server Socket.IO
participant LC as LocationController
participant ORC as OrderRequestController
participant UI as OrderRequestPage
S->>LC: new_ride_request event
Note over LC: Data format: List or Map
LC->>LC: handleIncomingOrder()
Note over LC: Validate key '16' exists
LC->>LC: Extract DriverList structure
LC->>LC: Sort by distance
LC->>UI: Get.to OrderRequestPage
UI->>ORC: initState -> _initializeData()
ORC->>ORC: Parse List or Map format
ORC->>ORC: _calculateFullJourney()
ORC->>MapSaaS: getRoute for pickup + trip
MapSaaS-->>ORC: Return polylines + distance + duration
ORC->>UI: Update UI with routes
```
### Socket Event: `new_ride_request`
Received in [`location_controller.dart`](lib/controller/functions/location_controller.dart) (lines ~230-323).
**Data Payload** (supports **two formats**):
**Format A — List** (original):
```dart
[
"lat,lng", // [0] start coordinates
"lat,lng", // [1] end coordinates
"price", // [2]
"duration_sec", // [3] trip duration
"total_sec", // [4] total duration
"distance_m", // [5] trip distance
"unknown", // [6]
"passenger_id", // [7]
"customer_name", // [8]
"customer_token", // [9]
"phone", // [10]
"unknown", // [11]
"dist_to_driver_m", // [12] distance to driver
"unknown", // [13]
"unknown", // [14]
"duration_to_driver_sec", // [15]
"ride_id", // [16] - Validation key
...
"start_address", // [29]
"end_address", // [30]
"ride_type", // [31] Speed/Comfort/Lady/etc
"passenger_rate", // [33]
]
```
**Format B — Map** (newer):
```dart
{
'myListString': { ... },
'DriverList': [
{ lat, lng, price, duration, ... },
...
]
}
```
### Validation Gate ([`location_controller.dart`](lib/controller/functions/location_controller.dart), lines 327-399)
```dart
void handleIncomingOrder(dynamic data) {
// Check if data has key '16' (ride_id) — if so, treat as List format
// Otherwise, extract from myListString/DriverList
// Convert all to sorted DriverList format
// Navigate to OrderRequestPage
}
```
### Ride Taken Prevention ([`order_request_controller.dart`](lib/controller/home/captin/order_request_controller.dart), lines 624-649)
A dedicated socket listener prevents double-accept:
```dart
socket.on('ride_taken', (data) {
// Check if ride_id matches current displayed order
// If so, show "ride taken" dialogue and return to home
});
```
---
## 4. Phase 2: Accept Order & Navigation to Pickup
### Accept Flow ([`order_request_controller.dart`](lib/controller/home/captin/order_request_controller.dart), lines 672-783)
```mermaid
sequenceDiagram
participant D as Driver
participant ORC as OrderRequestController
participant API as Ride Server
participant Box as GetStorage
participant PDP as PassengerLocationMapPage
participant MDC as MapDriverController
D->>ORC: Tap Accept
ORC->>API: GET acceptRide.php?ride_id=X&driver_id=Y
API-->>ORC: Success response
ORC->>Box: Write rideArgs: ride_id, status=applied, tokenPassenger, carType, kazan, etc.
ORC->>PDP: Get.to(PassengerLocationMapPage, arguments: rideArgs)
PDP->>MDC: argumentLoading() parses rideArgs
MDC->>Box: Persist all ride data
MDC->>MapSaaS: getRoute(for pickup location)
MapSaaS-->>MDC: Polyline + steps for driver->passenger route
MDC->>MDC: Draw polyline on map
MDC->>MDC: Start GPS tracking, step-by-step navigation
```
### Ride Arguments Map ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 2212-2279)
The complete `rideArgs` map written to Box:
| Field | Source | Description |
|-------|--------|-------------|
| `passenger_lat`, `passenger_lng` | From order data | Passenger pickup location |
| `passenger_destination_lat`, `passenger_destination_lng` | From order data | Trip destination |
| `ride_id` | From order data | Unique ride identifier |
| `tokenPassenger` | From order data | Passenger auth token |
| `carType` | Driver profile | Speed/Fixed/Comfort/Lady/Electric/Van/Delivery |
| `kazan` | From order data | Commission percentage |
| `status` | Set to `applied` | Current ride state |
| `price` | From order data | Trip price |
| `customerName` | From order data | Passenger name |
| `tripDistance` | From order data | Total trip distance |
| `tripDurationMin` | Calculated | Trip duration in minutes |
---
## 5. Phase 3: Arrival & Begin Ride
### Arrival Detection
The driver's GPS is continuously monitored. When the driver reaches within ~150m of the passenger, the "Arrived" UI state activates.
### Begin Ride ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 838-952)
```mermaid
sequenceDiagram
participant D as Driver
participant MDC as MapDriverController
participant API as Ride Server
participant Box as GetStorage
participant PSGR as Passenger App
D->>MDC: Tap "Begin Ride" / startRideFromDriver()
MDC->>MDC: Validate distance from passenger < 150m
MDC->>API: GET start_ride.php?ride_id=X&driver_id=Y&passenger_id=Z&timestamp=...
API-->>MDC: Success
MDC->>Box: Update status to 'begin'
MDC->>PSGR: Socket emit update_location with status=begin
MDC->>MapSaaS: getRoute(for destination, from current location)
MapSaaS-->>MDC: New polyline from current pos -> destination
MDC->>MDC: Redraw polyline on map
MDC->>MDC: Start pricing timer (rideIsBeginPassengerTimer)
MDC->>MDC: Start step-by-step navigation
```
**Key Validation**: Distance from passenger must be <150m before ride can begin. This prevents starting the ride prematurely.
---
## 6. Phase 4: In-Ride Navigation & Polyline System
### Full Navigation Stack
```mermaid
graph TB
subgraph "Navigation System"
GPS[Geolocator Stream] --> Filter[Jitter Filter <2m]
Filter --> NavCtrl[NavigationController]
Filter --> MDC[MapDriverController]
NavCtrl --> RouteMatch[RouteMatcherWorker Isolate]
NavCtrl --> PolylineDecode[DecodePolylineIsolate]
RouteMatch --> SmartSnap[Smart Sliding Window Snapping]
SmartSnap --> SplitPoly[Split Traveled vs Upcoming]
PolylineDecode --> RouteCoords[Full Route Coordinates]
RouteCoords --> StepNav[Step-by-Step Instructions]
StepNav --> TTS[Flutter TTS Voice]
SplitPoly --> GreyPoly[Grey Traveled Polyline]
SplitPoly --> ColorPoly[Colored Upcoming Polyline]
end
GPS --> Camera[Adaptive Camera]
Camera --> Zoom[Speed-Based Zoom: 15-19]
Camera --> Tilt[Speed-Based Tilt: 0-55deg]
MDC --> Pricing8[Pricing Timer: 1s interval]
```
### Polyline Rendering (Dual Polyline System)
The driver app renders **two polylines simultaneously**:
1. **Upcoming Route** (`polyline` in `MapDriverController`): Colored polyline from the driver's current snapped position to the destination
2. **Traveled Route** (`polyline2` in `MapDriverController`): Grey polyline showing the path already driven
### Smart Sliding Window Snapping ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 2563-2622)
```dart
void _updateTraveledPolylineSmart(LatLng currentLocation) {
// 1. Define sliding window of 60 points around last known index
// 2. Find closest point on the original route polyline within that window
// 3. Split the original route at the matched index:
// - Points [0..matchedIndex] -> traveled (grey polyline)
// - Points [matchedIndex..end] -> upcoming (colored polyline)
// 4. Update map with both polylines
}
```
### Isolate-Based Route Matching ([`route_matcher_worker.dart`](lib/controller/home/navigation/route_matcher_worker.dart))
The heavy computation of finding the closest point on the route polyline is offloaded to a **dedicated isolate**:
```
Messages: init, match, dispose
Response: matchResult { index, lat, lng, dist }
```
- Uses **Float64List** for zero-copy memory sharing
- Sliding window search (default: 120 points, configurable)
- **Haversine distance** for accurate meter-level distance calculation
- **Projection onto line segments** for sub-point accuracy
### Step-by-Step Navigation ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 211-273)
```dart
void startListeningStepNavigation() {
// 1. Subscribe to Geolocator stream with jitter filter (<2m ignore)
// 2. Smooth animation via AnimationController
// 3. Snap to route (update traveled polyline)
// 4. Check proximity to next step waypoint
// 5. If near next waypoint:
// - Speak next instruction via TTS
// - Update currentStepIndex
// - Show next instruction distance
// 6. Update camera position (adaptive zoom/tilt based on speed)
}
```
**Adaptive Camera**:
| Speed | Zoom | Tilt |
|-------|------|------|
| < 15 km/h | 19 | 0° |
| < 40 km/h | 18 | 40° |
| < 70 km/h | 17 | 55° |
| < 100 km/h | 16 | 55° |
| 100+ km/h | 15 | 55° |
---
## 7. Phase 5: Finish Ride & Payment
### Finish Ride Flow ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 1236-1354)
```mermaid
sequenceDiagram
participant D as Driver
participant MDC as MapDriverController
participant API as Ride Server
participant PayAPI as Payment Server
participant Box as GetStorage
D->>MDC: Tap "Finish Ride"
Note over MDC: Validate trip distance anti-fraud
par Parallel Execution
MDC->>API: finish_ride_updates.php
MDC->>PayAPI: process_ride_payments.php
end
API-->>MDC: Ride status updated to finished
PayAPI-->>MDC: Payment processed
MDC->>MDC: Stop pricing timer
MDC->>MDC: Stop navigation / polyline
MDC->>Box: Update status to 'finished'
MDC->>Box: Save ride price to payment_summary
MDC->>MDC: Clear polyline, markers, camera
MDC->>MDC: Show ride summary / review UI
```
### Anti-Fraud Distance Validation ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines ~1290-1330)
```dart
_validateTripDistance() {
// Actual traveled distance must be >= 1/5 of expected trip distance
// If not, auto-reject as potential fraud
// This prevents drivers from starting and immediately finishing rides
}
```
### Payment Processing ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines ~1330-1354)
- Payment is processed in parallel with ride finish
- Payment server generates secure tokens for wallet transactions
- Supports: Stripe, PayMob, MTN, Syriatel, eCash, ShamCash
---
## 8. Phase 6: Post-Ride (Rating, Review)
After finishing, the driver enters the `preCheckReview` state:
1. **Rating**: Rate the passenger (`addRateToPassenger.php`)
2. **Review Screen**: `ride_calculate_driver.dart` shows:
- Trip price breakdown
- Commission (kazan%)
- Net earnings
- Distance/time summary
3. **Return to Home**: Status reset to `noRide`, ready for next order
---
## 9. Pricing Engine
### Core Pricing Timer ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 1481-1570)
```dart
void rideIsBeginPassengerTimer() {
Timer.periodic(Duration(seconds: 1), (timer) {
// 1. Calculate distance delta since last tick
// 2. Fetch current car type pricing config
// 3. Apply time-of-day multiplier
// 4. Apply distance-based thresholds
// 5. Apply airport surcharge if applicable
// 6. Calculate commission (kazan%)
// 7. Update live price display
});
}
```
### Price Calculation Formula ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 1572-1662)
```dart
double _calculateCurrentPrice() {
// Base price depends on carType:
// Speed, Fixed, Comfort, Lady, Electric, Van, Delivery
//
// Time-of-day bands:
// nature (normal), late (evening/night), heavy (peak)
//
// Distance thresholds:
// 25km, 35km, 40km - different pricing tiers
//
// Commission: kazan% taken by platform
//
// Airport contexts: additional surcharge
//
// Formula (simplified):
// basePrice = carType.baseRate * timeMultiplier
// distancePrice = distance * carType.perKmRate
// if distance > 25km: apply longTripMultiplier
// if airport: add airportSurcharge
// finalPrice = (basePrice + distancePrice) * (1 + kazan/100)
}
```
---
## 10. Socket.IO Communication
### Connection Setup ([`location_controller.dart`](lib/controller/functions/location_controller.dart), lines 183-228)
```dart
void initSocket() {
socket = io(
'https://location.intaleq.xyz',
<String, dynamic>{
'transports': ['websocket'], // WebSocket-only transport
'query': {
'driver_id': driverId,
'token': authToken,
},
},
);
}
```
### Events Summary
| Event | Direction | Frequency | Purpose |
|-------|-----------|-----------|---------|
| `new_ride_request` | Server → Driver | On demand | Incoming ride request |
| `ride_taken` | Server → Driver | On demand | Ride accepted by another driver |
| `cancel_ride` | Server → Driver | On demand | Passenger cancelled the ride |
| `update_location` | Driver → Server | Every 5-10s | Driver location broadcast |
| `connect` | Bidirectional | On connect | Socket established |
| `disconnect` | Bidirectional | On disconnect | Socket lost |
| Heartbeat (ping/pong) | Bidirectional | Every 25s | Keep-alive |
### Cancel Ride Handler ([`map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart), lines 339-410)
```dart
void processRideCancelledByPassenger() {
// Gatekeeper: stop all timers immediately (Fix 2)
// Stop: pricingTimer, waitingTimer, navigation
// Show cancellation dialog
// Clear ride data from Box
// Reset status to noRide
// Return to HomeCaptain
}
```
### Location Upload ([`location_controller.dart`](lib/controller/functions/location_controller.dart), lines 420-453)
```dart
void emitLocationToSocket() {
Map<String, dynamic> data = {
'driver_id': driverId,
'lat': currentLat,
'lng': currentLng,
'heading': heading,
'speed': speed,
'status': currentStatus,
'distance': distance,
};
// If ride active, inject passenger_id and ride_id
if (rideActive) {
data['passenger_id'] = passengerId;
data['ride_id'] = rideId;
}
socket.emit('update_location', data);
}
```
---
## 11. HTTP Backend API Endpoints
### Ride Lifecycle Endpoints
| Endpoint | Method | Phase | Purpose |
|----------|--------|-------|---------|
| [`acceptRide.php`](lib/constant/links.dart) | GET | Accept | Driver accepts ride offer |
| [`start_ride.php`](lib/constant/links.dart) | GET | Begin | Start the ride trip |
| [`finish_ride_updates.php`](lib/constant/links.dart) | GET | Finish | Complete ride (parallel) |
| [`process_ride_payments.php`](lib/constant/links.dart) | GET | Finish | Process payment (parallel) |
| [`cancelRide/add.php`](lib/constant/links.dart) | POST | Any | Log cancellation |
| [`addCancelTripFromDriverAfterApplied.php`](lib/constant/links.dart) | POST | Applied | Driver cancels after accepting |
### Ride Data Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| [`rides/add.php`](lib/constant/links.dart) | POST | Create ride record |
| [`rides/get.php`](lib/constant/links.dart) | GET | Retrieve ride details |
| [`rides/update.php`](lib/constant/links.dart) | POST | Update ride status |
| [`rides/delete.php`](lib/constant/links.dart) | DELETE | Remove ride record |
| [`getRideStatus.php`](lib/constant/links.dart) | GET | Check ride status |
| [`getRideOrderID.php`](lib/constant/links.dart) | GET | Get order ID for ride |
| [`updateRideAndCheckIfApplied.php`](lib/constant/links.dart) | POST | Atomic status check + update |
| [`getRideStatusFromStartApp.php`](lib/constant/links.dart) | GET | Recover ride status on app start |
### Location Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| [`add_batch.php`](lib/constant/links.dart) | POST | Batch location upload |
| [`save_behavior.php`](lib/constant/links.dart) | POST | Record driving behavior |
| [`get.php`](lib/constant/links.dart) | GET | Get car locations |
| [`getRidesDriverByDay.php`](lib/constant/links.dart) | GET | Daily ride history |
| [`getTotalDriverDuration.php`](lib/constant/links.dart) | GET | Total driving time |
### Map SaaS Endpoint
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `https://map-saas.intaleqapp.com/api/maps/route` | POST | Route calculation with polyline |
| `https://map-saas.intaleqapp.com/api/geocoding/places` | POST | Place search/geocoding |
**Route API Response Format** (OSRM-compatible):
```json
{
"routes": [{
"geometry": {
"coordinates": [[lng, lat], ...],
"points": "encoded_polyline_string"
},
"legs": [{
"steps": [
{
"maneuver": { "location": [lng, lat], "modifier": "straight" },
"instruction": "Continue straight on Main St",
"distance": 123.4,
"duration": 45.6
}
],
"distance": 5000.0,
"duration": 600.0
}],
"distance": 5000.0,
"duration": 600.0
}]
}
```
### Payment Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| [`payment/add.php`](lib/constant/links.dart) | POST | Record payment |
| [`payment/get.php`](lib/constant/links.dart) | GET | Get today's payments |
| [`getAllPaymentFromRide.php`](lib/constant/links.dart) | GET | All payments for a ride |
| [`addPaymentTokenDriver.php`](lib/constant/links.dart) | POST | Generate payment token |
| [`payWithPayMobCardDriver.php`](lib/constant/links.dart) | POST | PayMob card payment |
| [`payWithWallet.php`](lib/constant/links.dart) | POST | Wallet payment |
| [`payWithMTNConfirm.php`](lib/constant/links.dart) | POST | MTN payment confirmation |
| [`payWithSyriatelConfirm.php`](lib/constant/links.dart) | POST | Syriatel payment confirmation |
---
## 12. Polyline Engine Deep Dive
### Polyline Decoding ([`decode_polyline_isolate.dart`](lib/controller/home/navigation/decode_polyline_isolate.dart))
Standard Google Encoded Polyline Format v5:
```dart
List<LatLng> decodePolylineIsolate(String encoded) {
// Standard algorithm:
// 1. Read 5-bit chunks from charCode - 63
// 2. Reconstruct signed value using ZigZag decoding
// 3. Accumulate lat/lng and divide by 1E5
// 4. Add to points list
}
```
Runs in **separate isolate** via `compute(PolylineUtils.decode, ...)` to avoid jank.
### Route Fetching ([`order_request_controller.dart`](lib/controller/home/captin/order_request_controller.dart), lines 341-405)
```dart
Future<Map<String, dynamic>> _fetchRouteData(LatLng from, LatLng to) async {
// 1. POST to AppLink.mapSaasRoute with coordinates
// 2. Parse OSRM-style response
// 3. Decode polyline via compute(_decodePolyline, ...)
// 4. Return {distance, duration, polyline, steps}
}
```
### Dual Polyline System (Visual)
```
Before traveling:
[Passenger] ============================================> [Destination]
(Full route in blue/colored)
During travel:
[Driver] ~~~~~~~~~~> [Current Position] ===============> [Destination]
(Grey traveled) (Colored upcoming)
```
### Smart Snapping Visualization
```
Original route points:
P0 --- P1 --- P2 --- P3 --- P4 --- P5 --- P6 --- P7 --- P8
Driver at position X (near P2-P3 segment):
Sliding window: [P0---P1---P2---P3---P4---P5] (window=60)
Closest projection: on segment P2-P3 at point C
Result:
Traveled: P0---P1---P2---C (grey)
Upcoming: C---P3---P4---P5---P6---P7---P8 (colored)
```
---
## 13. Location Tracking System
### Dual-Interval Architecture ([`location_controller.dart`](lib/controller/functions/location_controller.dart))
```mermaid
graph LR
subgraph "Normal Mode"
GPS3[GPS every 5s] --> Record3[Record to buffer every 3s]
Record3 --> Upload2[Upload batch every 2min]
Upload2 --> Socket[Socket.IO emit]
Upload2 --> HTTP[HTTP batch upload]
end
subgraph "Power Save Mode"
GPS10[GPS every 10s] --> Record10[Record to buffer every 10s]
Record10 --> Upload5[Upload batch every 5min]
Upload5 --> Socket
Upload5 --> HTTP
end
```
### Behavior Recording
In addition to location, the system records **driving behavior**:
- Acceleration/deceleration events
- Speed threshold violations
- Uploaded to `save_behavior.php`
### Battery Optimization
- **Wakelock**: Maintained during active rides (`wakelock_plus`)
- **Background Service**: `flutter_background_service` keeps location streaming alive
- **Overlay Window**: `flutter_overlay_window` shows driver status even when app is backgrounded
---
## 14. Voice Call Signaling
### WebSocket Signaling ([`signaling_service.dart`](lib/services/signaling_service.dart))
```mermaid
sequenceDiagram
participant D as Driver App
participant WS as WebSocket wss://calls.intaleqapp.com/ws
participant P as Passenger App
D->>WS: authenticate { session_id, user_id }
WS-->>D: authenticated
P->>WS: authenticate { session_id, user_id }
WS-->>P: authenticated
P->>WS: call_request { target_user_id }
WS->>D: participant_joined { user_id }
D->>WS: offer { sdp }
WS->>P: offer { sdp }
P->>WS: answer { sdp }
WS->>D: answer { sdp }
D->>WS: ice_candidate { candidate }
WS->>P: ice_candidate { candidate }
P->>WS: ice_candidate { candidate }
WS->>D: ice_candidate { candidate }
Note over D,P: WebRTC Peer Connection established
D->>WS: call_ended
WS->>P: call_ended
```
---
## 15. Key Architectural Patterns & Fixes
### Documented Fixes
| Fix | Issue | Solution |
|-----|-------|----------|
| Fix 1 | Two competing GPS listeners | Merged into single stream subscription |
| Fix 2 | Timer leak on cancel | Stop ALL timers immediately at gatekeeper |
| Fix 3 | Polyline decode blocking UI | Moved to `compute()` isolate |
| Fix 4 | Wrong distance unit in validation | Fixed `_validateTripDistance()` unit conversion |
| Fix 5 | `Future.delayed` without `await` | Added proper `await` |
| Fix 6 | Redundant heartbeat during stream | Skip heartbeat if location stream active |
### Architecture Patterns
1. **Controller-per-Screen**: Each screen has its own `GetxController`
2. **Box Persistence**: GetStorage used for ride state recovery across app restarts
3. **Socket Decoupling**: Location data flows through Socket.IO, but ride CRUD uses HTTP REST
4. **Isolate Offloading**: Heavy polyline operations run in isolates via `compute()`
5. **Parallel Execution**: Finish ride + payment run concurrently via `Future.wait`
6. **Dual Data Format Support**: Socket data arrives as either List or Map — both handled
7. **Gatekeeper Pattern**: Cancellation handler stops all active processes at a single entry point
---
## 16. Data Flow Diagrams
### Complete Ride Lifecycle Data Flow
```mermaid
graph TB
subgraph "Pre-Ride"
A[Socket: new_ride_request] --> B[Parse List/Map]
B --> C[OrderRequestPage]
C --> D[Driver Accepts]
D --> E[HTTP: acceptRide.php]
E --> F[Write rideArgs to Box]
F --> G[Navigate to Map]
end
subgraph "To-Passenger"
G --> H[Route: Driver -> Passenger]
H --> I[Drew colored polyline]
I --> J[Step Nav + TTS]
J --> K[GPS updates every 5s]
K --> L[Socket: update_location]
L --> M[Smart snap to route]
end
subgraph "At Passenger"
M --> N[Arrive ~150m]
N --> O[HTTP: start_ride.php]
O --> P[Redraw route -> Destination]
end
subgraph "To-Destination"
P --> Q[Pricing Timer 1s]
Q --> R[Live price display]
R --> S[Step Nav + TTS]
S --> T[Dual polyline: grey + colored]
T --> U[Socket: update_location with status]
end
subgraph "Finish"
U --> V[HTTP: finish_ride_updates.php]
V --> W[HTTP: process_ride_payments.php]
W --> X[Stop all timers]
X --> Y[Clear polylines]
Y --> Z[Show summary / rating]
Z --> AA[Reset to noRide]
end
style A fill:#e74c3c,color:#fff
style O fill:#2ecc71,color:#fff
style V fill:#2ecc71,color:#fff
style W fill:#2ecc71,color:#fff
style AA fill:#3498db,color:#fff
```
### Socket Event Flow During Ride
```mermaid
sequenceDiagram
participant Driver
participant LS as Location Server
participant PSGR as Passenger App
Note over Driver: Searching for ride
LS->>Driver: new_ride_request { data }
Driver->>LS: update_location { status: searching }
Note over Driver: Ride accepted
Driver->>LS: update_location { status: applied, ride_id }
Note over Driver: En route to passenger
Driver->>LS: update_location { lat, lng, heading, speed, status: goingToPassenger }
LS->>PSGR: driverLocationUpdate { ... }
Note over Driver: Arrived at passenger
Driver->>LS: update_location { status: arrived }
Note over Driver: Ride started
Driver->>LS: update_location { status: inProgress, ride_id }
Note over Driver: En route to destination
Driver->>LS: update_location { lat, lng, heading, speed, status: inProgress }
LS->>PSGR: driverLocationUpdate { ... }
Note over Driver: Ride finished
Driver->>LS: update_location { status: finished }
Note over Driver: Back to idle
Driver->>LS: update_location { status: online }
```
### Key Backend Endpoints Used Per Phase
| Phase | Endpoint | Purpose |
|-------|----------|---------|
| Accept | `acceptRide.php?ride_id=X&driver_id=Y` | Accept ride |
| Begin | `start_ride.php?ride_id=X&driver_id=Y&passenger_id=Z` | Start trip |
| In-Ride | `update_location` (Socket) | Location streaming |
| In-Ride | `updateRideAndCheckIfApplied.php` | Status sync |
| In-Ride | `getKazanPercent.php` | Commission config |
| Finish | `finish_ride_updates.php` | Complete ride |
| Finish | `process_ride_payments.php` | Payment processing |
| Finish | `addRateToPassenger.php` | Passenger rating |
| Post-Ride | `getAllPaymentFromRide.php` | Payment summary |
| Any | `getRideStatusFromStartApp.php` | State recovery on restart |
---
## Appendix: Key File Reference
| File | Lines | Purpose |
|------|-------|---------|
| [`lib/controller/home/captin/map_driver_controller.dart`](lib/controller/home/captin/map_driver_controller.dart) | 2644 | Core driver ride lifecycle, navigation, polyline, pricing |
| [`lib/controller/home/captin/order_request_controller.dart`](lib/controller/home/captin/order_request_controller.dart) | 828 | Ride request handling, accept logic, route display |
| [`lib/controller/functions/location_controller.dart`](lib/controller/functions/location_controller.dart) | 794 | Socket.IO, location tracking, batch upload, behavior |
| [`lib/controller/home/navigation/navigation_controller.dart`](lib/controller/home/navigation/navigation_controller.dart) | 1383 | Step-by-step navigation, route matching, alternative routes |
| [`lib/controller/home/navigation/route_matcher_worker.dart`](lib/controller/home/navigation/route_matcher_worker.dart) | 146 | Isolate-based route matching with sliding window |
| [`lib/controller/home/navigation/decode_polyline_isolate.dart`](lib/controller/home/navigation/decode_polyline_isolate.dart) | 32 | Polyline decode in isolate |
| [`lib/models/model/order_data.dart`](lib/models/model/order_data.dart) | 188 | Order data model (List + Map constructors) |
| [`lib/constant/links.dart`](lib/constant/links.dart) | 424 | All backend API endpoints |
| [`lib/services/signaling_service.dart`](lib/services/signaling_service.dart) | 112 | WebRTC call signaling |
| [`lib/services/offline_map_service.dart`](lib/services/offline_map_service.dart) | - | Offline map tile service |
| [`pubspec.yaml`](pubspec.yaml) | 144 | Dependencies and package config |

View File

@@ -0,0 +1,290 @@
<?php
require_once __DIR__ . '/../../connect.php';
try {
$con_ride = Database::get('ride');
} catch (Exception $e) {
error_log("[finish_ride_updates] Failed to connect to Ride Database: " . $e->getMessage());
}
// ============================================================
// finish_ride_updates.php — Atomic Server-to-Server
// ============================================================
// Driver App calls this ONCE with raw ride data (NOT the price).
// Server calculates price securely, processes payment via S2S,
// and atomically updates all databases within a transaction.
//
// Flow:
// 1. Receive raw params from driver app
// 2. Calculate price server-side (from DB + actual distance)
// 3. BEGIN TRANSACTION (local DB)
// 4. Update ride on local DB + remote DB (con_ride)
// 5. Update driver_orders
// 6. S2S cURL → Wallet Payment Server (process_ride_payments.php)
// 7. If payment OK → COMMIT, notify passenger (Socket + FCM)
// 8. If payment FAIL → ROLLBACK, ride stays 'Begin', safe retry
// ============================================================
// --- Secure S2S Configuration ---
define('S2S_SHARED_KEY', getenv('S2S_SHARED_KEY') );
define('WALLET_PAYMENT_URL', 'https://walletintaleq.intaleq.xyz/v1/main/ride/payment/process_ride_payments.php');
// ============================================================
// 1. Receive Raw Parameters (NO price from client)
// ============================================================
$rideId = filterRequest("rideId");
$driver_id = filterRequest("driver_id");
$passengerId = filterRequest("passengerId");
$newStatus = filterRequest("status"); // Expected: "Finished"
$actualDistance = filterRequest("actualDistance");
$actualDuration = filterRequest("actualDuration");
$passengerToken = filterRequest("passengerToken");
$driver_token = filterRequest("driver_token");
$walletChecked = filterRequest("walletChecked");
$passengerWalletBurc = filterRequest("passengerWalletBurc");
if (empty($rideId) || empty($newStatus) || empty($driver_id) || empty($passengerId)) {
jsonError("Missing required parameters: rideId, driver_id, passengerId, status");
exit;
}
if ($newStatus !== 'Finished') {
jsonError("Invalid status. Expected: Finished");
exit;
}
// ============================================================
// 2. Server-Side Price Calculation (Secure — NOT from client)
// ============================================================
try {
// Fetch ride data from remote/local DB for server-side calculation
$stmtRideData = $con->prepare("
SELECT id, price AS quoted_price, car_type,
distance AS planned_distance, passenger_id, driver_id
FROM ride WHERE id = ? AND driver_id = ?
LIMIT 1
");
$stmtRideData->execute([$rideId, $driver_id]);
$rideData = $stmtRideData->fetch(PDO::FETCH_ASSOC);
if (!$rideData) {
jsonError("Ride not found or driver mismatch.");
exit;
}
$quotedPrice = floatval($rideData['quoted_price'] ?? 0);
$kazanPercent = 10;
$carType = $rideData['car_type'] ?? 'Fixed Price';
// Fixed-price types: use quoted price as-is
$fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car'];
if (in_array($carType, $fixedPriceTypes)) {
$finalPrice = $quotedPrice;
} else {
// Variable pricing: calculate from actual distance
$cleanDist = preg_replace('/[^0-9.]/', '', $actualDistance);
$distanceKm = floatval($cleanDist);
if ($distanceKm <= 0) {
$finalPrice = $quotedPrice; // fallback
} else {
$perKmRate = getPerKmRate($carType);
$perMinRate = getPerMinRate();
$durationMin = intval(preg_replace('/[^0-9]/', '', $actualDuration));
$calculated = ($distanceKm * $perKmRate) + ($durationMin * $perMinRate);
$calculated *= (1 + ($kazanPercent / 100));
$finalPrice = max($quotedPrice, round($calculated, 2));
}
}
} catch (PDOException $e) {
jsonError("Error calculating price: " . $e->getMessage());
exit;
}
// ============================================================
// 3. Atomic Transaction: Update DBs + Process Payment
// ============================================================
try {
// --- Update Remote DB (con_ride) FIRST ---
// (Not in transaction — remote DB doesn't support cross-DB rollback,
// but we keep it minimal as a "best-effort" update)
if (isset($con_ride)) {
$stmtRemote = $con_ride->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtRemote->execute([$newStatus, $finalPrice, $rideId]);
}
// --- BEGIN Local DB Transaction ---
$con->beginTransaction();
// 3a. Update ride (local DB)
$stmtLocal = $con->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtLocal->execute([$newStatus, $finalPrice, $rideId]);
if ($stmtLocal->rowCount() == 0) {
throw new Exception("Ride already finished or not found in local DB.");
}
// 3b. Update driver_orders
$checkStmt = $con->prepare("SELECT order_id FROM driver_orders WHERE order_id = ?");
$checkStmt->execute([$rideId]);
if ($checkStmt->rowCount() > 0) {
$con->prepare("UPDATE driver_orders SET driver_id = ?, status = ?, created_at = NOW() WHERE order_id = ?")
->execute([$driver_id, $newStatus, $rideId]);
} else {
$con->prepare("INSERT INTO driver_orders (driver_id, order_id, created_at, status) VALUES (?, ?, NOW(), ?)")
->execute([$driver_id, $rideId, $newStatus]);
}
// ============================================================
// 3c. Server-to-Server Payment Processing (S2S)
// ============================================================
$paymentPayload = [
'rideId' => $rideId,
'driverId' => $driver_id,
'passengerId' => $passengerId,
'paymentAmount' => $finalPrice,
'paymentMethod' => ($walletChecked === 'true') ? 'wallet' : 'cash',
'walletChecked' => $walletChecked,
'passengerWalletBurc' => $passengerWalletBurc,
'authToken' => $driver_token,
];
$ch = curl_init(WALLET_PAYMENT_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($paymentPayload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . S2S_SHARED_KEY,
],
]);
$paymentResponse = curl_exec($ch);
$httpStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
// Validate payment response
$paymentSuccess = false;
$paymentError = '';
if ($curlError) {
$paymentError = "S2S connection error: " . $curlError;
} elseif ($httpStatusCode !== 200) {
$paymentError = "Payment server returned HTTP $httpStatusCode";
} else {
$paymentResult = json_decode($paymentResponse, true);
if ($paymentResult && isset($paymentResult['status']) && $paymentResult['status'] === 'success') {
$paymentSuccess = true;
} else {
$paymentError = $paymentResult['error'] ?? 'Payment server returned failure';
}
}
if (!$paymentSuccess) {
// ❌ Payment failed — ROLLBACK everything
$con->rollBack();
error_log("[finish_ride_updates] Payment FAILED for ride $rideId: $paymentError");
jsonError("Payment processing failed: $paymentError");
exit;
}
// ✅ Payment succeeded — COMMIT
$con->commit();
// ============================================================
// 4. Notifications (After successful commit)
// ============================================================
$passenger_id = $passengerId; // alias for legacy code
if (!empty($passenger_id)) {
// Legacy list for backward compatibility
$legacyList = [
(string)$driver_id,
(string)$rideId,
(string)$driver_token,
(string)$finalPrice
];
// a) Socket notification
$socketPayload = [
'ride_id' => $rideId,
'status' => 'finished',
'price' => $finalPrice,
'DriverList' => $legacyList
];
if (function_exists('notifyPassengerOnRideServer')) {
notifyPassengerOnRideServer($passenger_id, $socketPayload);
}
// b) FCM notification
if (!empty($passengerToken)) {
$fcmData = [
'ride_id' => (string)$rideId,
'price' => (string)$finalPrice,
'DriverList' => $legacyList
];
sendFCM_Internal(
$passengerToken,
"تم إنهاء الرحلة 🏁",
"المبلغ المطلوب: " . $finalPrice . " ل.س",
$fcmData,
'Driver Finish Trip',
false
);
}
}
// ============================================================
// 5. Return Success with server-calculated price
// ============================================================
jsonSuccess([
'price' => $finalPrice,
'rideId' => $rideId
], "Ride finished and payment processed successfully.");
} catch (Exception $e) {
if (isset($con) && $con->inTransaction()) {
$con->rollBack();
}
error_log("[finish_ride_updates] Error for ride $rideId: " . $e->getMessage());
jsonError("Transaction failed: " . $e->getMessage());
}
// ============================================================
// Helper Functions
// ============================================================
function getPerKmRate(string $carType): float {
$rates = [
'Comfort' => 44,
'Lady' => 44,
'Mishwar Vip' => 50,
'Electric' => 45,
'Van' => 63,
'Delivery' => 25,
'Speed' => 36,
'Fixed Price' => 36,
'Awfar Car' => 36,
];
return $rates[$carType] ?? 36;
}
function getPerMinRate(): float {
$hour = (int)date('H');
if ($hour >= 21 || $hour < 1) return 11; // Late
if ($hour >= 14 && $hour <= 17) return 10; // Peak
return 9; // Normal
}
?>

View File

@@ -0,0 +1,40 @@
# تقرير مراجعة كلاس MapDriverController - النسخة النهائية
## الإصلاحات المطبقة بالكامل ✅ (15 إصلاحاً)
### المراجعة الأولى (V1) — 12 إصلاحاً
| الكود | المشكلة | الحل | الحالة |
|-------|---------|------|--------|
| C-1 | `updateLocation()` for loop تسبب تسرب ذاكرة | `Timer.periodic` مع `startUpdateLocationTimer` و `stopUpdateLocationTimer` | ✅ |
| C-2 | `_validateTripDistance()` تُرجع قبل إغلاق الديالوج | `Completer<bool>` مع Deadlock protection | ✅ |
| C-3 | تكرار كود تحليل المسافة بين `finishRideFromDriver` و `_validateTripDistance` | دالة مشتركة `_parseDistanceToMeters()` | ✅ |
| C-4 | `myLocation` لا تتحدث في المستمع الأساسي | إضافة `myLocation = newLoc` في `_handleLocationUpdate` | ✅ |
| M-1 | اسم `jitterMeters` مضلّل (القيمة بالكيلومتر) | تغيير إلى `jitterKm = 0.01` | ✅ |
| M-2 | Variable Shadowing في `markDriverAsArrived` | تغيير المتغير إلى `distToPassenger` | ✅ |
| M-3 | كود ميت `_performanceReadings` و `_hasMadeDecision` | إزالة كاملة | ✅ |
| M-4 | تكرار `checkForNextStep` و `_checkNavigationStep` | دمج الدالتين + إزالة الكود المعلّق القديم | ✅ |
| M-5 | `disposeEverything` تستدعاء `onClose` مباشرة | استخدام `_stopAllServices` بدلاً منها | ✅ |
| M-6 | وحدات غير واضحة في `_calculateWaitingCost` | تعليق توضيحي `distanceBetweenDriverAndPassengerWhenConfirm بالكيلومتر` | ✅ |
| N-1 | رابط Google Maps `&` بدلاً من `?` | تصحيح المعاملات | ✅ |
| N-5 | `getLocationArea` تكتب في `box` بدون `update()` | إضافة `update()` بعد كل كتابة | ✅ |
### المراجعة الثانية (V2) — 3 إصلاحات إضافية
| الكود | المشكلة | الحل | الحالة |
|-------|---------|------|--------|
| C-2 v2 | Completer Deadlock عند إغلاق الديالوج بزر الرجوع | استخدام `Get.dialog` مع `.then()` callback يُكمل بـ `false` | ✅ |
| C-3 v2 | ديالوج مكرر عند إنهاء الرحلة بالزر | تمرير `isFromSlider: true` بعد التأكيد لتخطي الديالوج الثاني | ✅ |
| M-7 | Null checks على `String` غير قابلة للـ null | استخدام `isNotEmpty` بدلاً من `!= null` | ✅ |
## الإصلاحات الإضافية المطبقة
- تنظيف جميع التايمرات في `onClose()` و `_stopAllServices()`
- إزالة `@override` المكرر
- إضافة تعليقات توضيحية `[Fix Code]` لكل إصلاح
## الإصلاحات المتبقية (تحسينات منخفضة الأولوية) ⚠️
| الكود | الوصف | الأولوية |
|-------|-------|---------|
| N-4 | تحويل `step0..step4` إلى `List<String>` (تحسين تجاري) | منخفض |
| N-2 | استبدال `Future.delayed` في `argumentLoading` بـ `Completer` (تحسين أداء) | منخفض |

View File

@@ -0,0 +1,141 @@
<?php
/**
* process_ride_payments.php — Payment Processing Server
*
* Receives S2S (Server-to-Server) requests from finish_ride_updates.php.
* Authenticated via X-S2S-Api-Key header matching a shared secret.
*
* Flow:
* 1. Validate X-S2S-Api-Key header
* 2. BEGIN TRANSACTION
* 3. Insert payment record
* 4. Deduct from passenger wallet (if walletChecked)
* 5. Settle passenger debt (if negative balance)
* 6. Deduct driver points (8%)
* 7. COMMIT / ROLLBACK on failure
*/
// Adjust path as needed for your payment server structure
require_once __DIR__ . '/../../jwtconnect.php';
// === Secure S2S Configuration ===
define('S2S_SHARED_KEY', getenv('S2S_SHARED_KEY'));
// ============================================================
// 1. API Key Authentication (X-S2S-Api-Key header)
// ============================================================
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
if (empty($providedKey) || $providedKey !== S2S_SHARED_KEY) {
http_response_code(401);
printFailure("Unauthorized: Invalid or missing X-S2S-Api-Key.");
exit;
}
// ============================================================
// 2. Receive All Required Parameters
// ============================================================
$rideId = filterRequest("rideId");
$driverId = filterRequest("driverId");
$passengerId = filterRequest("passengerId");
$paymentAmount = filterRequest("paymentAmount");
$paymentMethod = filterRequest("paymentMethod");
$walletChecked = filterRequest("walletChecked"); // 'true' or 'false'
$passengerWalletBurc = filterRequest("passengerWalletBurc"); // passenger balance before operation
$authToken = filterRequest("authToken"); // kept for logging/audit, not used for auth
// --- Validate required fields ---
if (empty($rideId) || empty($driverId) || empty($passengerId) ||
!isset($paymentAmount) || empty($paymentMethod) ||
!isset($walletChecked) || !isset($passengerWalletBurc)) {
printFailure("Missing required parameters for payment processing.");
exit;
}
// ============================================================
// 3. Atomic Payment Processing
// ============================================================
try {
// --- Begin Transaction ---
$con->beginTransaction();
// 3a. Insert main payment record
$finalPaymentMethod = ($walletChecked === 'true') ? $paymentMethod . "Ride" : $paymentMethod;
$stmtPayment = $con->prepare(
"INSERT INTO payments (id, amount, payment_method, passengerID, rideId, driverID)
VALUES (UUID_SHORT(), :amount, :payment_method, :passengerID, :rideId, :driverID)"
);
$stmtPayment->execute([
':amount' => $paymentAmount,
':payment_method' => $finalPaymentMethod,
':passengerID' => $passengerId,
':rideId' => $rideId,
':driverID' => $driverId,
]);
if ($stmtPayment->rowCount() <= 0) {
throw new Exception("Failed to create payment record.");
}
// 3b. Deduct from passenger wallet (if wallet payment)
if ($walletChecked === 'true') {
$stmtPassengerWallet = $con->prepare(
"INSERT INTO `passengerWallet` (`passenger_id`, `balance`)
VALUES (:passenger_id, :balance)"
);
$stmtPassengerWallet->execute([
':passenger_id' => $passengerId,
':balance' => (-1) * floatval($paymentAmount),
]);
if ($stmtPassengerWallet->rowCount() <= 0) {
throw new Exception("Failed to deduct from passenger wallet.");
}
}
// 3c. Settle existing passenger debt (if balance was negative)
if (floatval($passengerWalletBurc) < 0) {
$stmtPassengerDebt = $con->prepare(
"INSERT INTO `passengerWallet` (`passenger_id`, `balance`)
VALUES (:passenger_id, :balance)"
);
$stmtPassengerDebt->execute([
':passenger_id' => $passengerId,
':balance' => (-1) * floatval($passengerWalletBurc),
]);
if ($stmtPassengerDebt->rowCount() <= 0) {
throw new Exception("Failed to settle passenger debt.");
}
}
// 3d. Deduct driver points (8% of payment amount)
$pointsSubtraction = floatval($paymentAmount) * (-0.08);
$stmtDriverPoints = $con->prepare(
"INSERT INTO `driverWallet` (`driverID`, `paymentID`, `amount`, `paymentMethod`)
VALUES (:driverID, :paymentID, :amount, :paymentMethod)"
);
$stmtDriverPoints->execute([
':driverID' => $driverId,
':paymentID' => 'rideId' . $rideId,
':amount' => number_format($pointsSubtraction, 0, '', ''),
':paymentMethod' => $paymentMethod,
]);
if ($stmtDriverPoints->rowCount() <= 0) {
throw new Exception("Failed to update driver wallet points.");
}
// --- All operations succeeded → Commit ---
$con->commit();
printSuccess("Payment processed successfully for ride $rideId.");
} catch (Exception $e) {
// --- Any failure → Rollback all changes ---
if (isset($con) && $con->inTransaction()) {
$con->rollBack();
}
error_log("[process_ride_payments] Transaction FAILED for ride $rideId: " . $e->getMessage());
printFailure("Transaction failed: " . $e->getMessage());
}

View File

@@ -304,6 +304,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.7"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.dev"
source: hosted
version: "1.8.1"
dbus:
dependency: transitive
description:
@@ -887,6 +895,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: c7b0a67ca2c878575fc5c146d801cd874f58f5f1ef5fa6e8eb0c93d413beb948
url: "https://pub.dev"
source: hosted
version: "1.4.1"
flutter_widget_from_html:
dependency: "direct main"
description:
@@ -1460,6 +1476,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.1"
logger:
dependency: transitive
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
@@ -1861,13 +1885,13 @@ packages:
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: "direct overridden"
dependency: transitive
description:
name: record_platform_interface
sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4"
sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.6.0"
record_web:
dependency: transitive
description:
@@ -2375,6 +2399,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.dev"
source: hosted
version: "1.5.1"
webview_flutter:
dependency: "direct main"
description:

View File

@@ -29,6 +29,7 @@ dependencies:
animated_text_kit: ^4.2.2
calendar_builder: ^0.0.6
cupertino_icons: ^1.0.2
flutter_webrtc: ^1.4.1
fl_chart: ^1.2.0
flutter_confetti: ^0.5.1
flutter_font_icons: ^2.2.5
@@ -71,7 +72,7 @@ dependencies:
record: ^6.2.0
share_plus: ^12.0.2
sign_in_with_apple: ^7.0.1
socket_io_client: ^1.0.2
socket_io_client: 1.0.2
url_launcher: ^6.3.1
vibration: ^3.1.8
video_player: ^2.9.2
@@ -138,6 +139,5 @@ flutter:
fonts:
- asset: assets/fonts/digit.ttf
dependency_overrides:
record_platform_interface: "1.2.0"
get:
path: ../Intaleq/packages/get

View File

@@ -0,0 +1,45 @@
import subprocess
import sys
def main():
print("Starting unsandboxed iOS build diagnostics...")
cmd = ["/Users/hamzaaleghwairyeen/flutter/bin/flutter", "build", "ios", "--no-codesign"]
# Run the command and print lines containing error or failure indicators
process = subprocess.Popen(
cmd,
cwd="/Users/hamzaaleghwairyeen/development/App/intaleq_driver",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
recent_lines = []
print_errors = False
while True:
line = process.stdout.readline()
if not line:
break
# Keep a rolling buffer of the last 30 lines
recent_lines.append(line.strip())
if len(recent_lines) > 30:
recent_lines.pop(0)
# If we see common error indicators, print them immediately
line_lower = line.lower()
if "error:" in line_lower or "failed" in line_lower or "error •" in line_lower:
print(f"🚨 FOUND ERROR: {line.strip()}")
print_errors = True
process.wait()
print(f"\nBuild finished with exit code: {process.returncode}")
if process.returncode != 0:
print("\n--- Last 30 lines of build output ---")
for rl in recent_lines:
print(rl)
if __name__ == "__main__":
main()

View File

@@ -14,6 +14,7 @@
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_tts/flutter_tts_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -38,6 +39,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
LocalAuthPluginRegisterWithRegistrar(

View File

@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_inappwebview_windows
flutter_secure_storage_windows
flutter_tts
flutter_webrtc
geolocator_windows
local_auth_windows
permission_handler_windows