Fixes & Updates - 2026-06-01: Integrate Back-End v3 updates, fix call/connection issues across apps
This commit is contained in:
@@ -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'
|
||||
|
||||
12
android/app/proguard-rules.pro
vendored
12
android/app/proguard-rules.pro
vendored
@@ -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.**
|
||||
@@ -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>
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
4
android/app/src/main/res/xml/automotive_app_desc.xml
Normal file
4
android/app/src/main/res/xml/automotive_app_desc.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="navigation" />
|
||||
</automotiveApp>
|
||||
@@ -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";
|
||||
|
||||
@@ -110,7 +110,7 @@ class PhoneAuthHelper {
|
||||
}
|
||||
|
||||
/// Verifies the OTP and logs the user in.
|
||||
static Future<void> verifyOtp(String phoneNumber) async {
|
||||
static Future<void> verifyOtp(String phoneNumber, String otpCode) async {
|
||||
try {
|
||||
final fixedPhone = formatSyrianPhone(phoneNumber);
|
||||
Log.print('fixedPhone: $fixedPhone');
|
||||
@@ -118,6 +118,7 @@ class PhoneAuthHelper {
|
||||
link: _verifyOtpUrl,
|
||||
payload: {
|
||||
'phone_number': fixedPhone,
|
||||
'otp': otpCode,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:sefer_driver/controller/home/captin/home_captain_controller.dart
|
||||
import 'package:sefer_driver/views/home/Captin/orderCaptin/order_speed_request.dart';
|
||||
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
|
||||
import 'package:sefer_driver/views/widgets/mydialoug.dart';
|
||||
import 'package:sefer_driver/controller/voice_call_controller.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -75,6 +76,24 @@ class FirebaseMessagesController extends GetxController {
|
||||
await fcmToken.subscribeToTopic("drivers"); // أو "users" حسب نوع المستخدم
|
||||
print("Subscribed to 'drivers' topic ✅");
|
||||
|
||||
FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage? message) async {
|
||||
if (message != null && message.data.isNotEmpty) {
|
||||
Log.print("🔔 FCM getInitialMessage payload: ${message.data}");
|
||||
String? category = message.data['category'] ?? message.data['type'];
|
||||
if (category == 'ORDER' || category == 'Order' || category == 'OrderVIP' || message.data.containsKey('DriverList')) {
|
||||
String? myListString = message.data['DriverList'];
|
||||
if (myListString != null && myListString.isNotEmpty) {
|
||||
await storage.write(key: 'pending_driver_list', value: myListString);
|
||||
Log.print("💾 Saved pending driver list to secure storage from getInitialMessage");
|
||||
}
|
||||
} else {
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
fireBaseTitles(message);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
// If the app is in the background or terminated, show a system tray message
|
||||
RemoteNotification? notification = message.notification;
|
||||
@@ -113,11 +132,22 @@ class FirebaseMessagesController extends GetxController {
|
||||
// if (Platform.isAndroid) {
|
||||
// notificationController.showNotification(title, body, 'order', '');
|
||||
// }
|
||||
|
||||
// 🔥 [Fix FCM-Guard] منع إعاقة الرحلة النشطة بطلبات جديدة عبر FCM
|
||||
String currentRideStatus = box.read(BoxName.rideStatus) ?? '';
|
||||
if (currentRideStatus == 'Begin' ||
|
||||
currentRideStatus == 'Apply' ||
|
||||
currentRideStatus == 'Arrived') {
|
||||
Log.print(
|
||||
"⛔ [FCM] Ignoring ORDER notification — driver has active ride ($currentRideStatus)");
|
||||
break;
|
||||
}
|
||||
|
||||
var myListString = message.data['DriverList'];
|
||||
if (myListString != null) {
|
||||
var myList = jsonDecode(myListString) as List<dynamic>;
|
||||
driverToken = myList[14].toString();
|
||||
Get.put(HomeCaptainController()).changeRideId();
|
||||
Get.put(HomeCaptainController(), permanent: true).changeRideId();
|
||||
update();
|
||||
Get.toNamed('/OrderRequestPage', arguments: {
|
||||
'myListString': myListString,
|
||||
@@ -231,6 +261,20 @@ class FirebaseMessagesController extends GetxController {
|
||||
mySnackbarSuccess("The order has been accepted by another driver.".tr);
|
||||
break;
|
||||
|
||||
case 'incoming_call':
|
||||
case 'INCOMING_CALL':
|
||||
final sessionId = message.data['session_id'];
|
||||
final callerName = message.data['caller_name'];
|
||||
final rideId = message.data['ride_id'];
|
||||
if (sessionId != null && callerName != null && rideId != null) {
|
||||
Get.find<VoiceCallController>().receiveCall(
|
||||
sessionIdVal: sessionId.toString(),
|
||||
remoteNameVal: callerName.toString(),
|
||||
rideIdVal: rideId.toString(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Log.print('Received unhandled notification category: $category');
|
||||
// Optionally show a generic notification
|
||||
|
||||
@@ -104,6 +104,7 @@ class NotificationController extends GetxController {
|
||||
String endLoc = _getVal(data, 30);
|
||||
String paxName = _getVal(data, 8);
|
||||
// String rating = _getVal(data, 33);
|
||||
String isHaveSteps = _getVal(data, 20);
|
||||
|
||||
// تنسيق النص ليكون 4 أسطر واضحة
|
||||
formattedBigText = "👤 $paxName\n"
|
||||
@@ -111,6 +112,10 @@ class NotificationController extends GetxController {
|
||||
"🟢 من: $startLoc\n"
|
||||
"🏁 إلى: $endLoc";
|
||||
|
||||
if (isHaveSteps == 'true') {
|
||||
formattedBigText += "\n🛑 هذه الرحلة تحتوي على نقاط توقف!";
|
||||
}
|
||||
|
||||
summaryText = 'سعر الرحلة: $price';
|
||||
} catch (e) {
|
||||
print("Error formatting notification text: $e");
|
||||
@@ -181,11 +186,16 @@ class NotificationController extends GetxController {
|
||||
final details =
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails);
|
||||
|
||||
String briefBody = "$price - مسافة $formattedBigText";
|
||||
if (_getVal(jsonDecode(myListString), 20) == 'true') {
|
||||
briefBody = "🛑 (متعددة التوقفات) $price - مسافة $formattedBigText";
|
||||
}
|
||||
|
||||
// عرض الإشعار
|
||||
await _flutterLocalNotificationsPlugin.show(
|
||||
id: 1001, // ID ثابت لاستبدال الإشعار القديم
|
||||
title: title,
|
||||
body: "$price - مسافة $formattedBigText", // نص مختصر يظهر في البار العلوي
|
||||
body: briefBody, // نص مختصر يظهر في البار العلوي
|
||||
notificationDetails: details,
|
||||
payload: jsonEncode({
|
||||
'type': 'Order',
|
||||
@@ -298,7 +308,7 @@ class NotificationController extends GetxController {
|
||||
// حماية من الكراش: التأكد من وجود HomeCaptainController
|
||||
if (!Get.isRegistered<HomeCaptainController>()) {
|
||||
print("♻️ Reviving HomeCaptainController...");
|
||||
Get.put(HomeCaptainController());
|
||||
Get.put(HomeCaptainController(), permanent: true);
|
||||
} else {
|
||||
Get.find<HomeCaptainController>().changeRideId();
|
||||
}
|
||||
|
||||
92
lib/controller/functions/app_update_controller.dart
Normal file
92
lib/controller/functions/app_update_controller.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../constant/info.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../constant/colors.dart';
|
||||
import '../../print.dart';
|
||||
import 'crud.dart';
|
||||
|
||||
class AppUpdateController extends GetxController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// الفحص التلقائي عند التشغيل لتحديثات المتجر
|
||||
checkSmartUpdate();
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// الدالة الذكية المدمجة (الآن تفحص المتجر فقط لأن Shorebird يعمل تلقائياً بالخلفية)
|
||||
// ======================================================================
|
||||
Future<void> checkSmartUpdate() async {
|
||||
Log.print("🔄 بدء فحص تحديثات المتجر...");
|
||||
|
||||
// 1. فحص تحديث المتجر (Native Update)
|
||||
await _checkStoreUpdate();
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 1. تحديث المتجر الأساسي
|
||||
// ======================================================================
|
||||
Future<bool> _checkStoreUpdate() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentBuildNumber = packageInfo.buildNumber;
|
||||
|
||||
// استخدام نفس الـ Endpoint والمعايير الموجودة في التطبيق
|
||||
var response = await CRUD().get(link: AppLink.packageInfo, payload: {
|
||||
"platform": Platform.isAndroid ? 'android' : 'ios',
|
||||
"appName": AppInformation.appVersion,
|
||||
});
|
||||
|
||||
if (response != 'failure') {
|
||||
var decoded = jsonDecode(response);
|
||||
if (decoded['status'] == 'success' && decoded['message'] != null && decoded['message'].isNotEmpty) {
|
||||
String latestBuildNumber = decoded['message'][0]['version'].toString();
|
||||
|
||||
// مقارنة الـ Build Number
|
||||
if (latestBuildNumber != currentBuildNumber) {
|
||||
_showStoreUpdateDialog();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print("❌ Store update check error: $e");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// دوال مساعدة
|
||||
// ======================================================================
|
||||
|
||||
void _showStoreUpdateDialog() {
|
||||
final String storeUrl = Platform.isAndroid
|
||||
? 'https://play.google.com/store/apps/details?id=com.intaleq_driver'
|
||||
: 'https://apps.apple.com/jo/app/intaleq-driver/id6482995159';
|
||||
|
||||
Get.defaultDialog(
|
||||
title: "تحديث جديد متوفر".tr,
|
||||
middleText: "يوجد إصدار جديد من التطبيق في المتجر، يرجى التحديث للحصول على الميزات الجديدة.".tr,
|
||||
barrierDismissible: false,
|
||||
onWillPop: () async => false,
|
||||
confirm: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColor.primaryColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))
|
||||
),
|
||||
onPressed: () async {
|
||||
if (await canLaunchUrl(Uri.parse(storeUrl))) {
|
||||
await launchUrl(Uri.parse(storeUrl), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text("تحديث الآن".tr, style: const TextStyle(color: Colors.white)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/controller/functions/audio_recorder_controller.dart
Normal file
80
lib/controller/functions/audio_recorder_controller.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
class AudioRecorderController extends GetxController {
|
||||
AudioPlayer audioPlayer = AudioPlayer();
|
||||
AudioRecorder recorder = AudioRecorder();
|
||||
|
||||
bool isRecording = false;
|
||||
bool isPlaying = false;
|
||||
bool isPaused = false;
|
||||
String filePath = '';
|
||||
String? selectedFilePath;
|
||||
double currentPosition = 0;
|
||||
double totalDuration = 0;
|
||||
|
||||
// Start recording
|
||||
Future<void> startRecording({String? rideId}) async {
|
||||
final bool isPermissionGranted = await recorder.hasPermission();
|
||||
if (!isPermissionGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final String dateStr =
|
||||
'${DateTime.now().year}-${DateTime.now().month.toString().padLeft(2, '0')}-${DateTime.now().day.toString().padLeft(2, '0')}';
|
||||
// Generate a unique file name
|
||||
String fileName = (rideId != null && rideId.isNotEmpty && rideId != 'yet' && rideId != 'null')
|
||||
? '${dateStr}_$rideId.m4a'
|
||||
: '$dateStr.m4a';
|
||||
filePath = '${directory.path}/$fileName';
|
||||
|
||||
const config = RecordConfig(
|
||||
encoder: AudioEncoder.aacLc,
|
||||
sampleRate: 44100,
|
||||
bitRate: 128000,
|
||||
);
|
||||
|
||||
await recorder.start(config, path: filePath);
|
||||
|
||||
isRecording = true;
|
||||
update();
|
||||
}
|
||||
|
||||
// Stop recording
|
||||
Future<void> stopRecording() async {
|
||||
await recorder.stop();
|
||||
isRecording = false;
|
||||
isPaused = false;
|
||||
update();
|
||||
}
|
||||
|
||||
// Get a list of recorded files
|
||||
Future<List<String>> getRecordedFiles() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final files = await directory.list().toList();
|
||||
return files
|
||||
.map((file) => file.path)
|
||||
.where((path) => path.endsWith('.m4a'))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Delete a specific recorded file
|
||||
Future<void> deleteRecordedFile(String filePath) async {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
audioPlayer.dispose();
|
||||
recorder.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'dart:io';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
|
||||
|
||||
void showInBrowser(String url) async {
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
@@ -7,34 +9,42 @@ void showInBrowser(String url) async {
|
||||
} else {}
|
||||
}
|
||||
|
||||
Future<void> makePhoneCall(String phoneNumber) async {
|
||||
String cleanAndFormatPhoneNumber(String phoneNumber) {
|
||||
// 1. Clean the number
|
||||
String formattedNumber = phoneNumber.replaceAll(RegExp(r'\s+'), '');
|
||||
|
||||
// 2. Format logic (Syria/International)
|
||||
// 2. Format logic (Syria/Egypt/International)
|
||||
if (formattedNumber.length > 6) {
|
||||
if (formattedNumber.startsWith('09')) {
|
||||
formattedNumber = '+963${formattedNumber.substring(1)}';
|
||||
} else if (formattedNumber.startsWith('01') && formattedNumber.length == 11) {
|
||||
formattedNumber = '+20${formattedNumber.substring(1)}';
|
||||
} else if (formattedNumber.startsWith('00')) {
|
||||
formattedNumber = '+${formattedNumber.substring(2)}';
|
||||
} else if (!formattedNumber.startsWith('+')) {
|
||||
formattedNumber = '+$formattedNumber';
|
||||
}
|
||||
}
|
||||
return formattedNumber;
|
||||
}
|
||||
|
||||
// 3. Create URI
|
||||
final Uri launchUri = Uri(
|
||||
scheme: 'tel',
|
||||
path: formattedNumber,
|
||||
);
|
||||
Future<void> makePhoneCall(String phoneNumber) async {
|
||||
String formattedNumber = cleanAndFormatPhoneNumber(phoneNumber);
|
||||
|
||||
if (!formattedNumber.startsWith('+963')) {
|
||||
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create URI directly from String to avoid double encoding '+' as '%2B'
|
||||
final Uri launchUri = Uri.parse('tel:$formattedNumber');
|
||||
|
||||
// 4. Execute with externalApplication mode
|
||||
try {
|
||||
// Attempt to launch directly without checking canLaunchUrl first
|
||||
// (Sometimes canLaunchUrl returns false on some devices even if it works)
|
||||
if (!await launchUrl(launchUri, mode: LaunchMode.externalApplication)) {
|
||||
throw 'Could not launch $launchUri';
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback: Try checking canLaunchUrl if the direct launch fails
|
||||
if (await canLaunchUrl(launchUri)) {
|
||||
await launchUrl(launchUri);
|
||||
} else {
|
||||
@@ -45,23 +55,30 @@ Future<void> makePhoneCall(String phoneNumber) async {
|
||||
|
||||
void launchCommunication(
|
||||
String method, String contactInfo, String message) async {
|
||||
String formattedContact = cleanAndFormatPhoneNumber(contactInfo);
|
||||
// WhatsApp prefers the phone number without the '+' prefix
|
||||
String whatsappContact = formattedContact.replaceAll('+', '');
|
||||
String url;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
switch (method) {
|
||||
case 'phone':
|
||||
url = 'tel:$contactInfo';
|
||||
if (!formattedContact.startsWith('+963')) {
|
||||
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
|
||||
return;
|
||||
}
|
||||
url = 'tel:$formattedContact';
|
||||
break;
|
||||
case 'sms':
|
||||
url = 'sms:$contactInfo?body=${Uri.encodeComponent(message)}';
|
||||
url = 'sms:$formattedContact?body=${Uri.encodeComponent(message)}';
|
||||
break;
|
||||
case 'whatsapp':
|
||||
url =
|
||||
'https://api.whatsapp.com/send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
|
||||
'https://api.whatsapp.com/send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
|
||||
break;
|
||||
case 'email':
|
||||
url =
|
||||
'mailto:$contactInfo?subject=Subject&body=${Uri.encodeComponent(message)}';
|
||||
'mailto:$formattedContact?subject=Subject&body=${Uri.encodeComponent(message)}';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -69,27 +86,29 @@ void launchCommunication(
|
||||
} else if (Platform.isAndroid) {
|
||||
switch (method) {
|
||||
case 'phone':
|
||||
url = 'tel:$contactInfo';
|
||||
if (!formattedContact.startsWith('+963')) {
|
||||
mySnackeBarError("Calling non-Syrian numbers is not supported".tr);
|
||||
return;
|
||||
}
|
||||
url = 'tel:$formattedContact';
|
||||
break;
|
||||
case 'sms':
|
||||
url = 'sms:$contactInfo?body=${Uri.encodeComponent(message)}';
|
||||
url = 'sms:$formattedContact?body=${Uri.encodeComponent(message)}';
|
||||
break;
|
||||
case 'whatsapp':
|
||||
// Check if WhatsApp is installed
|
||||
final bool whatsappInstalled =
|
||||
await canLaunchUrl(Uri.parse('whatsapp://'));
|
||||
if (whatsappInstalled) {
|
||||
url =
|
||||
'whatsapp://send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
|
||||
'whatsapp://send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
|
||||
} else {
|
||||
// Provide an alternative action, such as opening the WhatsApp Web API
|
||||
url =
|
||||
'https://api.whatsapp.com/send?phone=$contactInfo&text=${Uri.encodeComponent(message)}';
|
||||
'https://api.whatsapp.com/send?phone=$whatsappContact&text=${Uri.encodeComponent(message)}';
|
||||
}
|
||||
break;
|
||||
case 'email':
|
||||
url =
|
||||
'mailto:$contactInfo?subject=Subject&body=${Uri.encodeComponent(message)}';
|
||||
'mailto:$formattedContact?subject=Subject&body=${Uri.encodeComponent(message)}';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
|
||||
@@ -54,8 +54,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
late final HomeCaptainController _homeCtrl;
|
||||
late final CaptainWalletController _walletCtrl;
|
||||
|
||||
LatLng myLocation = const LatLng(0, 0);
|
||||
double heading = 0.0;
|
||||
LatLng myLocation = LatLng(
|
||||
box.read('last_lat') ?? 0.0,
|
||||
box.read('last_lng') ?? 0.0,
|
||||
);
|
||||
double heading = box.read('last_heading') ?? 0.0;
|
||||
double speed = 0.0;
|
||||
double totalDistance = 0.0;
|
||||
bool _isReady = false;
|
||||
@@ -379,7 +382,23 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
Log.print("Overlay check error: $e");
|
||||
}
|
||||
|
||||
if (Get.currentRoute != '/OrderRequestPage') {
|
||||
// 🔥 [Fix Active-Ride Guard] منع فتح صفحة الطلبات أثناء وجود السائق في رحلة نشطة
|
||||
// هذا يمنع socket event جديد من تعطيل رحلة جارية
|
||||
String? currentRideStatus = box.read(BoxName.rideStatus);
|
||||
bool hasActiveRide = (currentRideStatus == 'Begin' ||
|
||||
currentRideStatus == 'Apply' ||
|
||||
currentRideStatus == 'Arrived');
|
||||
String currentRoute = Get.currentRoute;
|
||||
bool isOnMapPage = currentRoute.contains('MapPage') ||
|
||||
currentRoute.contains('PassengerLocation');
|
||||
|
||||
if (hasActiveRide || isOnMapPage) {
|
||||
Log.print(
|
||||
"⛔ [LocationController] Ignoring new ride request — driver has active ride ($currentRideStatus) or is on map page ($currentRoute).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRoute != '/OrderRequestPage') {
|
||||
Log.print("🚀 Socket: Navigating to OrderRequestPage...");
|
||||
Get.toNamed('/OrderRequestPage', arguments: {
|
||||
'myListString': jsonEncode(driverList),
|
||||
@@ -398,6 +417,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
void _startHeartbeat() {
|
||||
_socketHeartbeat?.cancel();
|
||||
_socketHeartbeat = Timer.periodic(const Duration(seconds: 25), (timer) {
|
||||
// [Fix 6] تخطي الإرسال إذا كان stream الموقع نشطاً.
|
||||
// الـ _locSub يرسل update_location عند كل تحرك (كل 5-10 ثوانٍ) تلقائياً.
|
||||
// الـ heartbeat يكون مفيداً فقط عندما يتوقف الـ stream (الجهاز ثابت أو أوقف الخدمة).
|
||||
if (_locSub != null) return;
|
||||
if (socket != null && isSocketConnected && myLocation.latitude != 0) {
|
||||
emitLocationToSocket(myLocation, heading, speed);
|
||||
}
|
||||
@@ -491,6 +514,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
speed = loc.speed ?? 0.0;
|
||||
heading = loc.heading ?? 0.0;
|
||||
|
||||
box.write('last_lat', pos.latitude);
|
||||
box.write('last_lng', pos.longitude);
|
||||
box.write('last_heading', heading);
|
||||
|
||||
if (_lastPosForDistance != null) {
|
||||
final d = _calculateDistance(_lastPosForDistance!, pos);
|
||||
if (d > 5.0) totalDistance += d;
|
||||
@@ -499,10 +526,25 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
|
||||
update();
|
||||
emitLocationToSocket(pos, heading, speed);
|
||||
|
||||
if (Get.isRegistered<HomeCaptainController>()) {
|
||||
final homeCtrl = Get.find<HomeCaptainController>();
|
||||
if (homeCtrl.isActive &&
|
||||
homeCtrl.mapHomeCaptainController != null &&
|
||||
homeCtrl.isHomeMapActive &&
|
||||
homeCtrl.isMapReadyForCommands) {
|
||||
homeCtrl.mapHomeCaptainController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(pos, 17.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||||
}, onError: (e) => Log.print('❌ Location Stream Error: $e'));
|
||||
}
|
||||
|
||||
Timer? _socketWatchdogTimer;
|
||||
|
||||
Future<void> stopLocationUpdates() async {
|
||||
Log.print("🛑 Stopping Location Updates...");
|
||||
|
||||
@@ -511,11 +553,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
_recordTimer?.cancel();
|
||||
_uploadBatchTimer?.cancel();
|
||||
_socketHeartbeat?.cancel();
|
||||
_socketWatchdogTimer?.cancel();
|
||||
|
||||
if (socket != null) {
|
||||
socket!.clearListeners();
|
||||
socket!
|
||||
.dispose(); // استخدام dispose بدلاً من disconnect لضمان تحرير الموارد على iOS
|
||||
socket!.dispose();
|
||||
}
|
||||
|
||||
if (!Platform.isIOS) {
|
||||
@@ -534,6 +576,7 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
void _startBatchTimers() {
|
||||
_recordTimer?.cancel();
|
||||
_uploadBatchTimer?.cancel();
|
||||
_socketWatchdogTimer?.cancel();
|
||||
|
||||
final recDur =
|
||||
_isPowerSavingMode ? recordIntervalPowerSave : recordIntervalNormal;
|
||||
@@ -544,6 +587,14 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
_recordTimer =
|
||||
Timer.periodic(recDur, (_) => _recordCurrentLocationToBuffer());
|
||||
_uploadBatchTimer = Timer.periodic(upDur, (_) => _flushBufferToServer());
|
||||
|
||||
// محاولة إعادة الاتصال بالسوكيت إذا انقطع كل 3 ثواني
|
||||
_socketWatchdogTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
if (!isSocketConnected && !_isInitializingSocket) {
|
||||
Log.print("🔄 Socket Watchdog: Attempting to reconnect socket...");
|
||||
initSocket();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _recordCurrentLocationToBuffer() {
|
||||
@@ -736,7 +787,30 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
Future<LocationData?> getLocation() async {
|
||||
try {
|
||||
if (await _ensureServiceAndPermission()) {
|
||||
return await location.getLocation();
|
||||
final locData = await location.getLocation();
|
||||
if (locData != null && locData.latitude != null && locData.longitude != null) {
|
||||
myLocation = LatLng(locData.latitude!, locData.longitude!);
|
||||
heading = locData.heading ?? 0.0;
|
||||
speed = locData.speed ?? 0.0;
|
||||
|
||||
box.write('last_lat', myLocation.latitude);
|
||||
box.write('last_lng', myLocation.longitude);
|
||||
box.write('last_heading', heading);
|
||||
|
||||
update();
|
||||
|
||||
if (Get.isRegistered<HomeCaptainController>()) {
|
||||
final homeCtrl = Get.find<HomeCaptainController>();
|
||||
if (homeCtrl.mapHomeCaptainController != null &&
|
||||
homeCtrl.isMapReadyForCommands) {
|
||||
Log.print("📍 [LocationController] Animating camera to single location update");
|
||||
homeCtrl.mapHomeCaptainController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(myLocation, 17.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return locData;
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print('❌ FAILED to get single location: $e');
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sefer_driver/constant/box_name.dart';
|
||||
import 'dart:async';
|
||||
@@ -29,7 +30,7 @@ class HomeCaptainController extends GetxController {
|
||||
Timer? activeTimer;
|
||||
Map data = {};
|
||||
bool isHomeMapActive = true;
|
||||
InlqBitmap carIcon = InlqBitmap.defaultMarker;
|
||||
InlqBitmap carIcon = InlqBitmap.fromAsset('assets/images/car.png');
|
||||
bool isMapReadyForCommands = false;
|
||||
bool isLoading = true;
|
||||
late double kazan = 0;
|
||||
@@ -186,7 +187,8 @@ class HomeCaptainController extends GetxController {
|
||||
_heatmapTimer?.cancel();
|
||||
fetchAndDrawHeatmap();
|
||||
|
||||
_heatmapTimer = Timer.periodic(const Duration(minutes: 5), (timer) {
|
||||
// Refresh every 15 min instead of 5 to reduce data & battery usage
|
||||
_heatmapTimer = Timer.periodic(const Duration(minutes: 15), (timer) {
|
||||
if (isHeatmapVisible) {
|
||||
print("🔄 [Heatmap] Periodic refresh started...");
|
||||
fetchAndDrawHeatmap();
|
||||
@@ -213,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,13 +287,17 @@ 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;
|
||||
box.write('fatigue_total_seconds', totalSeconds);
|
||||
|
||||
if (totalSeconds >= 12 * 3600) { // 12 hours
|
||||
_forceOfflineDueToFatigue();
|
||||
// Increment Fatigue Counter (write to box every 30s)
|
||||
_fatigueSeconds++;
|
||||
if (_fatigueSeconds % 30 == 0) {
|
||||
int totalSeconds =
|
||||
(box.read('fatigue_total_seconds') ?? 0) + _fatigueSeconds;
|
||||
box.write('fatigue_total_seconds', totalSeconds);
|
||||
_fatigueSeconds = 0;
|
||||
if (totalSeconds >= 12 * 3600) {
|
||||
// 12 hours
|
||||
_forceOfflineDueToFatigue();
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -486,6 +497,7 @@ class HomeCaptainController extends GetxController {
|
||||
|
||||
// late IntaleqMapController mapHomeCaptainController;
|
||||
IntaleqMapController? mapHomeCaptainController;
|
||||
LatLng? _lastCameraLoc; // لتتبع آخر موقع حرك الكاميرا
|
||||
|
||||
// --- FIX 2: Smart Map Creation ---
|
||||
void onMapCreated(IntaleqMapController controller) {
|
||||
@@ -504,7 +516,7 @@ class HomeCaptainController extends GetxController {
|
||||
print(
|
||||
"🔥 [HomeCaptain] Safely moving camera to: ${currentLoc.latitude}");
|
||||
mapHomeCaptainController!.moveCamera(
|
||||
CameraUpdate.newLatLngZoom(currentLoc, 15),
|
||||
CameraUpdate.newLatLngZoom(currentLoc, 17.5),
|
||||
);
|
||||
} else {
|
||||
print("🔥 [HomeCaptain] Safely moving to default Damascus");
|
||||
@@ -680,19 +692,30 @@ class HomeCaptainController extends GetxController {
|
||||
checkAndShowBlockDialog();
|
||||
box.write(BoxName.statusDriverLocation, 'off');
|
||||
// 2. عدل الليسنر ليصبح مشروطاً
|
||||
// 2. مؤقت التتبع التلقائي (كل 5 ثوانٍ كما في الكود السابق)
|
||||
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
// Camera follow timer — only moves when the driver has
|
||||
// actually moved > 15 meters, saving GPU/battery on idle.
|
||||
_cameraFollowTimer = Timer.periodic(const Duration(seconds: 8), (timer) {
|
||||
if (isClosed ||
|
||||
!isHomeMapActive ||
|
||||
mapHomeCaptainController == null ||
|
||||
!isMapReadyForCommands ||
|
||||
!isActive) return;
|
||||
|
||||
var loc = locationController.myLocation;
|
||||
if (loc.latitude != 0 && loc.latitude != null && !loc.latitude.isNaN) {
|
||||
// Skip if driver hasn't moved significantly
|
||||
if (_lastCameraLoc != null) {
|
||||
final double dist = Geolocator.distanceBetween(
|
||||
_lastCameraLoc!.latitude,
|
||||
_lastCameraLoc!.longitude,
|
||||
loc.latitude,
|
||||
loc.longitude,
|
||||
);
|
||||
if (dist < 15) return;
|
||||
}
|
||||
_lastCameraLoc = loc;
|
||||
try {
|
||||
// 🔥 Safety double-check before animating
|
||||
if (mapHomeCaptainController != null) {
|
||||
print("🔥 [HomeCaptain] Safely moving camera to: ${loc.latitude}");
|
||||
mapHomeCaptainController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(loc, 17.5),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,14 +219,36 @@ class OrderRequestController extends GetxController
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
Future<void> _calculateFullJourney() async {
|
||||
if (mapController == null) return; // Wait for controller to draw
|
||||
// Don't block on mapController being null - we'll draw routes
|
||||
// and markers first, then zoom when controller is ready
|
||||
bool canZoom = mapController != null;
|
||||
|
||||
try {
|
||||
Position driverPos = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
LatLng driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
|
||||
// Reuse stored location from LocationController instead of
|
||||
// making a duplicate GPS hardware call (already fetched in
|
||||
// _initialMapSetup).
|
||||
LatLng driverLatLng;
|
||||
double driverHeading = 0.0;
|
||||
if (Get.isRegistered<LocationController>()) {
|
||||
final locCtrl = Get.find<LocationController>();
|
||||
if (locCtrl.myLocation.latitude != 0 ||
|
||||
locCtrl.myLocation.longitude != 0) {
|
||||
driverLatLng = locCtrl.myLocation;
|
||||
driverHeading = locCtrl.heading;
|
||||
} else {
|
||||
Position driverPos = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
|
||||
driverHeading = driverPos.heading;
|
||||
}
|
||||
} else {
|
||||
Position driverPos = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
driverLatLng = LatLng(driverPos.latitude, driverPos.longitude);
|
||||
driverHeading = driverPos.heading;
|
||||
}
|
||||
|
||||
updateDriverLocation(driverLatLng, driverPos.heading);
|
||||
updateDriverLocation(driverLatLng, driverHeading);
|
||||
|
||||
// Clear old polylines to avoid "ghost lines"
|
||||
polylines.clear();
|
||||
@@ -240,9 +262,9 @@ class OrderRequestController extends GetxController
|
||||
var tripFuture = _fetchRouteData(
|
||||
start: LatLng(latPassenger, lngPassenger),
|
||||
end: LatLng(latDestination, lngDestination),
|
||||
color: Colors.green,
|
||||
color: Colors.black,
|
||||
id: 'trip_route',
|
||||
isDashed: true);
|
||||
getSteps: true); // 🔥 نطلب الخطوات للمسار
|
||||
|
||||
var results = await Future.wait([pickupFuture, tripFuture]);
|
||||
|
||||
@@ -259,6 +281,11 @@ class OrderRequestController extends GetxController
|
||||
totalTripDistance = tripResult['distance_text'];
|
||||
totalTripDuration = tripResult['duration_text'];
|
||||
polylines.add(tripResult['polyline']);
|
||||
|
||||
// 🔥 تخزين استجابة السيرفر كاملة (بما فيها الـ points والـ instructions)
|
||||
if (tripResult['raw_response'] != null) {
|
||||
box.write('cached_trip_route', tripResult['raw_response']);
|
||||
}
|
||||
}
|
||||
|
||||
await _updateMarkers(
|
||||
@@ -267,8 +294,10 @@ class OrderRequestController extends GetxController
|
||||
destTime: totalTripDuration,
|
||||
destDist: totalTripDistance);
|
||||
|
||||
// Now zoom to fit all polylines and markers
|
||||
zoomToFitRide();
|
||||
// Now zoom to fit all polylines and markers (if controller available)
|
||||
if (canZoom) {
|
||||
zoomToFitRide();
|
||||
}
|
||||
|
||||
update();
|
||||
} catch (e) {
|
||||
@@ -297,18 +326,19 @@ class OrderRequestController extends GetxController
|
||||
required LatLng end,
|
||||
required Color color,
|
||||
required String id,
|
||||
bool isDashed = false}) async {
|
||||
bool getSteps = false}) async {
|
||||
try {
|
||||
if (start.latitude == 0 || end.latitude == 0) return null;
|
||||
if (mapController == null) return null;
|
||||
// Don't block on mapController — route data fetch is independent
|
||||
|
||||
final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: {
|
||||
'fromLat': start.latitude.toString(),
|
||||
'fromLng': start.longitude.toString(),
|
||||
'toLat': end.latitude.toString(),
|
||||
'toLng': end.longitude.toString(),
|
||||
'steps': 'false',
|
||||
'steps': getSteps ? 'true' : 'false',
|
||||
'alternatives': 'false',
|
||||
'locale': 'ar',
|
||||
});
|
||||
|
||||
final response = await http.get(saasUrl, headers: {
|
||||
@@ -347,7 +377,9 @@ class OrderRequestController extends GetxController
|
||||
return {
|
||||
'distance_text': distanceText,
|
||||
'duration_text': durationText,
|
||||
'polyline': polyline
|
||||
'polyline': polyline,
|
||||
'encoded_polyline': encodedPoints,
|
||||
'raw_response': response.body, // 🔥 نمرر الـ JSON كاملاً
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
212
lib/controller/home/captin/v2_review_delta.html
Normal file
212
lib/controller/home/captin/v2_review_delta.html
Normal file
@@ -0,0 +1,212 @@
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
.wrap { padding: 1.25rem 1rem; font-size: 14px; color: var(--color-text-primary); direction: rtl; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 3px; }
|
||||
.sub { font-size: 13px; color: var(--color-text-secondary); margin: 0 0 1.25rem; }
|
||||
.badge { display: inline-flex; align-items: center; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 20px; white-space: nowrap; }
|
||||
.b-ok { background: var(--color-background-success); color: var(--color-text-success); }
|
||||
.b-new { background: var(--color-background-danger); color: var(--color-text-danger); }
|
||||
.b-med { background: var(--color-background-warning); color: var(--color-text-warning); }
|
||||
.b-min { background: var(--color-background-info); color: var(--color-text-info); }
|
||||
.progress-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 1.25rem; }
|
||||
.pcard { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 12px 14px; }
|
||||
.pcard .val { font-size: 28px; font-weight: 500; }
|
||||
.pcard .lbl { font-size: 12px; color: var(--color-text-secondary); margin-top: 2px; }
|
||||
.ok-val { color: var(--color-text-success); }
|
||||
.bad-val { color: var(--color-text-danger); }
|
||||
.new-val { color: var(--color-text-warning); }
|
||||
.section { margin-bottom: 1.4rem; }
|
||||
.section-hdr { font-size: 14px; font-weight: 500; margin: 0 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); margin-bottom: 8px; overflow: hidden; }
|
||||
.card.fixed { border-right: 3px solid var(--color-border-success); }
|
||||
.card.broken{ border-right: 3px solid var(--color-border-danger); }
|
||||
.card.new { border-right: 3px solid var(--color-border-warning); }
|
||||
.card.minor { border-right: 3px solid var(--color-border-info); }
|
||||
.ch { display: flex; align-items: flex-start; gap: 8px; padding: 10px 14px; cursor: pointer; }
|
||||
.ch:hover { background: var(--color-background-secondary); }
|
||||
.ch-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
|
||||
.ch-title { font-size: 13.5px; font-weight: 500; flex: 1; line-height: 1.4; }
|
||||
.ch-badge { flex-shrink: 0; }
|
||||
.chev { font-size: 11px; color: var(--color-text-tertiary); transition: transform .2s; margin-right: auto; margin-left: 4px; }
|
||||
.chev.open { transform: rotate(90deg); }
|
||||
.cb { display: none; padding: 0 14px 14px; border-top: 0.5px solid var(--color-border-tertiary); }
|
||||
.cb.open { display: block; }
|
||||
.cb p { font-size: 13px; color: var(--color-text-secondary); line-height: 1.7; margin: 8px 0 6px; }
|
||||
pre { font-family: var(--font-mono); font-size: 11.5px; background: var(--color-background-tertiary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 9px 11px; overflow-x: auto; margin: 6px 0; line-height: 1.6; white-space: pre; }
|
||||
.fix { background: var(--color-background-success); border-radius: var(--border-radius-md); padding: 8px 11px; margin-top: 8px; font-size: 13px; line-height: 1.6; }
|
||||
.fix strong { color: var(--color-text-success); font-size: 11px; display: block; margin-bottom: 2px; }
|
||||
.warn { background: var(--color-background-warning); border-radius: var(--border-radius-md); padding: 8px 11px; margin-top: 8px; font-size: 13px; line-height: 1.6; }
|
||||
.warn strong { color: var(--color-text-warning); font-size: 11px; display: block; margin-bottom: 2px; }
|
||||
code { font-family: var(--font-mono); font-size: 12px; background: var(--color-background-secondary); padding: 0 4px; border-radius: 3px; }
|
||||
.score-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 7px; }
|
||||
.score-lbl { min-width: 160px; color: var(--color-text-secondary); }
|
||||
.strack { flex: 1; height: 6px; background: var(--color-border-tertiary); border-radius: 3px; position: relative; }
|
||||
.sfill { height: 100%; border-radius: 3px; }
|
||||
.sval { min-width: 36px; font-size: 12px; color: var(--color-text-secondary); text-align: left; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<h1>مراجعة النسخة المحدّثة — V2</h1>
|
||||
<p class="sub">مقارنة مع المراجعة السابقة · 16 مشكلة فُحصت</p>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="pcard"><div class="val ok-val">11</div><div class="lbl">مشكلة مُصلحة ✅</div></div>
|
||||
<div class="pcard"><div class="val bad-val">2</div><div class="lbl">مشكلة جديدة أدخلتها الإصلاحات ⚠️</div></div>
|
||||
<div class="pcard"><div class="val new-val">3</div><div class="lbl">مشكلة لم تُعالج بعد</div></div>
|
||||
<div class="pcard"><div class="val ok-val">69%</div><div class="lbl">تحسن من المراجعة الأولى</div></div>
|
||||
</div>
|
||||
|
||||
<div class="score-row"><span class="score-lbl">صحة المنطق البرمجي</span><div class="strack"><div class="sfill" style="width:72%;background:#3B8BD4"></div></div><span class="sval">72% ↑</span></div>
|
||||
<div class="score-row"><span class="score-lbl">نظافة الكود</span><div class="strack"><div class="sfill" style="width:63%;background:#1D9E75"></div></div><span class="sval">63% ↑</span></div>
|
||||
<div class="score-row" style="margin-bottom:1.4rem"><span class="score-lbl">قابلية الصيانة</span><div class="strack"><div class="sfill" style="width:58%;background:#1D9E75"></div></div><span class="sval">58% ↑</span></div>
|
||||
|
||||
<!-- FIXED -->
|
||||
<div class="section">
|
||||
<div class="section-hdr"><span class="badge b-ok">✅ مُصلح</span> ما تم إصلاحه بشكل صحيح</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">C-1 — استبدال الحلقة التكرارية والاستدعاء الذاتي بـ <code>Timer.periodic</code></span><span class="ch-badge badge b-ok">ممتاز</span><span class="chev">▶</span></div>
|
||||
<div class="cb"><p>تم حذف <code>updateLocation()</code> كاملاً واستبدالها بـ <code>startUpdateLocationTimer()</code> و <code>stopUpdateLocationTimer()</code>. التايمر مسجّل في <code>onClose()</code> و <code>_stopAllServices()</code>. إصلاح ممتاز.</p>
|
||||
<div class="warn"><strong>ملاحظة مهمة</strong>لا يظهر في الكود استدعاء لـ <code>startUpdateLocationTimer()</code> من أي مكان. يجب التأكد أنها تُستدعى من الـ View أو من <code>startRideFromDriver()</code>.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">C-4 — تحديث <code>myLocation</code> في <code>_handleLocationUpdate()</code></span><span class="chev">▶</span></div>
|
||||
<div class="cb"><pre>void _handleLocationUpdate(geo.Position pos) {
|
||||
final newLoc = LatLng(pos.latitude, pos.longitude);
|
||||
myLocation = newLoc; // ← [Fix C-4] ✅ صحيح
|
||||
// ...</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">M-4 — دمج <code>checkForNextStep()</code> مع <code>_checkNavigationStep()</code></span><span class="chev">▶</span></div>
|
||||
<div class="cb"><p><code>checkForNextStep</code> أصبحت wrapper بسيط يستدعي <code>_checkNavigationStep</code>. منطق واحد، لا تعارض.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">M-5 — <code>disposeEverything()</code> لا تستدعي <code>onClose()</code> يدوياً</span><span class="chev">▶</span></div>
|
||||
<div class="cb"><pre>void disposeEverything() {
|
||||
_stopAllServices(); // ✅ بدون onClose()
|
||||
}</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">C-3 جزئي — دالة مساعدة <code>_parseDistanceToMeters()</code> مشتركة</span><span class="chev">▶</span></div>
|
||||
<div class="cb"><p>تم استخراج منطق تحليل المسافة إلى دالة واحدة تستخدمها كلا <code>finishRideFromDriver()</code> و <code>_validateTripDistance()</code>. يحل مشكلة التضارب في الوحدات.</p>
|
||||
<div class="warn"><strong>لم يُحل كاملاً</strong>التحقق من المسافة لا يزال يحدث مرتين (انظر مشكلة C-3 أدناه).</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card fixed">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">✅</span><span class="ch-title">M-1 + M-2 + M-6 + N-1 + N-5 — إصلاحات طفيفة متعددة</span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<p><strong>M-1:</strong> <code>jitterMeters</code> → <code>jitterKm = 0.01</code> ✅</p>
|
||||
<p><strong>M-2:</strong> <code>distance</code> المحلية → <code>distToPassenger</code> ✅</p>
|
||||
<p><strong>M-6:</strong> تعليق يوضح أن الوحدة كيلومتر ✅</p>
|
||||
<p><strong>N-1:</strong> <code>&directionsmode</code> → <code>?directionsmode</code> ✅</p>
|
||||
<p><strong>N-5:</strong> إضافة <code>update()</code> في <code>getLocationArea()</code> ✅</p>
|
||||
<p><strong>M-3:</strong> حذف <code>_performanceReadings</code> والمتغيرات الميتة ✅</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEW BUGS -->
|
||||
<div class="section">
|
||||
<div class="section-hdr"><span class="badge b-new">🚨 جديد</span> مشاكل أدخلتها الإصلاحات</div>
|
||||
|
||||
<div class="card new">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">🚨</span><span class="ch-title">BUG جديد — <code>Completer</code> في C-2 يُسبب Deadlock عند إغلاق الديالوج بـ Back</span><span class="ch-badge badge b-new">حرج</span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<p>الإصلاح استخدم <code>Completer</code> بشكل صحيح لحل مشكلة الـ callback الآني، لكنه أدخل مشكلة أخرى: لو أغلق المستخدم الديالوج بزر الرجوع (Back) في Android بدون ضغط OK، فإن <code>completer.future</code> لن تكتمل أبداً، والدالة ستبقى معلّقة (deadlock) لأن <code>_validateTripDistance()</code> هي <code>async</code> وتنتظر نتيجة لن تأتي:</p>
|
||||
<pre>final completer = Completer<bool>();
|
||||
MyDialog().getDialog('Exit Ride?'.tr, '', () {
|
||||
if (!completer.isCompleted) completer.complete(true);
|
||||
Get.back();
|
||||
});
|
||||
return await completer.future; // ← ينتظر للأبد إذا أُغلق بـ Back</pre>
|
||||
<div class="fix"><strong>الحل</strong>أضف <code>barrierDismissible: false</code> للديالوج، أو استخدم <code>completer.complete(false)</code> عند إغلاق الديالوج بدون تأكيد (عبر <code>WillPopScope</code> أو <code>onDismissed</code> callback في <code>MyDialog</code>).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card new">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">🚨</span><span class="ch-title">C-3 لا يزال — المستخدم يرى ديالوجَي تأكيد متتاليَين عند إنهاء الرحلة بالزر</span><span class="ch-badge badge b-new">حرج</span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<p>رغم إضافة <code>_parseDistanceToMeters()</code>، تدفق الكود لا يزال يُقدّم ديالوجَين:</p>
|
||||
<pre>// finishRideFromDriver(isFromSlider: false):
|
||||
MyDialog().getDialog('Are you sure to exit ride?', '', () {
|
||||
Get.back();
|
||||
finishRideFromDriver1(); // ← isFromSlider = false افتراضياً
|
||||
});
|
||||
|
||||
// finishRideFromDriver1():
|
||||
if (!await _validateTripDistance(false)) return; // ← يُقدّم ديالوجاً ثانياً!</pre>
|
||||
<p>المستخدم يرى "هل أنت متأكد؟" → يضغط OK → يرى "Exit Ride?" مرة ثانية → ينتظر مجدداً.</p>
|
||||
<div class="fix"><strong>الحل</strong>احذف الديالوج من <code>finishRideFromDriver()</code> وأبقه في <code>_validateTripDistance()</code> فقط. أو مرّر <code>isFromSlider: true</code> لما يأتي من موافقة مسبقة.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REMAINING -->
|
||||
<div class="section">
|
||||
<div class="section-hdr"><span class="badge b-med">⚠️ لم تُعالج</span> مشاكل لا تزال قائمة</div>
|
||||
|
||||
<div class="card broken">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">M-7 — Null checks على <code>String</code> غير قابلة للـ null</span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<pre>if (isSocialPressed == true && passengerId != null && rideId != null) {
|
||||
// ^^^^^^^^^^^ دائماً non-null</pre>
|
||||
<p>لو <code>passengerId == ''</code> يمر الشرط ويُرسل بيانات فارغة للسيرفر. الفحص الصحيح: <code>passengerId.isNotEmpty && rideId.isNotEmpty</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card broken">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">N-2 — تأخير 1 ثانية Hardcoded في <code>argumentLoading()</code></span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<pre>await Future.delayed(const Duration(seconds: 1));
|
||||
await getRoute(...);</pre>
|
||||
<p>لا يزال موجوداً. Race condition يجب معالجته بـ <code>Completer</code> بدلاً من تخمين الوقت.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card broken">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">⚠️</span><span class="ch-title">N-4 — <code>step0</code> إلى <code>step4</code> بدلاً من <code>List<String></code></span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<pre>String step0 = ''; String step1 = ''; // ...
|
||||
step0 = Get.arguments['step0']?.toString() ?? '';
|
||||
step1 = Get.arguments['step1']?.toString() ?? '';</pre>
|
||||
<p>لا تزال 5 متغيرات منفصلة. <code>List<String> steps = List.filled(5, '')</code> أوضح وأسهل في المعالجة.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STILL MINOR -->
|
||||
<div class="section">
|
||||
<div class="section-hdr"><span class="badge b-min">ℹ️ بسيطة</span> ملاحظات إضافية على هذه النسخة</div>
|
||||
|
||||
<div class="card minor">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">ℹ️</span><span class="ch-title"><code>_suggestOptimization()</code> لا تزال موجودة لكن لا يستدعيها أحد</span><span class="chev">▶</span></div>
|
||||
<div class="cb"><p>بعد حذف <code>_performanceReadings</code> و <code>_analyzePerformance()</code>، بقيت <code>_suggestOptimization()</code> معزولة. إما أن تُستدعى من مكان ما أو تُحذف.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card minor">
|
||||
<div class="ch" onclick="t(this)"><span class="ch-icon">ℹ️</span><span class="ch-title">الاستيرادات المكررة لـ <code>dart:math</code> و <code>geolocator</code> لا تزال</span><span class="chev">▶</span></div>
|
||||
<div class="cb">
|
||||
<pre>import 'dart:math';
|
||||
import 'dart:math' as math; // مكرر
|
||||
import 'package:geolocator/geolocator.dart' as geo;
|
||||
import 'package:geolocator/geolocator.dart'; // مكرر</pre>
|
||||
<p>يُسبب تحذيرات من المحلل ويُشوّش قراءة الكود. احذف النسخة غير المعرّفة.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function t(header) {
|
||||
const b = header.nextElementSibling;
|
||||
const ch = header.querySelector('.chev');
|
||||
const o = b.classList.contains('open');
|
||||
b.classList.toggle('open', !o);
|
||||
if (ch) ch.classList.toggle('open', !o);
|
||||
}
|
||||
</script>
|
||||
@@ -93,13 +93,13 @@ class NavigationController extends GetxController
|
||||
String totalDistanceRemaining = "";
|
||||
String estimatedTimeRemaining = "";
|
||||
dynamic currentManeuverModifier = 0;
|
||||
String arrivalTime = "--:--";
|
||||
String arrivalTime = "--:--"; // NEW: For the active navigation HUD
|
||||
|
||||
double _routeTotalDistanceM = 0;
|
||||
double _routeTotalDurationS = 0;
|
||||
|
||||
bool isNavigating = false;
|
||||
bool isMuted = false;
|
||||
bool isMuted = false; // Sound toggle state
|
||||
String distanceWithUnit = "";
|
||||
bool _cameraLockedToUser = true;
|
||||
bool _mapReady = false;
|
||||
@@ -114,6 +114,7 @@ class NavigationController extends GetxController
|
||||
Future<void> submitNewPlace(String name, String category) async {
|
||||
if (mapController == null || name.isEmpty || category.isEmpty) return;
|
||||
|
||||
// Get current center of the map as the picked location
|
||||
final LatLng pickedPos = mapController!.cameraPosition!.target;
|
||||
|
||||
isLoading = true;
|
||||
@@ -139,15 +140,21 @@ class NavigationController extends GetxController
|
||||
isLoading = false;
|
||||
if (response != null) {
|
||||
HapticFeedback.lightImpact();
|
||||
mySnackbarSuccess('Place added successfully! Thanks for your contribution.'.tr);
|
||||
mySnackbarSuccess(box.read(BoxName.lang) == 'ar'
|
||||
? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.'
|
||||
: 'Place added successfully! Thanks for your contribution.');
|
||||
isSelectingPlaceLocation = false;
|
||||
} else {
|
||||
mySnackbarWarning('Failed to add place. Please try again later.'.tr);
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
|
||||
? 'تعذر إضافة المكان. يرجى المحاولة لاحقاً.'
|
||||
: 'Failed to add place. Please try again later.');
|
||||
}
|
||||
update();
|
||||
} catch (e) {
|
||||
isLoading = false;
|
||||
mySnackbarWarning('An error occurred while connecting to the server.'.tr);
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
|
||||
? 'حدث خطأ أثناء الاتصال بالخادم.'
|
||||
: 'An error occurred while connecting to the server.');
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -181,6 +188,7 @@ class NavigationController extends GetxController
|
||||
return 55.0;
|
||||
}
|
||||
|
||||
// Categories list for the picker
|
||||
static final List<Map<String, String>> placeCategories = [
|
||||
{
|
||||
'id': 'restaurant',
|
||||
@@ -303,6 +311,7 @@ class NavigationController extends GetxController
|
||||
_smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
|
||||
|
||||
if (isStyleLoaded) {
|
||||
_updateCarMarker();
|
||||
if (_cameraLockedToUser) {
|
||||
animateCameraToPosition(myLocation!,
|
||||
bearing: _smoothedHeading,
|
||||
@@ -365,7 +374,6 @@ class NavigationController extends GetxController
|
||||
void onMapCreated(IntaleqMapController controller) async {
|
||||
Log.print("DEBUG: NavigationController.onMapCreated called");
|
||||
mapController = controller;
|
||||
await onStyleLoaded();
|
||||
}
|
||||
|
||||
Future<void> onStyleLoaded() async {
|
||||
@@ -381,6 +389,7 @@ class NavigationController extends GetxController
|
||||
if (myLocation != null) {
|
||||
Log.print("DEBUG: Animating camera to initial location: $myLocation");
|
||||
animateCameraToPosition(myLocation!);
|
||||
_updateCarMarker();
|
||||
}
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
Log.print("DEBUG: Updating initial polylines");
|
||||
@@ -394,7 +403,7 @@ class NavigationController extends GetxController
|
||||
if (isNavigating || routes.isEmpty) return;
|
||||
|
||||
int? bestIndex;
|
||||
double minDistance = 100.0;
|
||||
double minDistance = 100.0; // 100 meters threshold for tap
|
||||
|
||||
for (int i = 0; i < routes.length; i++) {
|
||||
for (var coord in routes[i].coordinates) {
|
||||
@@ -422,12 +431,12 @@ class NavigationController extends GetxController
|
||||
Get.dialog(
|
||||
AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Text('Start Navigation?'.tr,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: Text('Do you want to go to this location?'.tr),
|
||||
title: const Text('بدء الملاحة؟',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: const Text('هل تريد الذهاب إلى هذا الموقع؟'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('Cancel'.tr, style: const TextStyle(color: Colors.grey)),
|
||||
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
|
||||
onPressed: () => Get.back()),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -435,10 +444,10 @@ class NavigationController extends GetxController
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12))),
|
||||
child:
|
||||
Text('Go Now'.tr, style: const TextStyle(color: Colors.white)),
|
||||
const Text('اذهب الآن', style: TextStyle(color: Colors.white)),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
startNavigationTo(tappedPoint, infoWindowTitle: 'Selected Location'.tr);
|
||||
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -458,6 +467,7 @@ class NavigationController extends GetxController
|
||||
_smoothedHeading = position.heading;
|
||||
update();
|
||||
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
||||
// Start the Location Stream for real-time updates
|
||||
_startLocationStream();
|
||||
_startBatchTimers();
|
||||
} catch (e) {
|
||||
@@ -467,10 +477,12 @@ class NavigationController extends GetxController
|
||||
|
||||
void _startLocationStream() {
|
||||
_locationStreamSubscription?.cancel();
|
||||
// Listen to location updates with minimum distance filter of 2 meters
|
||||
// This provides real-time updates without the 3-4 second delay
|
||||
_locationStreamSubscription = Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 2,
|
||||
distanceFilter: 2, // Update every 2 meters
|
||||
),
|
||||
).listen(
|
||||
(Position position) {
|
||||
@@ -489,8 +501,9 @@ class NavigationController extends GetxController
|
||||
|
||||
try {
|
||||
final newLoc = LatLng(position.latitude, position.longitude);
|
||||
currentSpeed = position.speed * 3.6;
|
||||
currentSpeed = position.speed * 3.6; // Convert m/s to km/h
|
||||
|
||||
// Skip if movement is too small
|
||||
if (_lastProcessedLocation != null) {
|
||||
final d = Geolocator.distanceBetween(
|
||||
newLoc.latitude,
|
||||
@@ -507,6 +520,7 @@ class NavigationController extends GetxController
|
||||
Log.print(
|
||||
"DEBUG: Location update - Speed: ${currentSpeed.toStringAsFixed(1)} km/h, Loc: $newLoc");
|
||||
|
||||
// Update total distance
|
||||
if (_lastDistanceLocation != null) {
|
||||
final d = Geolocator.distanceBetween(
|
||||
_lastDistanceLocation!.latitude,
|
||||
@@ -536,6 +550,8 @@ class NavigationController extends GetxController
|
||||
_animController?.forward(from: 0.0);
|
||||
_lastProcessedLocation = newLoc;
|
||||
|
||||
if (isStyleLoaded) _updateCarMarker();
|
||||
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
_updateTraveledPolylineSmart(newLoc);
|
||||
_checkNavigationStep(newLoc);
|
||||
@@ -556,7 +572,7 @@ class NavigationController extends GetxController
|
||||
}
|
||||
|
||||
void _checkOffRoute(LatLng pos) {
|
||||
if (_autoRecalcInProgress || isLoading) return;
|
||||
if (!isNavigating || _autoRecalcInProgress || isLoading) return;
|
||||
if (_fullRouteCoordinates.isEmpty) return;
|
||||
|
||||
const int searchWindow = 80;
|
||||
@@ -591,33 +607,11 @@ class NavigationController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
/// Recalculate immediately from the latest GPS point to the destination.
|
||||
Future<void> _smartRecalculateRoute(LatLng currentPos) async {
|
||||
try {
|
||||
if (routes.isNotEmpty && selectedRouteIndex < routes.length - 1) {
|
||||
final nextIndex = selectedRouteIndex + 1;
|
||||
final nextRoute = routes[nextIndex];
|
||||
|
||||
double minDist = double.infinity;
|
||||
for (var coord in nextRoute.coordinates) {
|
||||
final d = Geolocator.distanceBetween(
|
||||
currentPos.latitude,
|
||||
currentPos.longitude,
|
||||
coord.latitude,
|
||||
coord.longitude,
|
||||
);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
if (minDist < 100) {
|
||||
selectRoute(nextIndex);
|
||||
Log.print("DEBUG: Switched to alternative route due to deviation");
|
||||
_autoRecalcInProgress = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_finalDestination != null) {
|
||||
await recalculateRoute();
|
||||
await recalculateRoute(origin: currentPos, keepNavigationActive: true);
|
||||
}
|
||||
_autoRecalcInProgress = false;
|
||||
} catch (e) {
|
||||
@@ -669,13 +663,13 @@ class NavigationController extends GetxController
|
||||
if (_trackBuffer.isEmpty) return;
|
||||
final batch = List<Map<String, dynamic>>.from(_trackBuffer);
|
||||
_trackBuffer.clear();
|
||||
final String driverId = (box.read(BoxName.driverID) ?? '').toString();
|
||||
final String passengerId = (box.read(BoxName.passengerID) ?? '').toString();
|
||||
|
||||
try {
|
||||
await CRUD().post(
|
||||
link: '${AppLink.locationServerSide}/add_batch.php',
|
||||
payload: {
|
||||
'driver_id': driverId,
|
||||
'driver_id': passengerId,
|
||||
'batch_data': jsonEncode(batch),
|
||||
'session_dist': totalDistance.toStringAsFixed(1),
|
||||
},
|
||||
@@ -685,6 +679,10 @@ class NavigationController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateCarMarker() async {
|
||||
// Car marker is now handled natively by myLocationEnabled: true.
|
||||
}
|
||||
|
||||
void animateCameraToPosition(LatLng position,
|
||||
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
|
||||
if (!_mapReady || mapController == null) return;
|
||||
@@ -774,8 +772,11 @@ class NavigationController extends GetxController
|
||||
|
||||
Future<void> _updatePolylinesSets(
|
||||
List<LatLng> traveled, List<LatLng> remaining) async {
|
||||
Log.print(
|
||||
"DEBUG: Updating polylines. Traveled: ${traveled.length}, Remaining: ${remaining.length}");
|
||||
Set<Polyline> newPolylines = {};
|
||||
|
||||
// Render Alternative Routes first
|
||||
for (int i = 0; i < routes.length; i++) {
|
||||
if (i == selectedRouteIndex) continue;
|
||||
newPolylines.add(Polyline(
|
||||
@@ -840,7 +841,7 @@ class NavigationController extends GetxController
|
||||
if (dest != null && myLocation != null) {
|
||||
getRoute(myLocation!, dest);
|
||||
} else {
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'الموقع غير متاح حالياً.' : 'Location not available.');
|
||||
mySnackbarWarning('الموقع غير متاح حالياً.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,12 +866,13 @@ class NavigationController extends GetxController
|
||||
LatLng getAirportLatLng() {
|
||||
final String country = box.read(BoxName.countryCode) ?? 'JO';
|
||||
if (country == 'SY') {
|
||||
return const LatLng(33.4111, 36.5147);
|
||||
return const LatLng(33.4111, 36.5147); // Damascus Airport
|
||||
}
|
||||
return const LatLng(31.7225, 35.9933);
|
||||
return const LatLng(31.7225, 35.9933); // Queen Alia Airport (JO)
|
||||
}
|
||||
|
||||
Future<void> getRoute(LatLng origin, LatLng destination) async {
|
||||
Future<void> getRoute(LatLng origin, LatLng destination,
|
||||
{bool keepNavigationActive = false}) async {
|
||||
isLoading = true;
|
||||
update();
|
||||
|
||||
@@ -899,12 +901,13 @@ class NavigationController extends GetxController
|
||||
if (response.statusCode != 200) {
|
||||
isLoading = false;
|
||||
update();
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'تعذر الاتصال بخدمة التوجيه.' : 'Failed to connect to routing service.');
|
||||
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
|
||||
return;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
// ── Parse primary route (top-level in response) ──
|
||||
routes.clear();
|
||||
final primaryPts = data['points']?.toString() ?? "";
|
||||
if (primaryPts.isNotEmpty) {
|
||||
@@ -919,8 +922,10 @@ class NavigationController extends GetxController
|
||||
));
|
||||
}
|
||||
|
||||
// ── Parse alternative routes (in data['alternatives']) ──
|
||||
// إذا كان هناك routes بديلة متاحة من API
|
||||
if (data['alternatives'] != null && data['alternatives'] is List) {
|
||||
_hasAlternativeRoutes = (data['alternatives'] as List).isNotEmpty;
|
||||
_hasAlternativeRoutes = data['alternatives'].isNotEmpty;
|
||||
for (var alt in data['alternatives']) {
|
||||
final altPts = alt['points']?.toString() ?? "";
|
||||
if (altPts.isEmpty) continue;
|
||||
@@ -934,6 +939,9 @@ class NavigationController extends GetxController
|
||||
points: altPts,
|
||||
));
|
||||
}
|
||||
if (_hasAlternativeRoutes) {
|
||||
Log.print("DEBUG: ${routes.length - 1} alternative routes available");
|
||||
}
|
||||
} else {
|
||||
_hasAlternativeRoutes = false;
|
||||
}
|
||||
@@ -941,7 +949,7 @@ class NavigationController extends GetxController
|
||||
if (routes.isEmpty) {
|
||||
isLoading = false;
|
||||
update();
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'لم يتم العثور على مسار.' : 'No route found.');
|
||||
mySnackbarWarning('لم يتم العثور على مسار.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -965,8 +973,8 @@ class NavigationController extends GetxController
|
||||
currentStepIndex = 0;
|
||||
_nextInstructionSpoken = false;
|
||||
|
||||
isNavigating = false;
|
||||
_cameraLockedToUser = false;
|
||||
isNavigating = keepNavigationActive;
|
||||
_cameraLockedToUser = keepNavigationActive;
|
||||
_offRouteStartTime = null;
|
||||
isLoading = false;
|
||||
|
||||
@@ -986,7 +994,13 @@ class NavigationController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
if (_fullRouteCoordinates.length >= 2) {
|
||||
// Re-add car marker after polyline updates (ensures it stays on top)
|
||||
if (isStyleLoaded) _updateCarMarker();
|
||||
|
||||
if (keepNavigationActive && myLocation != null) {
|
||||
animateCameraToPosition(myLocation!,
|
||||
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
|
||||
} else if (_fullRouteCoordinates.length >= 2) {
|
||||
final bounds =
|
||||
data['bbox'] != null && (data['bbox'] as List).length == 4
|
||||
? LatLngBounds(
|
||||
@@ -1012,17 +1026,22 @@ class NavigationController extends GetxController
|
||||
final remainingM = _routeTotalDistanceM * fraction;
|
||||
final remainingS = _routeTotalDurationS * fraction;
|
||||
|
||||
// Distance
|
||||
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
if (remainingM > 1000) {
|
||||
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
|
||||
// We will handle the unit in the view or provide a unit string here
|
||||
} else {
|
||||
totalDistanceRemaining = remainingM.toStringAsFixed(0);
|
||||
}
|
||||
// New variable to hold formatted distance with unit
|
||||
distanceWithUnit = _formatDistance(remainingM, langCode);
|
||||
|
||||
// Time Remaining
|
||||
final minutes = (remainingS / 60).round();
|
||||
estimatedTimeRemaining = minutes.toString();
|
||||
|
||||
// Arrival Time Calculation
|
||||
final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt()));
|
||||
final h = arrival.hour > 12
|
||||
? arrival.hour - 12
|
||||
@@ -1040,6 +1059,7 @@ class NavigationController extends GetxController
|
||||
_finalDestination = destination;
|
||||
await clearRoute(isNewRoute: true);
|
||||
|
||||
// Preserve car marker if it exists
|
||||
markers = markers.where((m) => m.markerId.value == 'car').toSet();
|
||||
|
||||
markers.add(Marker(
|
||||
@@ -1065,12 +1085,23 @@ class NavigationController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recalculateRoute() async {
|
||||
if (myLocation == null || _finalDestination == null || isLoading) return;
|
||||
Future<void> recalculateRoute(
|
||||
{LatLng? origin, bool keepNavigationActive = false}) async {
|
||||
final LatLng? routeOrigin = origin ?? myLocation;
|
||||
if (routeOrigin == null || _finalDestination == null || isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
update();
|
||||
mySnackbarInfo(box.read(BoxName.lang) == 'ar' ? 'جاري حساب مسار جديد...' : 'Calculating new route...');
|
||||
await getRoute(myLocation!, _finalDestination!);
|
||||
|
||||
markers = markers.where((m) => m.markerId.value != 'origin').toSet();
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('origin'),
|
||||
position: routeOrigin,
|
||||
icon: InlqBitmap.fromStyleImage('start_icon'),
|
||||
));
|
||||
|
||||
await getRoute(routeOrigin, _finalDestination!,
|
||||
keepNavigationActive: keepNavigationActive);
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
@@ -1087,8 +1118,11 @@ class NavigationController extends GetxController
|
||||
isNavigating = true;
|
||||
_cameraLockedToUser = true;
|
||||
|
||||
// Ensure ETA and distances are up-to-date
|
||||
_lastTraveledIndexInFullRoute = _lastTraveledIndexInFullRoute;
|
||||
_recomputeETA();
|
||||
|
||||
// Initialize current instruction if available
|
||||
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
|
||||
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
|
||||
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
||||
@@ -1105,6 +1139,7 @@ class NavigationController extends GetxController
|
||||
}
|
||||
}
|
||||
|
||||
// Center camera on user for navigation mode
|
||||
if (myLocation != null) {
|
||||
animateCameraToPosition(myLocation!,
|
||||
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
|
||||
@@ -1145,22 +1180,21 @@ class NavigationController extends GetxController
|
||||
_routeTotalDistanceM = 0;
|
||||
_routeTotalDurationS = 0;
|
||||
|
||||
if (!isNewRoute) {
|
||||
await _updateCarMarker();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomIcons() async {
|
||||
if (mapController == null) return;
|
||||
try {
|
||||
final carBytes = await rootBundle.load('assets/images/car.png');
|
||||
final startBytes = await rootBundle.load('assets/images/A.png');
|
||||
final destBytes = await rootBundle.load('assets/images/b.png');
|
||||
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
|
||||
await mapController!
|
||||
.addImage('start_icon', startBytes.buffer.asUint8List());
|
||||
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
|
||||
} catch (e) {
|
||||
Log.print("Error loading custom icons: $e");
|
||||
}
|
||||
final carBytes = await rootBundle.load('assets/images/car.png');
|
||||
final startBytes = await rootBundle.load('assets/images/A.png');
|
||||
final destBytes = await rootBundle.load('assets/images/b.png');
|
||||
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
|
||||
await mapController!
|
||||
.addImage('start_icon', startBytes.buffer.asUint8List());
|
||||
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
|
||||
}
|
||||
|
||||
void _checkNavigationStep(LatLng pos) {
|
||||
@@ -1233,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();
|
||||
|
||||
201
lib/controller/home/profile/complaint_controller.dart
Normal file
201
lib/controller/home/profile/complaint_controller.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sefer_driver/constant/box_name.dart';
|
||||
import 'package:sefer_driver/constant/links.dart';
|
||||
import 'package:sefer_driver/controller/functions/crud.dart';
|
||||
import 'package:sefer_driver/main.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:sefer_driver/controller/functions/encrypt_decrypt.dart';
|
||||
import 'package:sefer_driver/env/env.dart';
|
||||
import 'package:sefer_driver/print.dart';
|
||||
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
|
||||
import 'package:sefer_driver/views/widgets/mydialoug.dart';
|
||||
|
||||
class ComplaintController extends GetxController {
|
||||
bool isLoading = false;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final complaintController = TextEditingController();
|
||||
|
||||
List<dynamic> ridesList = [];
|
||||
Map<String, dynamic>? selectedRide;
|
||||
|
||||
Map<String, dynamic>? passengerReport;
|
||||
Map<String, dynamic>? driverReport;
|
||||
|
||||
var isUploading = false.obs;
|
||||
var uploadSuccess = false.obs;
|
||||
String audioLink = ''; // سيتم تخزين رابط الصوت هنا بعد الرفع
|
||||
String attachedFileName = '';
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
getLatestRidesForDriver();
|
||||
}
|
||||
|
||||
void _showCustomSnackbar(String title, String message,
|
||||
{bool isError = false}) {
|
||||
if (title.toLowerCase() == 'success') {
|
||||
mySnackbarSuccess(message.tr);
|
||||
} else if (isError) {
|
||||
mySnackeBarError(message.tr);
|
||||
} else {
|
||||
mySnackbarWarning(message.tr);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getLatestRidesForDriver() async {
|
||||
isLoading = true;
|
||||
update();
|
||||
try {
|
||||
var res = await CRUD().get(link: AppLink.getRides, payload: {
|
||||
'driver_id': box.read(BoxName.driverID).toString(),
|
||||
});
|
||||
if (res != 'failure' && res != 'no_internet') {
|
||||
var decoded = jsonDecode(res);
|
||||
if (decoded['status'] == 'success') {
|
||||
ridesList = decoded['data'] ?? [];
|
||||
if (ridesList.isNotEmpty) {
|
||||
selectedRide = ridesList[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print("Error getting driver rides: $e");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
void selectRide(Map<String, dynamic> ride) {
|
||||
selectedRide = ride;
|
||||
audioLink = '';
|
||||
attachedFileName = '';
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> uploadAudioFile(File audioFile) async {
|
||||
try {
|
||||
isUploading.value = true;
|
||||
update();
|
||||
|
||||
var uri = Uri.parse(AppLink.uploadAudio);
|
||||
var request = http.MultipartRequest('POST', uri);
|
||||
String token = r(box.read(BoxName.jwt)).toString().split(Env.addd)[0];
|
||||
final String fingerPrint = box.read(BoxName.deviceFingerprint)?.toString() ?? '';
|
||||
|
||||
var mimeType = lookupMimeType(audioFile.path);
|
||||
request.headers.addAll({
|
||||
'Authorization': 'Bearer $token',
|
||||
'X-Device-FP': fingerPrint,
|
||||
});
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath(
|
||||
'audio',
|
||||
audioFile.path,
|
||||
contentType: mimeType != null ? MediaType.parse(mimeType) : null,
|
||||
),
|
||||
);
|
||||
|
||||
var response = await request.send();
|
||||
var responseBody = await http.Response.fromStream(response);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
var jsonResponse = jsonDecode(responseBody.body);
|
||||
if (jsonResponse['status'] == 'Audio file uploaded successfully.') {
|
||||
uploadSuccess.value = true;
|
||||
audioLink = jsonResponse['link'];
|
||||
attachedFileName = audioFile.path.split('/').last;
|
||||
_showCustomSnackbar('Success', 'Audio uploaded successfully.');
|
||||
} else {
|
||||
uploadSuccess.value = false;
|
||||
_showCustomSnackbar('Error', 'Failed to upload audio file.',
|
||||
isError: true);
|
||||
}
|
||||
} else {
|
||||
uploadSuccess.value = false;
|
||||
_showCustomSnackbar('Error', 'Server error: ${response.statusCode}',
|
||||
isError: true);
|
||||
}
|
||||
} catch (e) {
|
||||
uploadSuccess.value = false;
|
||||
_showCustomSnackbar(
|
||||
'Error', 'An application error occurred during upload.',
|
||||
isError: true);
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitComplaintToServer() async {
|
||||
if (!formKey.currentState!.validate() || complaintController.text.isEmpty) {
|
||||
_showCustomSnackbar(
|
||||
'Error', 'Please describe your issue before submitting.',
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRide == null) {
|
||||
_showCustomSnackbar('Error', 'Please select a ride before submitting.',
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
update();
|
||||
|
||||
try {
|
||||
final rideId = selectedRide!['id'].toString();
|
||||
final complaint = complaintController.text;
|
||||
|
||||
final responseData = await CRUD().post(
|
||||
link: AppLink.add_solve_all,
|
||||
payload: {
|
||||
'ride_id': rideId,
|
||||
'complaint_text': complaint,
|
||||
'audio_link': audioLink,
|
||||
},
|
||||
);
|
||||
|
||||
if (responseData == 'failure' || responseData == 'no_internet' || responseData == 'token_expired') {
|
||||
_showCustomSnackbar(
|
||||
'Error', 'Failed to connect to the server. Please try again.',
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseData['status'] == 'success') {
|
||||
passengerReport = responseData['data']['passenger_response'];
|
||||
driverReport = responseData['data']['driver_response'];
|
||||
update();
|
||||
|
||||
MyDialogContent().getDialog(
|
||||
'Success'.tr, Text('Your complaint has been submitted.'.tr), () {
|
||||
Get.back();
|
||||
complaintController.clear();
|
||||
audioLink = '';
|
||||
attachedFileName = '';
|
||||
formKey.currentState?.reset();
|
||||
});
|
||||
} else {
|
||||
String errorMessage =
|
||||
responseData['message'] ?? 'An unknown server error occurred'.tr;
|
||||
_showCustomSnackbar('Submission Failed', errorMessage, isError: true);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print("Submit Complaint Error: $e");
|
||||
_showCustomSnackbar('Error', 'An application error occurred.'.tr,
|
||||
isError: true);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sefer_driver/controller/auth/captin/login_captin_controller.dart';
|
||||
import 'package:sefer_driver/views/auth/captin/login_captin.dart';
|
||||
import 'package:sefer_driver/views/home/on_boarding_page.dart';
|
||||
import '../functions/app_update_controller.dart';
|
||||
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../main.dart';
|
||||
@@ -33,6 +34,7 @@ class SplashScreenController extends GetxController
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
Get.put(AppUpdateController()); // تهيئة متحكم التحديثات الذكي
|
||||
_setupAnimations();
|
||||
_initializeAndNavigate();
|
||||
checkSecurity();
|
||||
|
||||
@@ -15,6 +15,7 @@ class MyTranslation extends Translations {
|
||||
"Intaleq Wallet": "محفظة انطلق",
|
||||
"KM": "كم",
|
||||
"Minutes": "دقايق",
|
||||
"You haven't moved sufficiently!": 'لم تتحرك بالقدر الكافي',
|
||||
"Next as Cash !": "الرحلة الجاية كاش!",
|
||||
"You Earn today is": "أرباحك اليوم هي",
|
||||
"You Have in": "عندك بـ",
|
||||
@@ -29,6 +30,7 @@ class MyTranslation extends Translations {
|
||||
"below, I have reviewed and agree to the Terms of Use and acknowledge the Privacy Notice. I am at least 18 years of age.":
|
||||
"تحت، راجعت ووافقت على شروط الاستخدام وبوافق على سياسة الخصوصية. عمري 18 سنة أو أكثر.",
|
||||
"in your wallet": "بمحفظتك",
|
||||
"is calling you": "عم يتصل فيك",
|
||||
"is ON for this month": "مفعّلة هالشهر",
|
||||
"tips\nTotal is": "الإكراميات\nالإجمالي هو",
|
||||
"to arrive you.": "ليوصل لعندك.",
|
||||
@@ -46,17 +48,22 @@ class MyTranslation extends Translations {
|
||||
". The app will connect you with a nearby driver.":
|
||||
". التطبيق رح يربطك بسائق قريب منك.",
|
||||
"1. Describe Your Issue": "1. صف مشكلتك",
|
||||
"1. Select Ride": "1. اختر المشوار",
|
||||
"10 and get 4% discount": "10 واحصل على خصم 4%",
|
||||
"100 and get 11% discount": "100 واحصل على خصم 11%",
|
||||
"1999": "1999",
|
||||
"2. Attach Recorded Audio": "2. أرفق التسجيل الصوتي",
|
||||
"2. Attach Recorded Audio (Optional)":
|
||||
"2. أرفق التسجيل الصوتي (اختياري)",
|
||||
"2. Describe Your Issue": "2. اكتب وصف للمشكلة",
|
||||
"20 and get 6% discount": "20 واحصل على خصم 6%",
|
||||
"27\\": "27\\",
|
||||
"3. Attach Recorded Audio (Optional)":
|
||||
"3. أرفق التسجيل الصوتي (اختياري)",
|
||||
"3. Review Details & Response": "3. راجع التفاصيل والرد",
|
||||
"300 LE": "300 ل.م",
|
||||
"3000 LE": "3000 ل.م",
|
||||
"4. Review Details & Response": "4. راجع التفاصيل والرد",
|
||||
"40 and get 8% discount": "40 واحصل على خصم 8%",
|
||||
"5 digit": "5 أرقام",
|
||||
"<< BACK": "<< رجوع",
|
||||
@@ -73,6 +80,7 @@ class MyTranslation extends Translations {
|
||||
"About Us": "من نحن",
|
||||
"Abu Dhabi Commercial Bank – Egypt": "بنك أبوظبي التجاري – مصر",
|
||||
"Abu Dhabi Islamic Bank – Egypt": "بنك أبوظبي الإسلامي – مصر",
|
||||
"Accept": "قبول",
|
||||
"Accept Order": "اقبل الطلب",
|
||||
"Accept Ride": "اقبل الرحلة",
|
||||
"Accepted Ride": "الرحلة انقبلت",
|
||||
@@ -147,6 +155,7 @@ class MyTranslation extends Translations {
|
||||
"An unexpected error occurred. Please try again.":
|
||||
"صار خطأ غير متوقع. جرب مرة تانية.",
|
||||
"An unexpected error occurred:": "صار خطأ غير متوقع:",
|
||||
"An unknown server error occurred": "صار خطأ غير معروف بالسيرفر.",
|
||||
"Any comments about the passenger?": "في أي تعليق على الراكب؟",
|
||||
"App Dark Mode": "الوضع الليلي للتطبيق",
|
||||
"App Preferences": "تفضيلات التطبيق",
|
||||
@@ -270,6 +279,7 @@ class MyTranslation extends Translations {
|
||||
"No data yet": "لا توجد بيانات بعد",
|
||||
"h": "ساعة",
|
||||
"Trip": "رحلة",
|
||||
"Ride": "المشوار",
|
||||
"Rides": "رحلات",
|
||||
"Hours": "ساعات",
|
||||
"Total Trips": "إجمالي الرحلات",
|
||||
@@ -316,7 +326,10 @@ class MyTranslation extends Translations {
|
||||
"But you have a negative salary of": "بس عندك راتب سلبي بقيمة",
|
||||
"CODE": "الكود",
|
||||
"Calculating...": "عم نحسب...",
|
||||
"Calling non-Syrian numbers is not supported":
|
||||
"الاتصال بالأرقام غير السورية غير مدعوم",
|
||||
"Call": "اتصل",
|
||||
"Call Connected": "تم فتح الاتصال",
|
||||
"Call Driver": "اتصل بالسائق",
|
||||
"Call End": "انتهت المكالمة",
|
||||
"Call Income": "مكالمة واردة",
|
||||
@@ -326,6 +339,7 @@ class MyTranslation extends Translations {
|
||||
"Call Page": "صفحة الاتصال",
|
||||
"Call Passenger": "اتصل بالراكب",
|
||||
"Call Support": "اتصل بالدعم",
|
||||
"Calling": "عم نتصل بـ",
|
||||
"Camera Access Denied.": "تم رفض الوصول للكاميرا.",
|
||||
"Camera not initialized yet": "الكاميرا ما تجهزت بعد",
|
||||
"Camera not initilaized yet": "الكاميرا ما تجهزت بعد",
|
||||
@@ -342,6 +356,7 @@ class MyTranslation extends Translations {
|
||||
"Canceled": "ملغية",
|
||||
"Canceled Orders": "الطلبات الملغاة",
|
||||
"Cannot apply further discounts.": "ما في تطبيق خصومات إضافية.",
|
||||
"Captain": "الكابتن",
|
||||
"Capture an Image of Your Criminal Record":
|
||||
"التقط صورة لصحيفة الحالة الجنائية",
|
||||
"Capture an Image of Your Driver License": "التقط صورة لرخصة السائق",
|
||||
@@ -443,6 +458,7 @@ class MyTranslation extends Translations {
|
||||
"Confirm your Email": "تأكيد بريدك الإلكتروني",
|
||||
"Confirmation": "تأكيد",
|
||||
"Connected": "متصل",
|
||||
"Connecting...": "عم يتم الاتصال...",
|
||||
"Contact Options": "خيارات التواصل",
|
||||
"Contact Support": "تواصل مع الدعم",
|
||||
"Contact Support to Recharge": "تواصل مع الدعم للشحن",
|
||||
@@ -488,6 +504,7 @@ class MyTranslation extends Translations {
|
||||
"رقم هاتف العميل ما فيه محفظة عميل",
|
||||
"Customer not found": "ما لقينا العميل",
|
||||
"Customer phone is not active": "هاتف العميل مش شغال",
|
||||
"Decline": "رفض",
|
||||
"DISCOUNT": "خصم",
|
||||
"DRIVER123": "DRIVER123",
|
||||
"Date": "التاريخ",
|
||||
@@ -686,6 +703,7 @@ class MyTranslation extends Translations {
|
||||
"لرحلات انطلق والتوصيل، السعر بيحسب ديناميكياً. لرحلات الكومفورت، السعر بيعتمد على الوقت والمسافة.",
|
||||
"For Intaleq and scooter trips, the price is calculated dynamically. For Comfort trips, the price is based on time and distance":
|
||||
"لرحلات انطلاق والسكوتر، السعر بيحسب ديناميكياً. لرحلات الكومفورت، السعر بيعتمد على الوقت والمسافة.",
|
||||
"Free Call": "مكالمة مجانية",
|
||||
"Frequently Asked Questions": "الأسئلة الشائعة",
|
||||
"Frequently Questions": "أسئلة متكررة",
|
||||
"From": "من",
|
||||
@@ -774,6 +792,7 @@ class MyTranslation extends Translations {
|
||||
"I Arrive": "وصلت",
|
||||
"I Arrive your site": "وصلت لموقعك",
|
||||
"I Have Arrived": "أنا وصلت",
|
||||
"I've arrived.": "لقد وصلت.",
|
||||
"I added the wrong pick-up/drop-off location":
|
||||
"حطيت مكان الالتقاط/التنزيل غلط",
|
||||
"I arrive you": "وصلت لعندك",
|
||||
@@ -839,6 +858,7 @@ class MyTranslation extends Translations {
|
||||
"Intaleq Over": "انطلق انتهى",
|
||||
"Intaleq Reminder": "تذكير انطلق",
|
||||
"Intaleq Wallet Features:": "ميزات محفظة انطلق:",
|
||||
"Intaleq's Response": "رد انطلق",
|
||||
"Intaleq is a ride-sharing app designed with your safety and affordability in mind. We connect you with reliable drivers in your area, ensuring a convenient and stress-free travel experience.\nHere are some of the key features that set us apart:":
|
||||
"انطلق تطبيق مشاركة رحلات مصمم لسلامتك وتوفير فلوسك. بنربطك بسواقين موثوقين بمنطقتك...",
|
||||
"Intaleq is committed to safety, and all of our captains are carefully screened and background checked.":
|
||||
@@ -967,6 +987,7 @@ class MyTranslation extends Translations {
|
||||
"My location is correct. You can search for me using the navigation app":
|
||||
"موقعي صحيح. تقدر تبحث عليي بتطبيق الملاحة",
|
||||
"MyLocation": "موقعي",
|
||||
"Mute": "كتم الصوت",
|
||||
"N/A": "غير متاح",
|
||||
"NEXT >>": "التالي >>",
|
||||
"NEXT STEP": "الخطوة التالية",
|
||||
@@ -1011,6 +1032,8 @@ class MyTranslation extends Translations {
|
||||
"No accepted orders? Try raising your trip fee to attract riders.":
|
||||
"ما في طلبات منقبولة؟ جرّب ترفع رسوم رحلتك عشان تجذب ركاب.",
|
||||
"No audio files found.": "ما لقينا ملفات صوتية.",
|
||||
"No audio files found for this ride.":
|
||||
"ما لقينا تسجيلات صوتية لهاد المشوار.",
|
||||
"No audio files recorded.": "ما في ملفات صوتية مسجلة.",
|
||||
"No cars are available at the moment. Please try again later.":
|
||||
"ما في سيارات متاحة هلق. تفضل جرّب مرة تانية لاحقاً.",
|
||||
@@ -1047,6 +1070,8 @@ class MyTranslation extends Translations {
|
||||
"No rides available for your vehicle type.":
|
||||
"ما في رحلات متاحة لنوع سيارتك.",
|
||||
"No rides available right now.": "ما في رحلات متاحة هلق.",
|
||||
"No rides found to complain about.":
|
||||
"ما لقينا أي مشاوير لحتى تقدم شكوى عليها.",
|
||||
"No transactions this week": "ما في معاملات بهالأسبوع",
|
||||
"No transactions yet": "ما في معاملات لسا",
|
||||
"No trip data available": "ما في بيانات رحلة متاحة",
|
||||
@@ -1233,6 +1258,8 @@ class MyTranslation extends Translations {
|
||||
"Please enter your phone number": "يرجى إدخال رقم هاتفك",
|
||||
"Please enter your phone number.": "تفضل أدخل رقم هاتفك.",
|
||||
"Please enter your question": "تفضل أدخل سؤالك",
|
||||
"Please select a ride before submitting.":
|
||||
"تفضل اختر المشوار قبل ما ترسل.",
|
||||
"Please go closer to the passenger location (less than 150m)":
|
||||
"تفضل قرب من موقع الراكب (أقل من 150 متر)",
|
||||
"Please go to Car Driver": "يرجى الذهاب إلى سائق السيارة",
|
||||
|
||||
749
lib/controller/voice_call_controller.dart
Normal file
749
lib/controller/voice_call_controller.dart
Normal file
@@ -0,0 +1,749 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:get/get.dart' hide Response;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../main.dart';
|
||||
import '../../print.dart';
|
||||
import '../../services/signaling_service.dart';
|
||||
import '../../views/widgets/voice_call_bottom_sheet.dart';
|
||||
import 'functions/crud.dart';
|
||||
|
||||
// EN: Enum representing the different states of a voice call.
|
||||
// AR: تعداد يمثل الحالات المختلفة للمكالمة الصوتية.
|
||||
enum VoiceCallState { idle, dialing, ringing, connecting, active, ended }
|
||||
|
||||
class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
// EN: Instance of the signaling service to manage WebSocket communication.
|
||||
// AR: مثيل لخدمة الإشارات لإدارة الاتصال عبر الـ WebSocket.
|
||||
final SignalingService _signaling = SignalingService();
|
||||
|
||||
// --- Observable Variables (GetX) / المتغيرات التفاعلية ---
|
||||
|
||||
// EN: Current state of the call.
|
||||
// AR: الحالة الحالية للمكالمة.
|
||||
var state = VoiceCallState.idle.obs;
|
||||
|
||||
// EN: Unique identifier for the WebRTC session.
|
||||
// AR: المعرف الفريد لجلسة الاتصال.
|
||||
var sessionId = "".obs;
|
||||
|
||||
// EN: ID of the current active ride.
|
||||
// AR: معرف الرحلة النشطة الحالية.
|
||||
var rideId = "".obs;
|
||||
|
||||
// EN: Name of the other party (Driver/Passenger).
|
||||
// AR: اسم الطرف الآخر في المكالمة (سائق/راكب).
|
||||
var remoteName = "User".obs;
|
||||
|
||||
// EN: Microphone mute status.
|
||||
// AR: حالة كتم الميكروفون.
|
||||
var isMuted = false.obs;
|
||||
|
||||
// EN: Speakerphone status.
|
||||
// AR: حالة مكبر الصوت الخارجي.
|
||||
var isSpeakerOn = false.obs;
|
||||
|
||||
// EN: Timer countdown variable, starts from 60 seconds.
|
||||
// AR: متغير العد التنازلي للمؤقت، يبدأ من 60 ثانية.
|
||||
var elapsedSeconds = 60.obs;
|
||||
|
||||
// EN: Error message to display in UI when call setup fails.
|
||||
// AR: رسالة الخطأ لعرضها في الواجهة عندما يفشل إعداد المكالمة.
|
||||
var errorMessage = "".obs;
|
||||
|
||||
// --- Core State Variables / متغيرات الحالة الأساسية ---
|
||||
|
||||
// EN: Flag to determine if the current user initiated the call.
|
||||
// AR: مؤشر لتحديد ما إذا كان المستخدم الحالي هو من بدأ المكالمة.
|
||||
bool isCaller = false;
|
||||
|
||||
// EN: ID of the current user.
|
||||
// AR: معرف المستخدم الحالي.
|
||||
String currentUserId = "";
|
||||
|
||||
// --- WebRTC Internal Variables / متغيرات WebRTC الداخلية ---
|
||||
|
||||
// EN: The main connection object between peers.
|
||||
// AR: كائن الاتصال الرئيسي بين الطرفين.
|
||||
rtc.RTCPeerConnection? _peerConnection;
|
||||
|
||||
// EN: The local audio stream captured from the microphone.
|
||||
// AR: دفق الصوت المحلي الملتقط من الميكروفون.
|
||||
rtc.MediaStream? _localStream;
|
||||
|
||||
// EN: Timer to enforce the 60-second call limit.
|
||||
// AR: مؤقت لفرض حد الـ 60 ثانية للمكالمة.
|
||||
Timer? _countdownTimer;
|
||||
|
||||
// EN: Timer to hang up if the call is not answered within 30 seconds.
|
||||
// AR: مؤقت لإنهاء المكالمة إذا لم يتم الرد خلال 30 ثانية.
|
||||
Timer? _ringingTimeoutTimer;
|
||||
|
||||
// EN: Flag to indicate if the peer connection is currently attempting ICE reconnection.
|
||||
// AR: مؤشر يوضح ما إذا كان الاتصال يحاول إعادة بناء مسارات الشبكة حالياً.
|
||||
bool _isReconnecting = false;
|
||||
Timer? _reconnectTimer;
|
||||
List<dynamic> _dynamicIceServers = [];
|
||||
|
||||
AudioPlayer? _ringtonePlayer;
|
||||
|
||||
void _startRingtone() async {
|
||||
try {
|
||||
_ringtonePlayer ??= AudioPlayer();
|
||||
await _ringtonePlayer!.setAsset('assets/order.mp3');
|
||||
await _ringtonePlayer!.setLoopMode(LoopMode.one);
|
||||
_ringtonePlayer!.play();
|
||||
} catch (e) {
|
||||
Log.print("Error playing ringtone: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _stopRingtone() {
|
||||
try {
|
||||
_ringtonePlayer?.stop();
|
||||
} catch (e) {
|
||||
Log.print("Error stopping ringtone: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// EN: Add lifecycle observer.
|
||||
// AR: إضافة مراقب لدورة حياة التطبيق.
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// EN: Initialize WebSocket signaling listeners.
|
||||
// AR: تهيئة مستمعي إشارات الـ WebSocket.
|
||||
_initSignalingCallbacks();
|
||||
}
|
||||
|
||||
// EN: Lifecycle hook: handle app switching background/foreground.
|
||||
// AR: معالجة انتقال التطبيق إلى الخلفية أو العودة للواجهة.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
Log.print("VoiceCall: didChangeAppLifecycleState -> $state");
|
||||
if (state == AppLifecycleState.paused) {
|
||||
Log.print(
|
||||
"WARNING: App is in background. Microphone access might be suspended by the OS.");
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
Log.print("App resumed. Verifying WebRTC connection health.");
|
||||
if (this.state.value == VoiceCallState.active) {
|
||||
_ensureMicrophoneActive();
|
||||
_attemptIceRestart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Registers all event listeners for the signaling server.
|
||||
// AR: تسجيل جميع مستمعي الأحداث لخادم الإشارات.
|
||||
void _initSignalingCallbacks() {
|
||||
// EN: Triggered when successfully connected to the signaling server.
|
||||
// AR: يُستدعى عند الاتصال بنجاح بخادم الإشارات.
|
||||
_signaling.onConnected = (iceServers) {
|
||||
Log.print("WebRTC Signaling Connected & Authenticated");
|
||||
_dynamicIceServers = iceServers;
|
||||
};
|
||||
|
||||
// EN: Triggered when the WebSocket connection drops.
|
||||
// AR: يُستدعى عند انقطاع اتصال الـ WebSocket.
|
||||
_signaling.onDisconnected = (reason) {
|
||||
Log.print("WebRTC Signaling Disconnected: $reason");
|
||||
if (state.value != VoiceCallState.idle) {
|
||||
_endCallInternal("signaling_disconnected");
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Triggered when the remote user joins the room.
|
||||
// AR: يُستدعى عند انضمام الطرف الآخر إلى غرفة الاتصال.
|
||||
_signaling.onParticipantJoined = () async {
|
||||
Log.print("Remote participant joined signaling session");
|
||||
// EN: If we are the caller, initiate the WebRTC handshake by creating an Offer.
|
||||
// AR: إذا كنا نحن المتصل، نبدأ مصافحة WebRTC بإنشاء عرض (Offer).
|
||||
if (isCaller && state.value == VoiceCallState.dialing) {
|
||||
state.value = VoiceCallState.connecting;
|
||||
await _createOffer();
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Triggered when an SDP Offer is received from the remote peer.
|
||||
// AR: يُستدعى عند استلام عرض اتصال (Offer) من الطرف الآخر.
|
||||
_signaling.onOffer = (sdpMap) async {
|
||||
Log.print("Received WebRTC SDP Offer");
|
||||
if (!isCaller) {
|
||||
state.value = VoiceCallState.connecting;
|
||||
await _initializePeerConnection();
|
||||
|
||||
// EN: Set the remote peer's settings.
|
||||
// AR: تعيين إعدادات الطرف الآخر.
|
||||
final description =
|
||||
rtc.RTCSessionDescription(sdpMap['sdp'], sdpMap['type']);
|
||||
await _peerConnection!.setRemoteDescription(description);
|
||||
|
||||
// EN: Respond with an Answer.
|
||||
// AR: الرد بإجابة (Answer).
|
||||
await _createAnswer();
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Triggered when an SDP Answer is received.
|
||||
// AR: يُستدعى عند استلام إجابة (Answer) من الطرف الآخر.
|
||||
_signaling.onAnswer = (sdpMap) async {
|
||||
Log.print("Received WebRTC SDP Answer");
|
||||
if (isCaller && _peerConnection != null) {
|
||||
final description =
|
||||
rtc.RTCSessionDescription(sdpMap['sdp'], sdpMap['type']);
|
||||
await _peerConnection!.setRemoteDescription(description);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Triggered when ICE candidates (Network routing info) are exchanged.
|
||||
// AR: يُستدعى عند تبادل مسارات الشبكة (ICE Candidates) لتأسيس الاتصال.
|
||||
_signaling.onIceCandidate = (candidateMap) async {
|
||||
Log.print("Received Remote ICE Candidate");
|
||||
if (_peerConnection != null) {
|
||||
final candidate = rtc.RTCIceCandidate(
|
||||
candidateMap['candidate'],
|
||||
candidateMap['sdpMid'],
|
||||
candidateMap['sdpMLineIndex'],
|
||||
);
|
||||
await _peerConnection!.addCandidate(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Triggered when a hangup event is received from the server.
|
||||
// AR: يُستدعى عند استلام حدث إنهاء المكالمة من السيرفر.
|
||||
_signaling.onCallEnded = (reason) {
|
||||
Log.print("WebRTC Call Ended: $reason");
|
||||
_endCallInternal(reason);
|
||||
};
|
||||
}
|
||||
|
||||
// --- CALL LIFECYCLE / دورة حياة المكالمة ---
|
||||
|
||||
// EN: Initiates an outgoing call.
|
||||
// AR: يبدأ مكالمة صادرة.
|
||||
Future<void> startCall({
|
||||
required String rideIdVal,
|
||||
required String driverId,
|
||||
required String passengerId,
|
||||
required String remoteNameVal,
|
||||
}) async {
|
||||
if (state.value != VoiceCallState.idle) return;
|
||||
|
||||
// EN: Setup call variables.
|
||||
// AR: إعداد متغيرات المكالمة.
|
||||
state.value = VoiceCallState.dialing;
|
||||
isCaller = true;
|
||||
currentUserId = driverId;
|
||||
rideId.value = rideIdVal;
|
||||
remoteName.value = remoteNameVal;
|
||||
isMuted.value = false;
|
||||
isSpeakerOn.value = false;
|
||||
elapsedSeconds.value = 60;
|
||||
_isReconnecting = false;
|
||||
errorMessage.value = "";
|
||||
|
||||
_showCallBottomSheet();
|
||||
HapticFeedback.vibrate();
|
||||
|
||||
try {
|
||||
// 1. EN: Request Microphone Permission / AR: طلب صلاحية الميكروفون
|
||||
if (!GetPlatform.isIOS) {
|
||||
final permissionStatus = await Permission.microphone.request();
|
||||
if (!permissionStatus.isGranted) {
|
||||
errorMessage.value =
|
||||
"Microphone permission is required for voice calls".tr;
|
||||
_endCallInternal("permission_denied");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM.
|
||||
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM.
|
||||
final response = await CRUD().post(
|
||||
link: "${AppLink.server}/ride/call/driver/create_call_session.php",
|
||||
payload: {'ride_id': rideIdVal},
|
||||
);
|
||||
|
||||
if (response == null ||
|
||||
response == 'failure' ||
|
||||
response['status'] != 'success') {
|
||||
errorMessage.value =
|
||||
"Failed to initiate call session. Please try again.".tr;
|
||||
_endCallInternal("session_creation_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
final data = response['data'];
|
||||
sessionId.value = data['session_id'];
|
||||
|
||||
// 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
|
||||
await _signaling.connect(sessionId.value, currentUserId);
|
||||
|
||||
// 4. EN: Initialize Local WebRTC Audio Stream / AR: تهيئة دفق الصوت المحلي
|
||||
await _initializeLocalStream();
|
||||
|
||||
// 5. EN: Start Ringing Timeout Timer (30s max wait for passenger to answer).
|
||||
// AR: بدء مؤقت الرنين (أقصى انتظار 30 ثانية لرد الراكب).
|
||||
_ringingTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (state.value == VoiceCallState.dialing) {
|
||||
_signaling.send("hangup", {"reason": "no_answer"});
|
||||
_endCallInternal("no_answer");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Log.print("Error starting WebRTC call: $e");
|
||||
final errStr = e.toString().toLowerCase();
|
||||
if (errStr.contains("permission") || errStr.contains("denied")) {
|
||||
errorMessage.value =
|
||||
"Microphone permission is required for voice calls".tr;
|
||||
} else {
|
||||
errorMessage.value = "Error starting voice call".tr;
|
||||
}
|
||||
_endCallInternal("error");
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Handles incoming call requests via FCM/Socket.
|
||||
// AR: معالجة طلبات المكالمات الواردة.
|
||||
Future<void> receiveCall({
|
||||
required String sessionIdVal,
|
||||
required String remoteNameVal,
|
||||
required String rideIdVal,
|
||||
}) async {
|
||||
// EN: If already in a call, send busy signal.
|
||||
// AR: إذا كان في مكالمة بالفعل، إرسال إشارة مشغول.
|
||||
if (state.value != VoiceCallState.idle) {
|
||||
_signaling.send("hangup", {"reason": "busy"});
|
||||
return;
|
||||
}
|
||||
|
||||
state.value = VoiceCallState.ringing;
|
||||
isCaller = false;
|
||||
currentUserId = box.read(BoxName.driverID).toString();
|
||||
sessionId.value = sessionIdVal;
|
||||
rideId.value = rideIdVal;
|
||||
remoteName.value = remoteNameVal;
|
||||
isMuted.value = false;
|
||||
isSpeakerOn.value = false;
|
||||
elapsedSeconds.value = 60;
|
||||
_isReconnecting = false;
|
||||
errorMessage.value = "";
|
||||
|
||||
_showCallBottomSheet();
|
||||
_startRingtone();
|
||||
HapticFeedback.vibrate();
|
||||
|
||||
// EN: Max 30s ringing timeout for receiver before auto-decline.
|
||||
// AR: أقصى مدة للرنين 30 ثانية قبل الرفض التلقائي.
|
||||
_ringingTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (state.value == VoiceCallState.ringing) {
|
||||
declineCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Accepts the incoming call.
|
||||
// AR: قبول المكالمة الواردة.
|
||||
Future<void> acceptCall() async {
|
||||
if (state.value != VoiceCallState.ringing) return;
|
||||
|
||||
_ringingTimeoutTimer?.cancel();
|
||||
_stopRingtone();
|
||||
state.value = VoiceCallState.connecting;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
// EN: Check Mic permissions / AR: التحقق من صلاحيات الميكروفون
|
||||
if (!GetPlatform.isIOS) {
|
||||
final permissionStatus = await Permission.microphone.request();
|
||||
if (!permissionStatus.isGranted) {
|
||||
errorMessage.value =
|
||||
"Microphone permission is required for voice calls".tr;
|
||||
declineCall();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _signaling.connect(sessionId.value, currentUserId);
|
||||
await _initializeLocalStream();
|
||||
|
||||
// EN: Notify caller we accepted / AR: إشعار المتصل بأننا قبلنا المكالمة
|
||||
_signaling.send("join", {});
|
||||
} catch (e) {
|
||||
Log.print("Error accepting call: $e");
|
||||
final errStr = e.toString().toLowerCase();
|
||||
if (errStr.contains("permission") || errStr.contains("denied")) {
|
||||
errorMessage.value =
|
||||
"Microphone permission is required for voice calls".tr;
|
||||
} else {
|
||||
errorMessage.value = "Error connecting call".tr;
|
||||
}
|
||||
declineCall();
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Declines an incoming call.
|
||||
// AR: رفض المكالمة الواردة.
|
||||
void declineCall() {
|
||||
_ringingTimeoutTimer?.cancel();
|
||||
_stopRingtone();
|
||||
_signaling.send("hangup", {"reason": "declined"});
|
||||
_endCallInternal("declined");
|
||||
}
|
||||
|
||||
// EN: Ends an active or dialing call.
|
||||
// AR: إنهاء المكالمة النشطة أو الجاري الاتصال بها.
|
||||
void hangup() {
|
||||
_signaling.send("hangup", {"reason": "normal"});
|
||||
_endCallInternal("hangup");
|
||||
}
|
||||
|
||||
// --- WEBRTC CORE HELPERS / دوال WebRTC الأساسية ---
|
||||
|
||||
// EN: Captures the audio from the microphone with optimization constraints.
|
||||
// AR: التقاط الصوت من الميكروفون مع قيود تحسين الجودة (إلغاء الصدى والضوضاء).
|
||||
Future<void> _initializeLocalStream() async {
|
||||
final Map<String, dynamic> mediaConstraints = {
|
||||
'audio': {
|
||||
'echoCancellation': true,
|
||||
'noiseSuppression': true,
|
||||
'autoGainControl': true,
|
||||
},
|
||||
'video': false, // EN: Audio only / AR: صوت فقط
|
||||
};
|
||||
|
||||
_localStream =
|
||||
await rtc.navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value);
|
||||
}
|
||||
|
||||
// EN: Verifies local microphone stream health on app resume and recreates/replaces track if suspended.
|
||||
// AR: التحقق من سلامة مسار الميكروفون المحلي عند استئناف التطبيق وإعادة إنشائه إذا تم تعليقه.
|
||||
Future<void> _ensureMicrophoneActive() async {
|
||||
if (_localStream == null || _peerConnection == null) return;
|
||||
|
||||
bool needsRecreation = false;
|
||||
if (_localStream!.active == false) {
|
||||
needsRecreation = true;
|
||||
} else {
|
||||
for (var track in _localStream!.getAudioTracks()) {
|
||||
if (!track.enabled && !isMuted.value) {
|
||||
needsRecreation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRecreation) {
|
||||
Log.print(
|
||||
"Local audio track ended or disabled. Recreating local stream...");
|
||||
try {
|
||||
_localStream?.getTracks().forEach((track) => track.stop());
|
||||
_localStream?.dispose();
|
||||
_localStream = null;
|
||||
|
||||
await _initializeLocalStream();
|
||||
|
||||
final senders = await _peerConnection!.getSenders();
|
||||
for (var sender in senders) {
|
||||
final track = sender.track;
|
||||
if (track != null && track.kind == 'audio') {
|
||||
final newTracks = _localStream?.getAudioTracks();
|
||||
if (newTracks != null && newTracks.isNotEmpty) {
|
||||
await sender.replaceTrack(newTracks.first);
|
||||
Log.print(
|
||||
"Replaced suspended/ended audio track with a new active one.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print("Error recreating local stream on resume: $e");
|
||||
}
|
||||
} else {
|
||||
_localStream!.getAudioTracks().forEach((track) {
|
||||
track.enabled = !isMuted.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Creates the peer connection object and sets up ICE servers (STUN/TURN).
|
||||
// AR: إنشاء كائن الاتصال المباشر وإعداد خوادم STUN/TURN لاختراق الجدران النارية.
|
||||
Future<void> _initializePeerConnection() async {
|
||||
if (_peerConnection != null) return;
|
||||
|
||||
final List<Map<String, dynamic>> iceServers = [];
|
||||
if (_dynamicIceServers.isNotEmpty) {
|
||||
for (var server in _dynamicIceServers) {
|
||||
if (server is Map) {
|
||||
iceServers.add({
|
||||
"urls": server["urls"] ?? server["url"],
|
||||
if (server["username"] != null) "username": server["username"],
|
||||
if (server["credential"] != null)
|
||||
"credential": server["credential"],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// EN: Fallback STUN servers / AR: خوام STUN الاحتياطية
|
||||
iceServers.addAll([
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"},
|
||||
]);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> configuration = {
|
||||
"iceServers": iceServers,
|
||||
};
|
||||
|
||||
_peerConnection = await rtc.createPeerConnection(configuration);
|
||||
|
||||
// EN: Gather local network routing info and send to remote peer.
|
||||
// AR: جمع بيانات مسارات الشبكة المحلية وإرسالها للطرف الآخر.
|
||||
_peerConnection!.onIceCandidate = (candidate) {
|
||||
if (candidate.candidate != null) {
|
||||
_signaling.send("ice_candidate", {
|
||||
"candidate": {
|
||||
"candidate": candidate.candidate,
|
||||
"sdpMid": candidate.sdpMid,
|
||||
"sdpMLineIndex": candidate.sdpMLineIndex,
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Monitor connection status changes and handle disconnections.
|
||||
// AR: مراقبة تغيرات حالة الاتصال ومعالجة انقطاع الشبكة.
|
||||
_peerConnection!.onConnectionState = (connState) {
|
||||
Log.print("RTCPeerConnectionState: $connState");
|
||||
if (connState ==
|
||||
rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
|
||||
_onCallConnected();
|
||||
} else if (connState ==
|
||||
rtc.RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||
connState ==
|
||||
rtc.RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) {
|
||||
_handleIceConnectionFailure();
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Add local audio stream to the connection to send it to the other peer.
|
||||
// AR: إضافة دفق الصوت المحلي للاتصال لإرساله للطرف الآخر.
|
||||
if (_localStream != null) {
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
_peerConnection!.addTrack(track, _localStream!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Attempts an ICE restart to reconnect the WebRTC session when disconnections occur.
|
||||
// AR: محاولة إعادة تأسيس الاتصال (ICE Restart) في حالة انقطاع الشبكة.
|
||||
void _handleIceConnectionFailure() {
|
||||
if (_isReconnecting) return;
|
||||
_isReconnecting = true;
|
||||
Log.print(
|
||||
"ICE connection dropped. Attempting ICE Restart reconnection for 5s...");
|
||||
|
||||
if (isCaller) {
|
||||
_attemptIceRestart();
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (state.value == VoiceCallState.active &&
|
||||
_peerConnection?.connectionState !=
|
||||
rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
|
||||
Log.print("ICE reconnection timed out. Hanging up.");
|
||||
_endCallInternal("connection_lost");
|
||||
} else {
|
||||
_isReconnecting = false;
|
||||
Log.print("ICE Reconnection succeeded!");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Initiates ICE Restart SDP exchange.
|
||||
// AR: بدء تبادل حزم SDP لإعادة بناء مسارات الاتصال.
|
||||
Future<void> _attemptIceRestart() async {
|
||||
if (_peerConnection == null || !isCaller) return;
|
||||
try {
|
||||
Log.print("Caller initiating WebRTC ICE Restart...");
|
||||
final constraints = {
|
||||
'mandatory': {
|
||||
'OfferToReceiveAudio': true,
|
||||
'OfferToReceiveVideo': false,
|
||||
},
|
||||
'optional': [
|
||||
{'IceRestart': true}
|
||||
],
|
||||
};
|
||||
final offer = await _peerConnection!.createOffer(constraints);
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
_signaling.send("offer", {
|
||||
"sdp": {
|
||||
"sdp": offer.sdp,
|
||||
"type": offer.type,
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Log.print("Error initiating WebRTC ICE Restart: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Generates an SDP Offer to initialize the connection.
|
||||
// AR: إنشاء عرض (Offer) لبدء الاتصال وتحديد قدرات الجهاز.
|
||||
Future<void> _createOffer() async {
|
||||
await _initializePeerConnection();
|
||||
|
||||
final constraints = {
|
||||
'mandatory': {
|
||||
'OfferToReceiveAudio': true,
|
||||
'OfferToReceiveVideo': false,
|
||||
},
|
||||
'optional': [],
|
||||
};
|
||||
|
||||
final offer = await _peerConnection!.createOffer(constraints);
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
_signaling.send("offer", {
|
||||
"sdp": {
|
||||
"sdp": offer.sdp,
|
||||
"type": offer.type,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Generates an SDP Answer in response to an Offer.
|
||||
// AR: الرد بإنشاء إجابة (Answer) بناءً على العرض المستلم.
|
||||
Future<void> _createAnswer() async {
|
||||
final constraints = {
|
||||
'mandatory': {
|
||||
'OfferToReceiveAudio': true,
|
||||
'OfferToReceiveVideo': false,
|
||||
},
|
||||
'optional': [],
|
||||
};
|
||||
|
||||
final answer = await _peerConnection!.createAnswer(constraints);
|
||||
await _peerConnection!.setLocalDescription(answer);
|
||||
|
||||
_signaling.send("answer", {
|
||||
"sdp": {
|
||||
"sdp": answer.sdp,
|
||||
"type": answer.type,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Triggered when connection is fully established. Starts the 60s timer.
|
||||
// AR: يُستدعى عند تأسيس الاتصال بنجاح، ويقوم ببدء مؤقت الـ 60 ثانية.
|
||||
void _onCallConnected() {
|
||||
_ringingTimeoutTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_isReconnecting = false;
|
||||
|
||||
if (state.value != VoiceCallState.active) {
|
||||
state.value = VoiceCallState.active;
|
||||
HapticFeedback.vibrate();
|
||||
|
||||
// EN: Start 120s countdown timer / AR: بدء العد التنازلي لمدة 120 ثانية
|
||||
_countdownTimer?.cancel();
|
||||
elapsedSeconds.value = 120;
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (elapsedSeconds.value > 1) {
|
||||
elapsedSeconds.value--;
|
||||
} else {
|
||||
elapsedSeconds.value = 0;
|
||||
_countdownTimer?.cancel();
|
||||
// EN: Force hangup when timer reaches 0 / AR: إغلاق إجباري عند وصول المؤقت لصفر
|
||||
hangup();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Internal cleanup function. Closes all connections and streams.
|
||||
// AR: دالة التنظيف الداخلية. تقوم بإغلاق جميع الاتصالات وتفريغ الذاكرة.
|
||||
void _endCallInternal(String reason) {
|
||||
_countdownTimer?.cancel();
|
||||
_ringingTimeoutTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_stopRingtone();
|
||||
|
||||
state.value = VoiceCallState.ended;
|
||||
|
||||
// EN: Close WebRTC connection / AR: إغلاق اتصال WebRTC
|
||||
_peerConnection?.close();
|
||||
_peerConnection = null;
|
||||
|
||||
// EN: Stop mic capture / AR: إيقاف التقاط الميكروفون
|
||||
_localStream?.getTracks().forEach((track) => track.stop());
|
||||
_localStream?.dispose();
|
||||
_localStream = null;
|
||||
|
||||
// EN: Disconnect WebSockets / AR: إغلاق اتصال الـ WebSockets
|
||||
_signaling.disconnect();
|
||||
|
||||
// EN: Close UI BottomSheet after delay / AR: إغلاق واجهة المكالمة بعد فترة زمنية قصيرة
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (state.value == VoiceCallState.ended) {
|
||||
state.value = VoiceCallState.idle;
|
||||
Get.back();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- ACTIONS (UI Controls) / إجراءات الواجهة ---
|
||||
|
||||
// EN: Toggles microphone mute state.
|
||||
// AR: تبديل حالة كتم الميكروفون.
|
||||
void toggleMute() {
|
||||
isMuted.value = !isMuted.value;
|
||||
_localStream?.getAudioTracks().forEach((track) {
|
||||
track.enabled = !isMuted.value;
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Toggles loudspeaker mode.
|
||||
// AR: تبديل حالة مكبر الصوت الخارجي.
|
||||
void toggleSpeaker() {
|
||||
isSpeakerOn.value = !isSpeakerOn.value;
|
||||
rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value);
|
||||
}
|
||||
|
||||
// EN: Displays the call UI overlay.
|
||||
// AR: إظهار نافذة المكالمة السفلية.
|
||||
void _showCallBottomSheet() {
|
||||
Get.bottomSheet(
|
||||
const VoiceCallBottomSheet(),
|
||||
isScrollControlled: true,
|
||||
enableDrag: false,
|
||||
isDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
// EN: Lifecycle hook: clean up resources when controller is destroyed.
|
||||
// AR: دورة الحياة: تفريغ الذاكرة وإغلاق الموارد عند تدمير المتحكم.
|
||||
@override
|
||||
void onClose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_countdownTimer?.cancel();
|
||||
_ringingTimeoutTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_stopRingtone();
|
||||
_ringtonePlayer?.dispose();
|
||||
_peerConnection?.close();
|
||||
_localStream?.dispose();
|
||||
_signaling.disconnect();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
111
lib/services/signaling_service.dart
Normal file
111
lib/services/signaling_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
@@ -193,7 +195,7 @@ class InstructionsOfRoads extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Text(
|
||||
"${"NEXT STEP".tr} (${controller.distanceToNextStep})",
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,49 +23,59 @@ class GoogleDriverMap extends StatelessWidget {
|
||||
final double mapPaddingBottom = MediaQuery.of(context).size.height * 0.3;
|
||||
|
||||
return GetBuilder<MapDriverController>(
|
||||
builder: (controller) => IntaleqMap(
|
||||
apiKey: AK.mapAPIKEY,
|
||||
onMapCreated: (mapController) {
|
||||
controller.onMapCreated(mapController);
|
||||
},
|
||||
mapType: Get.isRegistered<SettingController>()
|
||||
? (Get.find<SettingController>().isMapDarkMode
|
||||
? IntaleqMapType.normal
|
||||
: IntaleqMapType.light)
|
||||
: IntaleqMapType.light,
|
||||
zoomControlsEnabled: false,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: locationController.myLocation,
|
||||
zoom: 17,
|
||||
bearing: locationController.heading,
|
||||
tilt: 60,
|
||||
builder: (controller) => Listener(
|
||||
onPointerDown: (_) => controller.onUserMapInteraction(),
|
||||
child: IntaleqMap(
|
||||
apiKey: AK.mapAPIKEY,
|
||||
onMapCreated: (mapController) {
|
||||
controller.onMapCreated(mapController);
|
||||
},
|
||||
mapType: Get.isRegistered<SettingController>()
|
||||
? (Get.find<SettingController>().isMapDarkMode
|
||||
? IntaleqMapType.normal
|
||||
: IntaleqMapType.light)
|
||||
: IntaleqMapType.light,
|
||||
zoomControlsEnabled: false,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: controller.smoothedLocation ?? locationController.myLocation,
|
||||
zoom: 17,
|
||||
bearing: controller.smoothedHeading,
|
||||
tilt: 60,
|
||||
),
|
||||
// padding: EdgeInsets.only(bottom: 50, top: Get.height * 0.7),
|
||||
// minMaxZoomPreference: const MinMaxZoomPreference(8, 18),
|
||||
myLocationEnabled: false,
|
||||
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: 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: const MarkerId('start'),
|
||||
position: controller.latLngPassengerLocation,
|
||||
icon: controller.startIcon,
|
||||
),
|
||||
if (controller.latLngPassengerDestination.latitude != 0 ||
|
||||
controller.latLngPassengerDestination.longitude != 0)
|
||||
Marker(
|
||||
markerId: const MarkerId('end'),
|
||||
position: controller.latLngPassengerDestination,
|
||||
icon: controller.endIcon,
|
||||
),
|
||||
},
|
||||
),
|
||||
// padding: EdgeInsets.only(bottom: 50, top: Get.height * 0.7),
|
||||
// minMaxZoomPreference: const MinMaxZoomPreference(8, 18),
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: true,
|
||||
compassEnabled: true,
|
||||
polylines: controller.polyLines.toSet(),
|
||||
markers: {
|
||||
Marker(
|
||||
markerId: MarkerId('MyLocation'.tr),
|
||||
position: controller.smoothedLocation ?? locationController.myLocation,
|
||||
rotation: controller.smoothedHeading,
|
||||
flat: true,
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
icon: controller.carIcon,
|
||||
),
|
||||
Marker(
|
||||
markerId: MarkerId('start'.tr),
|
||||
position: controller.latLngPassengerLocation,
|
||||
icon: controller.startIcon,
|
||||
),
|
||||
Marker(
|
||||
markerId: MarkerId('end'.tr),
|
||||
position: controller.latLngPassengerDestination,
|
||||
icon: controller.endIcon,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600),
|
||||
// 🔥 [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();
|
||||
Get.back();
|
||||
},
|
||||
// 🔥 [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 للقيمة الأصلية
|
||||
}
|
||||
|
||||
@@ -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,46 +41,15 @@ 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,
|
||||
bgColor: AppColor.redColor,
|
||||
tooltip: 'EMERGENCY SOS',
|
||||
isPulsing: true,
|
||||
onTap: () => _handleSosCall(controller),
|
||||
),
|
||||
_buildModernActionButton(
|
||||
icon: MaterialIcons.warning,
|
||||
color: Colors.white,
|
||||
bgColor: AppColor.redColor,
|
||||
tooltip: 'EMERGENCY SOS',
|
||||
isPulsing: true,
|
||||
onTap: () => _handleSosCall(controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
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)),
|
||||
])),
|
||||
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)),
|
||||
])),
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
278
lib/views/home/profile/complaint_page.dart
Normal file
278
lib/views/home/profile/complaint_page.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,207 +200,206 @@ 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(
|
||||
child: Container(
|
||||
height: 5,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// --- 2. العنوان والأيقونة ---
|
||||
Image.asset(
|
||||
'assets/images/shamCash.png',
|
||||
height: 50,
|
||||
),
|
||||
// const Icon(Icons.account_balance_wallet_rounded,
|
||||
// size: 45, color: Colors.blueAccent),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"ربط حساب شام كاش 🔗",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueGrey[900]),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
"أدخل بيانات حسابك لاستقبال الأرباح فوراً",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// --- 3. الحقل الأول: اسم الحساب (أعلى الباركود) ---
|
||||
const Text("1. اسم الحساب (أعلى الباركود)",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "مثال: intaleq",
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.person_outline_rounded,
|
||||
color: Colors.blueGrey),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 15, horizontal: 10),
|
||||
children: [
|
||||
// --- 1. المقبض العلوي ---
|
||||
Center(
|
||||
child: Container(
|
||||
height: 5,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.dividerColor,
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// --- 4. الحقل الثاني: الكود (أسفل الباركود) ---
|
||||
const Text("2. كود المحفظة (أسفل الباركود)",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
// --- 2. العنوان والأيقونة ---
|
||||
Image.asset(
|
||||
'assets/images/shamCash.png',
|
||||
height: 50,
|
||||
),
|
||||
child: TextField(
|
||||
controller: codeController,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5), // خط أصغر قليلاً للكود الطويل
|
||||
decoration: InputDecoration(
|
||||
hintText: "مثال: 80f23afe40...",
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.qr_code_2_rounded,
|
||||
color: Colors.blueGrey),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 15, horizontal: 10),
|
||||
// const Icon(Icons.account_balance_wallet_rounded,
|
||||
// size: 45, color: Colors.blueAccent),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"ربط حساب شام كاش 🔗",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueGrey[900]),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
"أدخل بيانات حسابك لاستقبال الأرباح فوراً",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// زر لصق الكود
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste_rounded, color: Colors.blue),
|
||||
tooltip: "لصق الكود",
|
||||
onPressed: () async {
|
||||
ClipboardData? data =
|
||||
await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
codeController.text = data.text!;
|
||||
// تحريك المؤشر للنهاية بعد اللصق
|
||||
codeController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: codeController.text.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
// --- 3. الحقل الأول: اسم الحساب (أعلى الباركود) ---
|
||||
const Text("1. اسم الحساب (أعلى الباركود)",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "مثال: intaleq",
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.person_outline_rounded,
|
||||
color: Colors.blueGrey),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 15, horizontal: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// --- 5. زر الحفظ ---
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
String name = nameController.text.trim();
|
||||
String code = codeController.text.trim();
|
||||
// --- 4. الحقل الثاني: الكود (أسفل الباركود) ---
|
||||
const Text("2. كود المحفظة (أسفل الباركود)",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: TextField(
|
||||
controller: codeController,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5), // خط أصغر قليلاً للكود الطويل
|
||||
decoration: InputDecoration(
|
||||
hintText: "مثال: 80f23afe40...",
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.qr_code_2_rounded,
|
||||
color: Colors.blueGrey),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 15, horizontal: 10),
|
||||
|
||||
// التحقق من صحة البيانات
|
||||
if (name.isNotEmpty && code.length > 5) {
|
||||
// 1. إرسال البيانات إلى السيرفر
|
||||
var res = await CRUD()
|
||||
.post(link: AppLink.updateShamCashDriver, payload: {
|
||||
"id": box.read(BoxName.driverID),
|
||||
"accountBank": name,
|
||||
"bankCode": code,
|
||||
});
|
||||
// زر لصق الكود
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste_rounded, color: Colors.blue),
|
||||
tooltip: "لصق الكود",
|
||||
onPressed: () async {
|
||||
ClipboardData? data =
|
||||
await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
codeController.text = data.text!;
|
||||
// تحريك المؤشر للنهاية بعد اللصق
|
||||
codeController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: codeController.text.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (res != 'failure') {
|
||||
// 2. 🔴 الحفظ في الذاكرة المحلية (GetStorage) بعد نجاح التحديث
|
||||
box.write('shamcash_name', name);
|
||||
box.write('shamcash_code', code);
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Get.back(); // إغلاق النافذة
|
||||
Get.snackbar(
|
||||
"تم الحفظ بنجاح",
|
||||
"تم ربط حساب ($name) لاستلام الأرباح.",
|
||||
backgroundColor: Colors.green,
|
||||
colorText: Colors.white,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(20),
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
);
|
||||
return;
|
||||
// --- 5. زر الحفظ ---
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
String name = nameController.text.trim();
|
||||
String code = codeController.text.trim();
|
||||
|
||||
// التحقق من صحة البيانات
|
||||
if (name.isNotEmpty && code.length > 5) {
|
||||
// 1. إرسال البيانات إلى السيرفر
|
||||
var res = await CRUD()
|
||||
.post(link: AppLink.updateShamCashDriver, payload: {
|
||||
"id": box.read(BoxName.driverID),
|
||||
"accountBank": name,
|
||||
"bankCode": code,
|
||||
});
|
||||
|
||||
if (res != 'failure') {
|
||||
// 2. 🔴 الحفظ في الذاكرة المحلية (GetStorage) بعد نجاح التحديث
|
||||
box.write('shamcash_name', name);
|
||||
box.write('shamcash_code', code);
|
||||
|
||||
Get.back(); // إغلاق النافذة
|
||||
Get.snackbar(
|
||||
"تم الحفظ بنجاح",
|
||||
"تم ربط حساب ($name) لاستلام الأرباح.",
|
||||
backgroundColor: Colors.green,
|
||||
colorText: Colors.white,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(20),
|
||||
icon: const Icon(Icons.check_circle_outline,
|
||||
color: Colors.white),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
// في حال فشل الإرسال إلى السيرفر
|
||||
Get.snackbar(
|
||||
"خطأ في السيرفر",
|
||||
"فشل تحديث البيانات، يرجى المحاولة لاحقاً.",
|
||||
backgroundColor: Colors.redAccent,
|
||||
colorText: Colors.white,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(20),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// في حال فشل الإرسال إلى السيرفر
|
||||
Get.snackbar(
|
||||
"خطأ في السيرفر",
|
||||
"فشل تحديث البيانات، يرجى المحاولة لاحقاً.",
|
||||
backgroundColor: Colors.redAccent,
|
||||
"بيانات ناقصة",
|
||||
"يرجى التأكد من إدخال الاسم والكود بشكل صحيح.",
|
||||
backgroundColor: Colors.orange,
|
||||
colorText: Colors.white,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(20),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Get.snackbar(
|
||||
"بيانات ناقصة",
|
||||
"يرجى التأكد من إدخال الاسم والكود بشكل صحيح.",
|
||||
backgroundColor: Colors.orange,
|
||||
colorText: Colors.white,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
margin: const EdgeInsets.all(20),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2ecc71), // الأخضر المالي
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 2,
|
||||
),
|
||||
child: const Text(
|
||||
"حفظ وتفعيل الحساب",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2ecc71), // الأخضر المالي
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 2,
|
||||
),
|
||||
child: const Text(
|
||||
"حفظ وتفعيل الحساب",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10), // مسافة سفلية إضافية للأمان
|
||||
],
|
||||
const SizedBox(height: 10), // مسافة سفلية إضافية للأمان
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// ويدجت داخلية لزر في الشبكة
|
||||
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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,18 +151,47 @@ class RideAvailableCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.greenColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColor.greenColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
rideInfo['carType'] ?? 'Fixed Price'.tr,
|
||||
style: AppStyle.title
|
||||
.copyWith(color: AppColor.greenColor, fontSize: 13),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.greenColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColor.greenColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
rideInfo['carType'] ?? 'Fixed Price'.tr,
|
||||
style: AppStyle.title
|
||||
.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)
|
||||
|
||||
300
lib/views/widgets/voice_call_bottom_sheet.dart
Normal file
300
lib/views/widgets/voice_call_bottom_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
flutter_webrtc
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
918
plans/driver_ride_lifecycle_report.md
Normal file
918
plans/driver_ride_lifecycle_report.md
Normal 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×tamp=...
|
||||
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 |
|
||||
290
plans/finish_ride_updates.php
Normal file
290
plans/finish_ride_updates.php
Normal 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
|
||||
}
|
||||
?>
|
||||
40
plans/map_driver_controller_review.md
Normal file
40
plans/map_driver_controller_review.md
Normal 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` (تحسين أداء) | منخفض |
|
||||
141
plans/process_ride_payments.php
Normal file
141
plans/process_ride_payments.php
Normal 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());
|
||||
}
|
||||
38
pubspec.lock
38
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
45
scratch/build_and_catch_error.py
Normal file
45
scratch/build_and_catch_error.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user