From cbf693c804cb22e528545819c1d6176010636501 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 1 Jun 2026 23:35:29 +0300 Subject: [PATCH] Fixes & Updates - 2026-06-01: Integrate Back-End v3 updates, fix call/connection issues across apps --- android/app/build.gradle | 2 + android/app/proguard-rules.pro | 14 +- android/app/src/main/AndroidManifest.xml | 37 + .../intaleq_driver/CarNavigationData.kt | 44 + .../example/intaleq_driver/MainActivity.kt | 44 + .../example/intaleq_driver/MapPresentation.kt | 119 ++ .../example/intaleq_driver/MyCarAppService.kt | 24 + .../com/example/intaleq_driver/MyCarScreen.kt | 80 + .../example/intaleq_driver/MyCarSession.kt | 69 + .../src/main/res/xml/automotive_app_desc.xml | 4 + lib/constant/links.dart | 3 + .../auth/captin/phone_helper_controller.dart | 3 +- lib/controller/firebase/firbase_messge.dart | 46 +- .../firebase/local_notification.dart | 14 +- .../functions/app_update_controller.dart | 92 + .../functions/audio_recorder_controller.dart | 80 + lib/controller/functions/launch.dart | 61 +- .../functions/location_controller.dart | 86 +- .../home/captin/home_captain_controller.dart | 71 +- .../home/captin/map_driver_controller.dart | 1701 ++++++++++------- .../home/captin/order_request_controller.dart | 58 +- .../home/captin/v2_review_delta.html | 212 ++ .../navigation/navigation_controller.dart | 190 +- .../home/profile/complaint_controller.dart | 201 ++ .../home/splash_screen_controlle.dart | 2 + lib/controller/local/translations.dart | 27 + lib/controller/voice_call_controller.dart | 749 ++++++++ lib/main.dart | 10 +- lib/services/signaling_service.dart | 111 ++ lib/translations_ar.json | 10 +- lib/translations_en.json | 10 +- lib/views/auth/captin/otp_page.dart | 12 +- lib/views/auth/captin/otp_token_page.dart | 8 +- lib/views/home/Captin/driver_map_page.dart | 25 +- .../home/Captin/home_captain/home_captin.dart | 2 +- .../google_driver_map_page.dart | 94 +- .../passenger_info_window.dart | 255 ++- .../Captin/mapDriverWidgets/sos_connect.dart | 122 +- .../Captin/orderCaptin/order_over_lay.dart | 4 +- lib/views/home/journal/schedule_page.dart | 133 +- lib/views/home/profile/complaint_page.dart | 278 +++ lib/views/home/profile/profile_captain.dart | 353 ++-- .../notification/available_rides_page.dart | 61 +- .../widgets/voice_call_bottom_sheet.dart | 300 +++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + plans/driver_ride_lifecycle_report.md | 918 +++++++++ plans/finish_ride_updates.php | 290 +++ plans/map_driver_controller_review.md | 40 + plans/process_ride_payments.php | 141 ++ pubspec.lock | 38 +- pubspec.yaml | 4 +- scratch/build_and_catch_error.py | 45 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 56 files changed, 6091 insertions(+), 1217 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/intaleq_driver/CarNavigationData.kt create mode 100644 android/app/src/main/kotlin/com/example/intaleq_driver/MapPresentation.kt create mode 100644 android/app/src/main/kotlin/com/example/intaleq_driver/MyCarAppService.kt create mode 100644 android/app/src/main/kotlin/com/example/intaleq_driver/MyCarScreen.kt create mode 100644 android/app/src/main/kotlin/com/example/intaleq_driver/MyCarSession.kt create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 lib/controller/functions/app_update_controller.dart create mode 100644 lib/controller/functions/audio_recorder_controller.dart create mode 100644 lib/controller/home/captin/v2_review_delta.html create mode 100644 lib/controller/home/profile/complaint_controller.dart create mode 100644 lib/controller/voice_call_controller.dart create mode 100644 lib/services/signaling_service.dart create mode 100644 lib/views/home/profile/complaint_page.dart create mode 100644 lib/views/widgets/voice_call_bottom_sheet.dart create mode 100644 plans/driver_ride_lifecycle_report.md create mode 100644 plans/finish_ride_updates.php create mode 100644 plans/map_driver_controller_review.md create mode 100644 plans/process_ride_payments.php create mode 100644 scratch/build_and_catch_error.py diff --git a/android/app/build.gradle b/android/app/build.gradle index c403417..fdd9158 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 9af0b7d..675f9b0 100755 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -35,4 +35,16 @@ #-keep class com.sefer_driver.RootDetection { *; } -keep class com.sefer_driver.RootDetection { native ; - } \ No newline at end of file + } + +# 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.** \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 924466e..be9ea81 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -70,6 +70,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/CarNavigationData.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/CarNavigationData.kt new file mode 100644 index 0000000..8e72568 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/CarNavigationData.kt @@ -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() } + } + } +} diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/MainActivity.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/MainActivity.kt index a96f2fe..8dfd098 100644 --- a/android/app/src/main/kotlin/com/example/intaleq_driver/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/MainActivity.kt @@ -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("lat") ?: CarNavigationData.currentLat + CarNavigationData.currentLng = call.argument("lng") ?: CarNavigationData.currentLng + CarNavigationData.currentBearing = call.argument("bearing") ?: CarNavigationData.currentBearing + CarNavigationData.currentSpeed = call.argument("speed") ?: CarNavigationData.currentSpeed + CarNavigationData.currentInstruction = call.argument("instruction") ?: CarNavigationData.currentInstruction + CarNavigationData.distanceToNextStepMeters = call.argument("distanceToStep") ?: CarNavigationData.distanceToNextStepMeters + CarNavigationData.totalDistanceRemainingMeters = call.argument("totalDistance") ?: CarNavigationData.totalDistanceRemainingMeters + CarNavigationData.estimatedTimeRemainingSeconds = call.argument("eta") ?: CarNavigationData.estimatedTimeRemainingSeconds + CarNavigationData.maneuverType = call.argument("maneuver") ?: CarNavigationData.maneuverType + CarNavigationData.isNavigating = call.argument("isNavigating") ?: CarNavigationData.isNavigating + CarNavigationData.notifyListeners() + result.success(true) + } + "updateLocation" -> { + CarNavigationData.currentLat = call.argument("lat") ?: 0.0 + CarNavigationData.currentLng = call.argument("lng") ?: 0.0 + CarNavigationData.currentBearing = call.argument("bearing") ?: CarNavigationData.currentBearing + CarNavigationData.currentSpeed = call.argument("speed") ?: CarNavigationData.currentSpeed + CarNavigationData.notifyListeners() + result.success(true) + } + "updateInstruction" -> { + CarNavigationData.currentInstruction = call.argument("instruction") ?: "" + CarNavigationData.maneuverType = call.argument("maneuver") ?: 0 + CarNavigationData.distanceToNextStepMeters = call.argument("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?) { diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/MapPresentation.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/MapPresentation.kt new file mode 100644 index 0000000..95afb00 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/MapPresentation.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarAppService.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarAppService.kt new file mode 100644 index 0000000..ae51db7 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarAppService.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarScreen.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarScreen.kt new file mode 100644 index 0000000..929061f --- /dev/null +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarScreen.kt @@ -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 + } + } +} diff --git a/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarSession.kt b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarSession.kt new file mode 100644 index 0000000..daeaefa --- /dev/null +++ b/android/app/src/main/kotlin/com/example/intaleq_driver/MyCarSession.kt @@ -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() } +} diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..c8e2801 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + diff --git a/lib/constant/links.dart b/lib/constant/links.dart index e609559..0630e44 100755 --- a/lib/constant/links.dart +++ b/lib/constant/links.dart @@ -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"; diff --git a/lib/controller/auth/captin/phone_helper_controller.dart b/lib/controller/auth/captin/phone_helper_controller.dart index 200cd96..3e0d968 100644 --- a/lib/controller/auth/captin/phone_helper_controller.dart +++ b/lib/controller/auth/captin/phone_helper_controller.dart @@ -110,7 +110,7 @@ class PhoneAuthHelper { } /// Verifies the OTP and logs the user in. - static Future verifyOtp(String phoneNumber) async { + static Future 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, }, ); diff --git a/lib/controller/firebase/firbase_messge.dart b/lib/controller/firebase/firbase_messge.dart index af74f2e..7a5360f 100755 --- a/lib/controller/firebase/firbase_messge.dart +++ b/lib/controller/firebase/firbase_messge.dart @@ -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; 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().receiveCall( + sessionIdVal: sessionId.toString(), + remoteNameVal: callerName.toString(), + rideIdVal: rideId.toString(), + ); + } + break; + default: Log.print('Received unhandled notification category: $category'); // Optionally show a generic notification diff --git a/lib/controller/firebase/local_notification.dart b/lib/controller/firebase/local_notification.dart index 60b06ed..5d193db 100755 --- a/lib/controller/firebase/local_notification.dart +++ b/lib/controller/firebase/local_notification.dart @@ -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()) { print("♻️ Reviving HomeCaptainController..."); - Get.put(HomeCaptainController()); + Get.put(HomeCaptainController(), permanent: true); } else { Get.find().changeRideId(); } diff --git a/lib/controller/functions/app_update_controller.dart b/lib/controller/functions/app_update_controller.dart new file mode 100644 index 0000000..cc2347c --- /dev/null +++ b/lib/controller/functions/app_update_controller.dart @@ -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 checkSmartUpdate() async { + Log.print("🔄 بدء فحص تحديثات المتجر..."); + + // 1. فحص تحديث المتجر (Native Update) + await _checkStoreUpdate(); + } + + // ====================================================================== + // 1. تحديث المتجر الأساسي + // ====================================================================== + Future _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)), + ), + ); + } +} diff --git a/lib/controller/functions/audio_recorder_controller.dart b/lib/controller/functions/audio_recorder_controller.dart new file mode 100644 index 0000000..c8c802e --- /dev/null +++ b/lib/controller/functions/audio_recorder_controller.dart @@ -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 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 stopRecording() async { + await recorder.stop(); + isRecording = false; + isPaused = false; + update(); + } + + // Get a list of recorded files + Future> 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 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(); + } +} diff --git a/lib/controller/functions/launch.dart b/lib/controller/functions/launch.dart index 98cd01d..fe98848 100755 --- a/lib/controller/functions/launch.dart +++ b/lib/controller/functions/launch.dart @@ -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 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 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 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; diff --git a/lib/controller/functions/location_controller.dart b/lib/controller/functions/location_controller.dart index c7227dc..66dd5e6 100755 --- a/lib/controller/functions/location_controller.dart +++ b/lib/controller/functions/location_controller.dart @@ -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()) { + final homeCtrl = Get.find(); + 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 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 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()) { + final homeCtrl = Get.find(); + 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'); diff --git a/lib/controller/home/captin/home_captain_controller.dart b/lib/controller/home/captin/home_captain_controller.dart index 9d5b5c6..3176f58 100755 --- a/lib/controller/home/captin/home_captain_controller.dart +++ b/lib/controller/home/captin/home_captain_controller.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:intaleq_maps/intaleq_maps.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:sefer_driver/constant/box_name.dart'; import 'dart:async'; @@ -29,7 +30,7 @@ class HomeCaptainController extends GetxController { Timer? activeTimer; Map data = {}; bool isHomeMapActive = true; - InlqBitmap carIcon = InlqBitmap.defaultMarker; + InlqBitmap carIcon = InlqBitmap.fromAsset('assets/images/car.png'); bool isMapReadyForCommands = false; bool isLoading = true; late double kazan = 0; @@ -186,7 +187,8 @@ class HomeCaptainController extends GetxController { _heatmapTimer?.cancel(); fetchAndDrawHeatmap(); - _heatmapTimer = Timer.periodic(const Duration(minutes: 5), (timer) { + // Refresh every 15 min instead of 5 to reduce data & battery usage + _heatmapTimer = Timer.periodic(const Duration(minutes: 15), (timer) { if (isHeatmapVisible) { print("🔄 [Heatmap] Periodic refresh started..."); fetchAndDrawHeatmap(); @@ -213,7 +215,8 @@ class HomeCaptainController extends GetxController { } String stringActiveDuration = ''; - + int _fatigueSeconds = 0; // عداد ثواني الإرهاق المؤقت + // ========================================== // ====== 🛡️ Fatigue Monitoring System ====== // ========================================== @@ -230,7 +233,8 @@ class HomeCaptainController extends GetxController { } } - if (totalSecondsToday >= 12 * 3600) { // 12 Hours + if (totalSecondsToday >= 12 * 3600) { + // 12 Hours _forceOfflineDueToFatigue(); throw Exception('Fatigue Limit Exceeded'); } @@ -244,12 +248,15 @@ class HomeCaptainController extends GetxController { activeTimer?.cancel(); update(); } - + Get.defaultDialog( title: 'Safety First 🛑'.tr, - middleText: 'You have been driving for 12 hours. For your safety and compliance, please take a 6-hour break.'.tr, + middleText: + 'You have been driving for 12 hours. For your safety and compliance, please take a 6-hour break.' + .tr, barrierDismissible: false, - titleStyle: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + titleStyle: + const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), confirm: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.red), onPressed: () => Get.back(), @@ -263,30 +270,34 @@ class HomeCaptainController extends GetxController { Get.put(CaptainWalletController()); } totalPoints = Get.find().totalPoints; - + // Toggle Active State isActive = !isActive; - + if (isActive) { try { _checkFatigueBeforeOnline(); // Throws exception if tired - + if (double.parse(totalPoints) > -200) { locationController.startLocationUpdates(); HapticFeedback.heavyImpact(); activeStartTime = DateTime.now(); - + activeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { activeDuration = DateTime.now().difference(activeStartTime!); stringActiveDuration = formatDuration(activeDuration); - - // Increment Fatigue Counter - int totalSeconds = box.read('fatigue_total_seconds') ?? 0; - totalSeconds += 1; - box.write('fatigue_total_seconds', totalSeconds); - if (totalSeconds >= 12 * 3600) { // 12 hours - _forceOfflineDueToFatigue(); + // Increment Fatigue Counter (write to box every 30s) + _fatigueSeconds++; + if (_fatigueSeconds % 30 == 0) { + int totalSeconds = + (box.read('fatigue_total_seconds') ?? 0) + _fatigueSeconds; + box.write('fatigue_total_seconds', totalSeconds); + _fatigueSeconds = 0; + if (totalSeconds >= 12 * 3600) { + // 12 hours + _forceOfflineDueToFatigue(); + } } update(); @@ -311,7 +322,7 @@ class HomeCaptainController extends GetxController { activeTimer?.cancel(); savePeriod(activeDuration); activeDuration = Duration.zero; - + // Save offline time for Fatigue Monitoring reset box.write('fatigue_last_offline', DateTime.now().toIso8601String()); update(); @@ -486,6 +497,7 @@ class HomeCaptainController extends GetxController { // late IntaleqMapController mapHomeCaptainController; IntaleqMapController? mapHomeCaptainController; + LatLng? _lastCameraLoc; // لتتبع آخر موقع حرك الكاميرا // --- FIX 2: Smart Map Creation --- void onMapCreated(IntaleqMapController controller) { @@ -504,7 +516,7 @@ class HomeCaptainController extends GetxController { print( "🔥 [HomeCaptain] Safely moving camera to: ${currentLoc.latitude}"); mapHomeCaptainController!.moveCamera( - CameraUpdate.newLatLngZoom(currentLoc, 15), + CameraUpdate.newLatLngZoom(currentLoc, 17.5), ); } else { print("🔥 [HomeCaptain] Safely moving to default Damascus"); @@ -680,19 +692,30 @@ class HomeCaptainController extends GetxController { checkAndShowBlockDialog(); box.write(BoxName.statusDriverLocation, 'off'); // 2. عدل الليسنر ليصبح مشروطاً - // 2. مؤقت التتبع التلقائي (كل 5 ثوانٍ كما في الكود السابق) - _cameraFollowTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + // Camera follow timer — only moves when the driver has + // actually moved > 15 meters, saving GPU/battery on idle. + _cameraFollowTimer = Timer.periodic(const Duration(seconds: 8), (timer) { if (isClosed || !isHomeMapActive || mapHomeCaptainController == null || + !isMapReadyForCommands || !isActive) return; var loc = locationController.myLocation; if (loc.latitude != 0 && loc.latitude != null && !loc.latitude.isNaN) { + // Skip if driver hasn't moved significantly + if (_lastCameraLoc != null) { + final double dist = Geolocator.distanceBetween( + _lastCameraLoc!.latitude, + _lastCameraLoc!.longitude, + loc.latitude, + loc.longitude, + ); + if (dist < 15) return; + } + _lastCameraLoc = loc; try { - // 🔥 Safety double-check before animating if (mapHomeCaptainController != null) { - print("🔥 [HomeCaptain] Safely moving camera to: ${loc.latitude}"); mapHomeCaptainController?.animateCamera( CameraUpdate.newLatLngZoom(loc, 17.5), ); diff --git a/lib/controller/home/captin/map_driver_controller.dart b/lib/controller/home/captin/map_driver_controller.dart index fbf3b7c..95411ad 100755 --- a/lib/controller/home/captin/map_driver_controller.dart +++ b/lib/controller/home/captin/map_driver_controller.dart @@ -32,6 +32,7 @@ import '../../firebase/notification_service.dart'; import '../../functions/crud.dart'; import '../../functions/location_controller.dart'; import '../../functions/tts.dart'; +import '../../functions/audio_recorder_controller.dart'; import 'behavior_controller.dart'; class MapDriverController extends GetxController @@ -55,34 +56,31 @@ class MapDriverController extends GetxController List polyLines = []; List polyLinesDestination = []; Set markers = {}; - late String passengerLocation; - late String passengerDestination; - late String step0; - late String step1; - late String step2; - late String step3; - late String step4; - late String passengerWalletBurc; - late String timeOfOrder; - late String duration; - late String totalCost; + String passengerLocation = ''; + String passengerDestination = ''; + // [Fix N-4] استبدال 5 متغيرات منفصلة بقائمة واحدة + List steps = ['', '', '', '', '']; + String passengerWalletBurc = ''; + String timeOfOrder = ''; + String duration = ''; + String totalCost = ''; String distance = '0'; String? passengerName; - late String passengerEmail; - late String totalPricePassenger; - late String passengerPhone; - late String rideId; - late String isHaveSteps; + String passengerEmail = ''; + String totalPricePassenger = ''; + String passengerPhone = ''; + String rideId = ''; + String isHaveSteps = ''; String paymentAmount = '0'; - late String paymentMethod; - late String passengerId; - late String driverId; - late String tokenPassenger; + String paymentMethod = ''; + String passengerId = ''; + String driverId = ''; + String tokenPassenger = ''; String durationToPassenger = '100'; - late String walletChecked; - late String direction; - late String durationOfRideValue; - late String status; + String walletChecked = ''; + String direction = ''; + String durationOfRideValue = ''; + String status = ''; int timeWaitingPassenger = 5; //5 miniute bool isPassengerInfoWindow = false; bool isBtnRideBegin = false; @@ -102,6 +100,18 @@ class MapDriverController extends GetxController int remainingTimeToPassenger = 60; int remainingTimeInPassengerLocatioWait = 60; + // [Fix P-1] Gatekeeper لمنع الاستدعاءات المتكررة لـ getRoute() + bool _isRouteRequested = false; + // 🔥 [Fix Double-Init] Debounce لمنع التنفيذ المتزامن/المتسارع لـ argumentLoading() + // StatelessWidget يُسجّل addPostFrameCallback في كل build() — هذا يمنع التنفيذ المتكرر + DateTime? _lastArgumentLoadingTime; + bool _argumentLoadingInProgress = false; + Completer? _mapReadyCompleter; + + // [Fix P-2] Throttle لتقليل تحديثات UI من مستمع GPS + DateTime _lastUIUpdate = DateTime.fromMillisecondsSinceEpoch(0); + static const _uiThrottleMs = 400; + // ─── Navigation & Smoothing ────────────────────────────────────────── AnimationController? _animController; LatLng? smoothedLocation; @@ -123,7 +133,7 @@ class MapDriverController extends GetxController // ───────────────────────────────────────────────────────────────────── bool isDriverNearPassengerStart = false; IntaleqMapController? mapController; - late LatLng myLocation; + LatLng myLocation = LatLng(0, 0); int remainingTimeTimerRideBegin = 60; String stringRemainingTimeRideBegin = ''; String stringRemainingTimeRideBegin1 = ''; @@ -133,9 +143,7 @@ class MapDriverController extends GetxController final zones = []; String canelString = 'yet'; LatLng latLngPassengerLocation = LatLng(0, 0); - late LatLng latLngPassengerDestination = LatLng(0, 0); - - + LatLng latLngPassengerDestination = LatLng(0, 0); // في MapDriverController @@ -154,8 +162,11 @@ class MapDriverController extends GetxController } } + /// [Fix M-5] disposeEverything يجب أن يتوقف عن استدعاء onClose مباشرة + /// لأن GetX يتولى ذلك تلقائياً. نستخدم _stopAllServices فقط. void disposeEverything() { - onClose(); + print("--- KILLING ALL DRIVER TIMERS (via disposeEverything) ---"); + _stopAllServices(); } @override @@ -163,7 +174,10 @@ class MapDriverController extends GetxController print("--- KILLING ALL DRIVER TIMERS ---"); _rideTimer?.cancel(); _rideTimer = null; + _passengerTimer?.cancel(); + _passengerTimer = null; _waitingTimer?.cancel(); + _waitingTimer = null; timer?.cancel(); timer = null; @@ -186,88 +200,180 @@ class MapDriverController extends GetxController controller.animateCamera(CameraUpdate.newLatLngZoom(myLocation, 16)); } - // 🔥 رسم المسار فور جاهزية الخريطة - if (isRideStarted) { - // إذا كانت الرحلة بدأت، ارسم للمكان النهائي - getRoute( + // [Fix P-1] إكمال الـ Completer لتحرير argumentLoading من الانتظار + _mapReadyCompleter?.complete(); + _mapReadyCompleter = null; + + if (!_isRouteRequested) { + // أول مرة — argumentLoading لم تكتمل بعد، ارسم المسار الأولي + // startListeningStepNavigation() محذوفة من هنا عمداً: + // تُستدعى من argumentLoading() بعد اكتمال getRoute() وتحميل البيانات + if (isRideStarted) { + getRoute( origin: myLocation, destination: latLngPassengerDestination, - routeColor: Colors.blue); - } else { - // إذا كان السائق ذاهب للراكب - getRoute( + routeColor: Colors.blue, + ); + } else { + getRoute( origin: myLocation, destination: latLngPassengerLocation, - routeColor: Colors.yellow); + routeColor: Colors.yellow, + ); + } + } else { + // 🔥 [Fix Rebuild] إعادة بناء الخريطة (مثلاً تغيير theme أو update()) + // أعد رسم البوليلاين الموجود وأعد تشغيل الملاحة إذا لزم + _redrawExistingPolylines(); + if (_navigationTimer == null || !_navigationTimer!.isActive) { + startListeningStepNavigation(); + } + } + } + + /// إعادة رسم البوليلاين الموجود بعد إعادة بناء الخريطة (GetBuilder rebuild) + /// يُستدعى فقط من onMapCreated() عند _isRouteRequested == true + void _redrawExistingPolylines() { + if (upcomingPathPoints.isEmpty) { + // لا يوجد مسار محمّل — لا شيء نرسمه + return; } - // بدء الاستماع للموقع للملاحة وتحديث الماركر - startListeningStepNavigation(); + final remaining = upcomingPathPoints.sublist(_lastTraveledIndex); + polyLines.clear(); + polyLines.add( + Polyline( + polylineId: const PolylineId("upcoming_route"), + points: remaining, + width: 8, + color: isRideStarted ? Colors.blue : Colors.yellow, + ), + ); + + // إعادة رسم المسار المقطوع (رمادي) إذا وجد + if (_lastTraveledIndex > 0) { + final traveled = upcomingPathPoints.sublist(0, _lastTraveledIndex + 1); + polyLines.add( + Polyline( + polylineId: const PolylineId('traveled_route'), + points: traveled, + color: Colors.grey.withValues(alpha: 0.8), + width: 7, + zIndex: 1, + ), + ); + } + + Log.print( + "🔄 [onMapCreated] Redrawn ${polyLines.length} polylines after map rebuild."); + update(); } bool isCameraLocked = true; // للتحكم في تتبع الكاميرا + Timer? _cameraLockTimer; - Future startListeningStepNavigation() async { - _posSub?.cancel(); - - _posSub = Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.bestForNavigation, // دقة عالية للملاحة - distanceFilter: 5, // تحديث كل 5 أمتار - ), - ).listen((position) { - LatLng newLoc = LatLng(position.latitude, position.longitude); - - // فلتر الاهتزاز البسيط (Jitter Filter) - if (_lastRecordedLocation != null) { - double dist = Geolocator.distanceBetween( - newLoc.latitude, - newLoc.longitude, - _lastRecordedLocation!.latitude, - _lastRecordedLocation!.longitude); - if (dist < 2.0) return; // تجاهل الحركة الطفيفة - } - - _lastRecordedLocation = newLoc; - myLocation = newLoc; - double heading = position.heading; - double speedKmh = position.speed * 3.6; + void onUserMapInteraction() { + if (isCameraLocked) { + isCameraLocked = false; + update(); + } + _cameraLockTimer?.cancel(); + _cameraLockTimer = Timer(const Duration(seconds: 5), () { if (!isClosed) { - // 1. تحديث الماركر - updateMarker(); - - // 2. تحديث المسار (Smart Snapping) - if (upcomingPathPoints.isNotEmpty) { - _updateTraveledPolylineSmart(myLocation); + isCameraLocked = true; + if (myLocation != null && mapController != null) { + if (Get.isRegistered()) { + final locCtrl = Get.find(); + final double speedKmh = locCtrl.speed * 3.6; + final double bearing = speedKmh > 5 ? locCtrl.heading : 0.0; + _animateCameraToNavigationMode(myLocation!, bearing); + } else { + _animateCameraToNavigationMode(myLocation!, 0.0); + } } - - // 3. تحريك الكاميرا (Navigation Mode) - // فقط إذا كان "القفل" مفعلاً والسرعة > 5 كم (لتجنب الدوران العشوائي عند الوقوف) - if (isCameraLocked && mapController != null) { - double bearing = (speedKmh > 5) ? heading : 0.0; - // ملاحظة: يمكنك تخزين آخر bearing معروف واستخدامه عند التوقف لتحسين التجربة - _animateCameraToNavigationMode(newLoc, bearing); - } - - // 4. فحص التعليمات الصوتية - checkForNextStep(myLocation); - - // 5. فحص الوصول للوجهة (للإشعار) - checkDestinationProximity(); - update(); } }); } + Future startListeningStepNavigation() async { + // Cancel any previous listener + _navigationTimer?.cancel(); + + // Reuse LocationController's GPS stream instead of opening a + // second GPS channel — eliminates duplicate battery/CPU drain. + // Lightweight Timer-based polling at 500ms (was a full GPS stream). + _navigationTimer = Timer.periodic( + const Duration(milliseconds: 500), + (_) { + if (isClosed || mapController == null) return; + + final locCtrl = Get.isRegistered() + ? Get.find() + : null; + if (locCtrl == null || locCtrl.myLocation.latitude == 0) return; + + final LatLng newLoc = locCtrl.myLocation; + final double heading = locCtrl.heading; + final double speedKmh = locCtrl.speed * 3.6; + + // Jitter filter + if (_lastRecordedLocation != null) { + final double dist = Geolocator.distanceBetween( + newLoc.latitude, + newLoc.longitude, + _lastRecordedLocation!.latitude, + _lastRecordedLocation!.longitude, + ); + if (dist < 3.0) return; + } + + _lastRecordedLocation = newLoc; + myLocation = newLoc; + + _oldLoc = smoothedLocation ?? newLoc; + _targetLoc = newLoc; + _oldHeading = smoothedHeading; + _targetHeading = speedKmh > 0.5 ? heading : _oldHeading; + _animController?.forward(from: 0.0); + + if (!isClosed) { + updateMarker(); + if (upcomingPathPoints.isNotEmpty) { + _updateTraveledPolylineSmart(myLocation); + } + if (isCameraLocked) { + final double bearing = speedKmh > 5 ? heading : 0.0; + _animateCameraToNavigationMode(newLoc, bearing); + } + checkForNextStep(myLocation); + checkDestinationProximity(); + + final now = DateTime.now(); + if (now.difference(_lastUIUpdate).inMilliseconds > _uiThrottleMs) { + _lastUIUpdate = now; + update(); + } + } + }, + ); + } + void changeStatusDriver() { status = 'On'; update(); } + AudioRecorderController _getAudioController() { + if (!Get.isRegistered()) { + Get.put(AudioRecorderController(), permanent: true); + } + return Get.find(); + } + void changeDriverEndPage() { - remainingTimeTimerRideBegin < 60 ? driverEndPage = 160 : 100; + driverEndPage = remainingTimeTimerRideBegin < 60 ? 160 : 100; update(); } @@ -275,19 +381,13 @@ class MapDriverController extends GetxController // mapController!.takeSnapshot(); } - @override - void dispose() { - print("--- KILLING ALL DRIVER TIMERS ---"); - _stopAllServices(); - super.dispose(); - } + // [Fix P-3] إزالة dispose() المكررة — GetX يستدعي onClose() تلقائياً void _stopAllServices() { _rideTimer?.cancel(); _passengerTimer?.cancel(); _waitingTimer?.cancel(); _posSub?.cancel(); - // mapController?.dispose(); } Future openGoogleMapFromDriverToPassenger() async { @@ -297,8 +397,9 @@ class MapDriverController extends GetxController var startLat = Get.find().myLocation.latitude; var startLng = Get.find().myLocation.longitude; + /// [Fix N-1] تصحيح رابط Google Maps: & → ? String url = - 'https://www.google.com/maps/dir/$startLat,$startLng/$endLat,$endLng/&directionsmode=driving'; + 'https://www.google.com/maps/dir/$startLat,$startLng/$endLat,$endLng/?directionsmode=driving'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url)); } else { @@ -320,15 +421,37 @@ class MapDriverController extends GetxController update(); } -// متغير لمنع التكرار + // متغير لمنع التكرار bool _isCancelReceived = false; /// **معالجة إلغاء الراكب الموحدة (Gatekeeper)** - void processRideCancelledByPassenger(String reason, - {String source = "Unknown"}) { - if (_isCancelReceived) return; // تم المعالجة مسبقاً + void processRideCancelledByPassenger( + String reason, { + String source = "Unknown", + }) { + if (_isCancelReceived) return; _isCancelReceived = true; + // [Fix 2] إيقاف جميع التايمرات النشطة فوراً عند إلغاء الراكب. + // كانت هذه التايمرات تستمر حتى onClose() مما يسبب تسرب في الموارد. + _rideTimer?.cancel(); + _rideTimer = null; + _passengerTimer?.cancel(); + _passengerTimer = null; + _waitingTimer?.cancel(); + _waitingTimer = null; + Log.print("🛑 All ride timers cancelled due to passenger cancellation."); + + try { + final AudioRecorderController audioRecorderController = + _getAudioController(); + if (audioRecorderController.isRecording) { + audioRecorderController.stopRecording(); + } + } catch (e) { + Log.print("Error stopping audio recording: $e"); + } + Log.print("🚫 Ride Cancelled by Passenger via $source. Reason: $reason"); // 1. إيقاف التوجيه والتايمرات @@ -343,7 +466,7 @@ class MapDriverController extends GetxController // 3. عرض رسالة للسائق if (Get.isDialogOpen == true) { - navigatorKey.currentState?.pop(); + Get.back(); } Get.defaultDialog( @@ -362,7 +485,9 @@ class MapDriverController extends GetxController ), confirm: ElevatedButton( onPressed: () { - navigatorKey.currentState?.pop(); // إغلاق الديالوج + Get.back(); // إغلاق الديالوج + Get.delete< + HomeCaptainController>(); // clear old controller to fix map generation Get.offAll(() => HomeCaptain()); // العودة للرئيسية }, child: Text("OK".tr), @@ -375,19 +500,22 @@ class MapDriverController extends GetxController Future cancelTripFromDriverAfterApplied() async { if (formKeyCancel.currentState!.validate()) { - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); try { // 1. استدعاء السيرفر var response = await CRUD().post( - link: "${AppLink.ride}/rides/cancel_ride_by_driver.php", - payload: { - "ride_id": (rideId).toString(), - "driver_id": box.read(BoxName.driverID).toString(), - "reason": (cancelTripCotroller.text) ?? '', - "passenger_token": tokenPassenger.toString(), - }); + link: "${AppLink.ride}/rides/cancel_ride_by_driver.php", + payload: { + "ride_id": (rideId).toString(), + "driver_id": box.read(BoxName.driverID).toString(), + "reason": (cancelTripCotroller.text) ?? '', + "passenger_token": tokenPassenger.toString(), + }, + ); if (response['status'] == 'success') { // 🔥🔥 معالجة الحظر (The Penalty Logic) 🔥🔥 @@ -404,23 +532,30 @@ class MapDriverController extends GetxController box.write(BoxName.statusDriverLocation, 'blocked'); // عرض رسالة العقوبة - Get.snackbar( - "Your account is temporarily restricted ⛔".tr, - "Due to excessive cancellations (3 times), receiving orders has been suspended for 4 hours." - .tr, - duration: Duration(seconds: 8), - backgroundColor: Colors.red, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM); + mySnackeBarError( + "Due to excessive cancellations (3 times), receiving orders has been suspended for 4 hours." + .tr, + ); } else { // تحذير فقط int count = response['cancel_count'] ?? 0; - Get.snackbar("تنبيه", - "لقد ألغيت $count رحلات اليوم. الوصول لـ 3 سيعرضك للإيقاف المؤقت.", - backgroundColor: Colors.orange); + mySnackbarWarning( + "لقد ألغيت $count رحلات اليوم. الوصول لـ 3 سيعرضك للإيقاف المؤقت." + .tr, + ); } // تنظيف البيانات + try { + final AudioRecorderController audioRecorderController = + _getAudioController(); + if (audioRecorderController.isRecording) { + await audioRecorderController.stopRecording(); + } + } catch (e) { + Log.print("Error stopping audio recording: $e"); + } + box.remove('cached_trip_route'); box.remove(BoxName.rideArgumentsFromBackground); box.remove(BoxName.rideArguments); box.remove(BoxName.passengerID); @@ -440,23 +575,27 @@ class MapDriverController extends GetxController Get.find().getRefusedOrderByCaptain(); } else { // في حال لم يكن مسجل (جاي من background) - Get.put(HomeCaptainController()).getRefusedOrderByCaptain(); + Get.put(HomeCaptainController(), permanent: true) + .getRefusedOrderByCaptain(); } if (Get.isDialogOpen == true) { - navigatorKey.currentState?.pop(); + Get.back(); } + Get.delete< + HomeCaptainController>(); // clear old controller to fix map generation Get.offAll( - () => HomeCaptain()); // العودة للرئيسية ليتم تطبيق الحظر هناك + () => HomeCaptain(), + ); // العودة للرئيسية ليتم تطبيق الحظر هناك } else { if (Get.isDialogOpen == true) { - navigatorKey.currentState?.pop(); + Get.back(); } - Get.snackbar("Error", "Failed to cancel ride"); + mySnackeBarError("Failed to cancel ride".tr); } } catch (e) { if (Get.isDialogOpen == true) { - navigatorKey.currentState?.pop(); + Get.back(); } Log.print("Error: $e"); } @@ -493,8 +632,9 @@ class MapDriverController extends GetxController int.tryParse(durationToPassenger.toString()) ?? 60; // استخدام DateTime لضمان دقة الوقت وعدم التأثر ببطء الجهاز - final DateTime endTime = - DateTime.now().add(Duration(seconds: totalDuration)); + final DateTime endTime = DateTime.now().add( + Duration(seconds: totalDuration), + ); // 4. بدء التايمر الدوري _passengerTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -556,8 +696,9 @@ class MapDriverController extends GetxController final int totalDurationSeconds = timeWaitingPassenger * 60; // 3. تحديد وقت الانتهاء المتوقع (للدقة) - final DateTime endTime = - DateTime.now().add(Duration(seconds: totalDurationSeconds)); + final DateTime endTime = DateTime.now().add( + Duration(seconds: totalDurationSeconds), + ); Log.print("⏳ Driver Waiting Timer Started: $totalDurationSeconds seconds"); @@ -641,8 +782,11 @@ class MapDriverController extends GetxController return false; } - // 2. تسجيل العملية (تسجيل آمن) - if (isSocialPressed == true && passengerId != null && rideId != null) { + // [Fix M-7] استخدام isNotEmpty بدلاً من != null لأن passengerId و rideId من نوع String غير قابل للـ null + // لو كانا فارغين '' يمر الشرط ويُرسل بيانات فارغة للسيرفر + if (isSocialPressed == true && + passengerId.isNotEmpty && + rideId.isNotEmpty) { box.write(BoxName.statusDriverLocation, 'off'); // لا نستخدم await هنا لكي لا نؤخر فتح الهاتف @@ -667,7 +811,7 @@ class MapDriverController extends GetxController } } -// دالة مساعدة لحماية التطبيق من كراش الخرائط + // دالة مساعدة لحماية التطبيق من كراش الخرائط Future safeAnimateCamera(CameraUpdate cameraUpdate) async { if (isClosed || mapController == null) return; try { @@ -678,9 +822,10 @@ class MapDriverController extends GetxController } Future getDriverScam() async { - var res = await CRUD().post(link: AppLink.getDriverScam, payload: { - 'driverID': box.read(BoxName.driverID), - }); + var res = await CRUD().post( + link: AppLink.getDriverScam, + payload: {'driverID': box.read(BoxName.driverID)}, + ); if (res == 'failure') { box.write(BoxName.statusDriverLocation, 'off'); @@ -714,11 +859,11 @@ class MapDriverController extends GetxController // --- FIX END --- } - void startRideFromStartApp() { + void startRideFromStartApp() async { // if (box.read(BoxName.rideStatus) == 'Begin') { changeRideToBeginToPassenger(); isPassengerInfoWindow = false; - isRideStarted = true; + isRideStarted = true; // يجب أن يكون true قبل getRoute لتفعيل وضع الملاحة isRideFinished = false; remainingTimeInPassengerLocatioWait = 0; timeWaitingPassenger = 0; @@ -726,7 +871,48 @@ class MapDriverController extends GetxController update(); // } + // Immediately fetch high-accuracy location for navigation origin + try { + Position currentPos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.bestForNavigation); + myLocation = LatLng(currentPos.latitude, currentPos.longitude); + smoothedLocation = myLocation; + smoothedHeading = currentPos.heading; + } catch (e) { + Log.print( + "Error getting current position on start ride from start app: $e"); + } + + // التحقق من صحة إحداثيات الهدف قبل الرسم + if (latLngPassengerDestination.latitude == 0 || + latLngPassengerDestination.longitude == 0) { + Log.print( + "⚠️ startRideFromStartApp: destination is (0,0) — skipping getRoute"); + return; + } + + // ── إعادة رسم المسار إلى وجهة الراكب النهائية ──────────────── + await getRoute( + origin: myLocation.latitude == 0 + ? Get.find().myLocation + : myLocation, + destination: latLngPassengerDestination, + routeColor: Colors.blue, + ); + + updateMarker(); + + // بدء الخدمات (الملاحة والعداد) + await startListeningStepNavigation(); + rideIsBeginPassengerTimer(); + try { + final AudioRecorderController audioRecorderController = + _getAudioController(); + audioRecorderController.startRecording(rideId: rideId.toString()); + } catch (e) { + Log.print("Error starting audio recording: $e"); + } } Position? currentPosition; @@ -745,8 +931,10 @@ class MapDriverController extends GetxController /// 3. معالجة فشل السيرفر (Revert Logic) لضمان عدم ضياع حالة الرحلة. Future startRideFromDriver() async { // 1. إظهار مؤشر تحميل فوري (Blocking) - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); try { // 2. التحقق من المسافة (Async) @@ -773,9 +961,46 @@ class MapDriverController extends GetxController box.write(BoxName.statusDriverLocation, 'on'); box.write(BoxName.rideStatus, 'Begin'); + // Immediately fetch high-accuracy location for navigation origin + try { + Position currentPos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.bestForNavigation); + myLocation = LatLng(currentPos.latitude, currentPos.longitude); + smoothedLocation = myLocation; + smoothedHeading = currentPos.heading; + } catch (e) { + Log.print("Error getting current position on start ride: $e"); + } + + // 🔥 [Fix Polyline] مسح المسار الأصفر (للراكب) فوراً قبل رسم الأزرق (للوجهة) + // بدون هذا قد يبقى الخط الأصفر ظاهراً إذا تأخر getRoute() أو أُعيد بناء الخريطة + clearPolyline(); + + // ── إعادة رسم المسار إلى وجهة الراكب النهائية ──────────────── + String? cachedRoute = box.read('cached_trip_route'); + + await getRoute( + origin: myLocation.latitude == 0 + ? Get.find().myLocation + : myLocation, + destination: latLngPassengerDestination, + routeColor: Colors + .black, // Color for the actual trip (black as user prefers solid) + cachedResponse: cachedRoute, + ); + + updateMarker(); + // بدء الخدمات (الملاحة والعداد) await startListeningStepNavigation(); rideIsBeginPassengerTimer(); + try { + final AudioRecorderController audioRecorderController = + _getAudioController(); + audioRecorderController.startRecording(rideId: rideId.toString()); + } catch (e) { + Log.print("Error starting audio recording: $e"); + } // --- ب) تحديث الواجهة (Targeted Updates Only) --- // نحدث فقط الأجزاء التي تتغير، بدلاً من إعادة رسم الخريطة كاملة @@ -786,18 +1011,33 @@ class MapDriverController extends GetxController // --- ج) إرسال الطلب للسيرفر (Background) --- // لا ننتظر النتيجة لتعطيل الواجهة، لكن نعالج الخطأ إن حدث CRUD().post( - link: "${AppLink.server}/ride/rides/start_ride.php", - payload: { - 'id': rideId.toString(), - 'driver_id': box.read(BoxName.driverID).toString(), - 'status': 'Begin', - "passengerToken": tokenPassenger.toString() - }).then((response) { - // هنا يمكن التحقق مما إذا كان السيرفر قد رفض الطلب (اختياري) + link: "${AppLink.server}/ride/rides/start_ride.php", + payload: { + 'id': rideId.toString(), + 'driver_id': box.read(BoxName.driverID).toString(), + 'status': 'Begin', + "passengerToken": tokenPassenger.toString(), + }, + ).then((response) { if (response['status'] == 'failure') { - // Revert logic if needed (نادر الحدوث) Log.print("Server failed to start ride!"); + isRideStarted = false; + isRideBegin = false; + box.write(BoxName.rideStatus, 'Apply'); + isPassengerInfoWindow = true; + stopListeningStepNavigation(); + mySnackeBarError("Failed to start ride. Please try again."); + update(); } + }).catchError((error) { + Log.print("Network/Server error starting ride: $error"); + isRideStarted = false; + isRideBegin = false; + box.write(BoxName.rideStatus, 'Apply'); + isPassengerInfoWindow = true; + stopListeningStepNavigation(); + mySnackeBarError("Network error. Failed to start ride."); + update(); }); } else { // --- حالة الرفض (بعيد جداً) --- @@ -806,8 +1046,8 @@ class MapDriverController extends GetxController builder: (context) => AlertDialog( title: Text('You are far from passenger location'.tr), content: Text( - 'Please go closer to the passenger location (less than 150m)' - .tr), + 'Please go closer to the passenger location (less than 150m)'.tr, + ), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -823,7 +1063,7 @@ class MapDriverController extends GetxController Get.back(); } Log.print("Error starting ride: $e"); - Get.snackbar("Error", "Could not start ride. Please check internet."); + mySnackeBarError("Could not start ride. Please check internet.".tr); } } @@ -838,12 +1078,19 @@ class MapDriverController extends GetxController } double speedoMeter = 0; - void updateLocation() async { - try { - for (var i = 0; i < remainingTimeTimerRideBegin; i++) { - await Future.delayed(const Duration(seconds: 3)); + Timer? _updateLocationTimer; - await safeAnimateCamera( + /// [Fix C-1] استبدال for loop الحلقة التكرارية بـ Timer.periodic + /// لمنع تسرب الذاكرة وufeni الـ Stack Overflow من الاستدعاء الذاتي المتكرر. + void startUpdateLocationTimer() { + _updateLocationTimer?.cancel(); + _updateLocationTimer = Timer.periodic(const Duration(seconds: 3), (timer) { + if (isClosed || !isRideBegin) { + timer.cancel(); + return; + } + try { + safeAnimateCamera( CameraUpdate.newCameraPosition( CameraPosition( bearing: Get.find().heading, @@ -852,60 +1099,79 @@ class MapDriverController extends GetxController ), ), ); - // }); update(); + } catch (error) { + debugPrint('Error listening to GPS: $error'); } - - // Stop listening after ride finishes - if (!isRideBegin) {} - } catch (error) { - debugPrint('Error listening to GPS: $error'); - // Handle GPS errors gracefully - } - - // Periodically call updateLocation again - await Future.delayed(const Duration(seconds: 1)); - updateLocation(); + }); } - calculateDistanceBetweenDriverAndPassengerLocation() async { - Get.put(LocationController()); - var res = await CRUD().get( + void stopUpdateLocationTimer() { + _updateLocationTimer?.cancel(); + _updateLocationTimer = null; + } + + Future calculateDistanceBetweenDriverAndPassengerLocation() async { + final locationController = Get.isRegistered() + ? Get.find() + : Get.put(LocationController()); + try { + var res = await CRUD().get( link: AppLink.getLatestLocationPassenger, - payload: {'rideId': (rideId)}); - if (res != 'failure') { - var passengerLatestLocationString = jsonDecode(res)['message']; + payload: {'rideId': (rideId)}, + ).timeout(const Duration(seconds: 5)); + if (res != 'failure') { + var passengerLatestLocationString = jsonDecode(res)['message']; - double distance2 = Geolocator.distanceBetween( - double.parse(passengerLatestLocationString[0]['lat'].toString()), - double.parse(passengerLatestLocationString[0]['lng'].toString()), - Get.find().myLocation.latitude, - Get.find().myLocation.longitude, - ); - return distance2; - } else { - double distance2 = Geolocator.distanceBetween( - latLngPassengerLocation.latitude, - latLngPassengerLocation.longitude, - Get.find().myLocation.latitude, - Get.find().myLocation.longitude, - ); - return distance2; - } + double distance2 = Geolocator.distanceBetween( + double.parse(passengerLatestLocationString[0]['lat'].toString()), + double.parse(passengerLatestLocationString[0]['lng'].toString()), + locationController.myLocation.latitude, + locationController.myLocation.longitude, + ); + return distance2; + } + } catch (_) {} + + // Fallback to local coordinates in case of error/timeout + double distance2 = Geolocator.distanceBetween( + latLngPassengerLocation.latitude, + latLngPassengerLocation.longitude, + locationController.myLocation.latitude, + locationController.myLocation.longitude, + ); + return distance2; } - /// دالة مساعدة لحساب التكلفة (Logic Helper) + // [Fix C-3] دالة مساعدة مشتركة لتحليل المسافة النصية إلى متر + // تُستخدم في كلا الدالتين: finishRideFromDriver و _validateTripDistance + double _parseDistanceToMeters() { + String cleanDistance = distance.toString().replaceAll( + RegExp(r'[^0-9.]'), + '', + ); + if (cleanDistance.isEmpty) cleanDistance = "0.0"; + double numericDistance = double.parse(cleanDistance); + + bool isMeters = distance.toString().contains('m') && + !distance.toString().contains('km'); + if (!isMeters && numericDistance > 100) isMeters = true; + + return isMeters ? numericDistance : numericDistance * 1000; + } + + /// [Fix M-6] دالة مساعدة لحساب التكلفة + /// ⚠️ ملاحظة: distanceBetweenDriverAndPassengerWhenConfirm بالكيلومتر double _calculateWaitingCost() { bool isEgypt = box.read(BoxName.countryCode) == 'Egypt'; double waitingMinutes = 5.0; if (isEgypt) { - // معادلة مصر: (المسافة * 0.08) + (5 دقائق * 1) + // معادلة مصر: (المسافة كم * 0.08) + (5 دقائق * 1) return (distanceBetweenDriverAndPassengerWhenConfirm * 0.08) + (waitingMinutes * 1.0); } else { - // معادلة الأردن/أخرى: (المسافة * 11) + (5 دقائق * 0.06) - // تأكد من منطق الوحدات هنا (هل المسافة بالكيلومتر أم بالمتر؟) + // معادلة الأردن/أخرى: (المسافة كم * 11) + (5 دقائق * 0.06) return (distanceBetweenDriverAndPassengerWhenConfirm * 11) + (waitingMinutes * 0.06); } @@ -913,8 +1179,10 @@ class MapDriverController extends GetxController Future addWaitingTimeCostFromPassengerToDriverWallet() async { // ... (فحص المسافة واللودينج كما هو) ... - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); try { // 1. حساب التكلفة @@ -924,44 +1192,46 @@ class MapDriverController extends GetxController // 2. تحديث البيانات التشغيلية (Main Server) // هذا الطلب لا يحتاج توكنات مالية، فقط تحديث حالة await CRUD().post( - link: "${AppLink.ride}/rides/update_ride_cancel_wait.php", - payload: { - 'ride_id': rideId.toString(), - 'driver_id': box.read(BoxName.driverID).toString(), - }); + link: "${AppLink.ride}/rides/update_ride_cancel_wait.php", + payload: { + 'ride_id': rideId.toString(), + 'driver_id': box.read(BoxName.driverID).toString(), + }, + ); // 3. توليد التوكنات (Server-Side Logic Security) // نحتاج توكن للسائق وتوكن للراكب final tokens = await Future.wait([ generateTokenDriver(costOfWaiting.toString()), - generateTokenPassenger(costForPassenger.toString()) + generateTokenPassenger(costForPassenger.toString()), ]); // 4. تنفيذ العملية المالية الموحدة (Payment Server) var paymentResponse = await CRUD().postWallet( - link: - "${AppLink.paymentServer}/ride/passengerWallet/process_wait_compensation.php", // الرابط الجديد - payload: { - 'ride_id': rideId.toString(), - 'driver_id': box.read(BoxName.driverID).toString(), - 'passenger_id': passengerId.toString(), - 'amount': costOfWaiting.toString(), // المبلغ الموجب - 'amount_passenger': costForPassenger.toString(), // المبلغ السالب - 'token_driver': tokens[0], - 'token_passenger': tokens[1], - }); + link: + "${AppLink.paymentServer}/ride/passengerWallet/process_wait_compensation.php", // الرابط الجديد + payload: { + 'ride_id': rideId.toString(), + 'driver_id': box.read(BoxName.driverID).toString(), + 'passenger_id': passengerId.toString(), + 'amount': costOfWaiting.toString(), // المبلغ الموجب + 'amount_passenger': costForPassenger.toString(), // المبلغ السالب + 'token_driver': tokens[0], + 'token_passenger': tokens[1], + }, + ); if (paymentResponse['status'] == 'success') { // النجاح if (Get.isDialogOpen == true) Get.back(); - Get.snackbar( - 'Compensation Received'.tr, + mySnackbarSuccess( '${'You gained'.tr} ${costOfWaiting.toStringAsFixed(2)} ${'in your wallet'.tr}', - backgroundColor: AppColor.deepPurpleAccent, ); box.write(BoxName.statusDriverLocation, 'off'); + Get.delete< + HomeCaptainController>(); // clear old controller to fix map generation Get.offAll(() => HomeCaptain()); } else { throw Exception("Payment Transaction Failed"); @@ -969,7 +1239,7 @@ class MapDriverController extends GetxController } catch (e) { if (Get.isDialogOpen == true) Get.back(); Log.print("Error: $e"); - Get.snackbar("Error", "Transaction failed, please try again."); + mySnackeBarError("Transaction failed, please try again.".tr); } } @@ -987,17 +1257,8 @@ class MapDriverController extends GetxController /// [isFromSlider]: إذا كانت القيمة true، فهذا يعني أن السائق سحب الشريط /// وبالتالي هو موافق ضمنياً، فلا داعي لعرض ديالوج "هل أنت متأكد؟" Future finishRideFromDriver({bool isFromSlider = false}) async { - // 1. تحويل مسافة الرحلة الكلية - - // 1. نقوم أولاً بتنظيف النص من أي حروف (مثل 'km' أو 'كم') ونبقي الأرقام والنقطة فقط - String cleanDistance = - distance.toString().replaceAll(RegExp(r'[^0-9.]'), ''); - -// 2. حماية إضافية: إذا كان النص فارغاً بعد التنظيف نعتبره صفر - if (cleanDistance.isEmpty) cleanDistance = "0.0"; - -// 3. الآن التحويل سيتم بنجاح بدون أخطاء - final double totalTripDistanceMeters = double.parse(cleanDistance) * 1000; + // [Fix C-3] استخدام الدالة المساعدة المشتركة لتحليل المسافة + final double totalTripDistanceMeters = _parseDistanceToMeters(); // 2. حساب المسافة المقطوعة final double displacementMeters = Geolocator.distanceBetween( latLngPassengerLocation.latitude, @@ -1020,15 +1281,11 @@ class MapDriverController extends GetxController // إذا جاء من السلايدر، نفذ فوراً finishRideFromDriver1(); } else { - // إذا جاء من زر عادي (إن وجد)، اطلب التأكيد - MyDialog().getDialog( - 'Are you sure to exit ride?'.tr, - '', - () { - Get.back(); - finishRideFromDriver1(); - }, - ); + // [Fix C-3 v2] بعد التأكيد، نمرّر isFromSlider=true لتجنب الديالوج المكرر + MyDialog().getDialog('Are you sure to exit ride?'.tr, '', () { + Get.back(); + finishRideFromDriver1(isFromSlider: true); + }); } } else { // ❌ الحالة مرفوضة: المسافة غير كافية @@ -1043,29 +1300,31 @@ class MapDriverController extends GetxController () => Get.back(), ); - await textToSpeechController - .speakText("You haven't moved sufficiently!".tr); + await textToSpeechController.speakText( + "You haven't moved sufficiently!".tr, + ); } } String paymentToken = ''; Future generateTokenDriver(String amount) async { - var res = - await CRUD().postWallet(link: AppLink.addPaymentTokenDriver, payload: { - 'driverID': box.read(BoxName.driverID).toString(), - 'amount': amount.toString(), - }); + var res = await CRUD().postWallet( + link: AppLink.addPaymentTokenDriver, + payload: { + 'driverID': box.read(BoxName.driverID).toString(), + 'amount': amount.toString(), + }, + ); var d = (res); return d['message']; } String paymentTokenPassenger = ''; Future generateTokenPassenger(String amount) async { - var res = await CRUD() - .postWallet(link: AppLink.addPaymentTokenPassenger, payload: { - 'passengerId': passengerId, - 'amount': amount.toString(), - }); + var res = await CRUD().postWallet( + link: AppLink.addPaymentTokenPassenger, + payload: {'passengerId': passengerId, 'amount': amount.toString()}, + ); var d = (res); return d['message']; } @@ -1095,11 +1354,23 @@ class MapDriverController extends GetxController if (!await _validateTripDistance(isFromSlider)) return; // 2. إظهار لودينج (Blocking) لمنع التكرار - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); try { - // 3. تحديث الحالة المحلية لمنع أي تفاعلات أخرى + try { + final AudioRecorderController audioRecorderController = + _getAudioController(); + if (audioRecorderController.isRecording) { + await audioRecorderController.stopRecording(); + } + } catch (e) { + Log.print("Error stopping audio recording: $e"); + } + + // تحديث الحالة المحلية لمنع أي تفاعلات أخرى أثناء الطلب isRideFinished = true; isRideStarted = false; isPriceWindow = false; @@ -1108,83 +1379,59 @@ class MapDriverController extends GetxController box.remove(BoxName.passengerID); box.remove(BoxName.rideId); - // 4. حساب التكلفة النهائية (Logic) - _calculateFinalTotalCost(); - - // 5. تجهيز البيانات (Payloads) - final rideUpdatePayload = { + // تجهيز البيانات الخام الموحدة للسيرفر ليقوم بمعالجة الدفع والإنهاء معاً بنظام المعاملة الواحدة + final finishPayload = { 'rideId': rideId.toString(), 'driver_id': box.read(BoxName.driverID).toString(), + 'passengerId': passengerId.toString(), 'status': 'Finished', - 'price': totalCost, - 'passengerId': passengerId.toString(), - 'driver_token': box.read(BoxName.tokenDriver).toString(), - 'passengerToken': tokenPassenger.toString(), - }; - - // توليد توكن الدفع أولاً - final String paymentAuthToken = - await generateTokenDriver(paymentAmount.toString()); - - final paymentProcessingPayload = { - 'rideId': rideId.toString(), - 'driverId': box.read(BoxName.driverID).toString(), - 'passengerId': passengerId.toString(), - 'paymentAmount': paymentAmount, - 'paymentMethod': paymentMethod, + 'actualDistance': distance.toString(), + 'actualDuration': duration.toString(), 'walletChecked': walletChecked.toString(), 'passengerWalletBurc': passengerWalletBurc.toString(), - 'authToken': paymentAuthToken, + 'passengerToken': tokenPassenger.toString(), + 'driver_token': box.read(BoxName.tokenDriver).toString(), }; - // 6. التنفيذ المتوازي (Parallel Execution) - الأسرع - final results = await Future.wait([ - CRUD().post( - link: "${AppLink.ride}/rides/finish_ride_updates.php", - payload: rideUpdatePayload), - CRUD().postWallet( - link: - "${AppLink.paymentServer}/ride/payment/process_ride_payments.php", - payload: paymentProcessingPayload), - ]); + // إرسال طلب واحد موحد للسيرفر الرئيسي + final response = await CRUD().post( + link: "${AppLink.ride}/rides/finish_ride_updates.php", + payload: finishPayload, + ); - final rideRes = results[0]; - final payRes = results[1]; - - // 7. التحقق من النجاح - if (rideRes['status'] == 'success' && payRes['status'] == 'success') { - // تنظيف البيانات + if (response['status'] == 'success') { + // تنظيف البيانات محلياً عند النجاح الكامل box.remove(BoxName.rideArguments); box.remove(BoxName.rideArgumentsFromBackground); + box.remove('cached_trip_route'); // إغلاق اللودينج if (Get.isDialogOpen == true) Get.back(); // إرسال تقرير السلوك (Fire and forget) - Get.put(DriverBehaviorController()) - .sendSummaryToServer(driverId, rideId); + Get.put( + DriverBehaviorController(), + ).sendSummaryToServer(driverId, rideId); - // الانتقال لصفحة التقييم - Get.off(() => RatePassenger(), arguments: { - 'passengerId': passengerId, - 'rideId': rideId, - 'price': paymentAmount.toString(), - 'walletChecked': walletChecked.toString() ?? 'false' - }); + // الانتقال لصفحة التقييم بالسعر الذي حدده وحسبه السيرفر بأمان + Get.off( + () => RatePassenger(), + arguments: { + 'passengerId': passengerId, + 'rideId': rideId, + 'price': response['price']?.toString() ?? paymentAmount.toString(), + 'walletChecked': walletChecked.toString() ?? 'false', + }, + ); } else { - throw Exception( - "Server Error: Ride=${rideRes['status']}, Payment=${payRes['status']}"); + throw Exception(response['error'] ?? "Unknown backend error"); } } catch (e) { - // 8. معالجة الأخطاء (Revert State) if (Get.isDialogOpen == true) Get.back(); // إغلاق اللودينج - Log.print("Error finishing ride: $e"); - Get.snackbar( - "Error".tr, "Failed to finish ride. Please check internet.".tr, - backgroundColor: Colors.red, colorText: Colors.white); + mySnackeBarError("Failed to finish ride: $e"); - // إعادة الحالة للسماح للمستخدم بالمحاولة مرة أخرى + // إعادة الحالة محلياً للسماح للمستخدم بالمحاولة مرة أخرى بأمان isRideFinished = false; isRideStarted = true; box.write(BoxName.rideStatus, 'Begin'); @@ -1195,12 +1442,9 @@ class MapDriverController extends GetxController // --- دوال مساعدة (Helpers) لتنظيف الكود --- Future _validateTripDistance(bool isFromSlider) async { - // منطق التحقق من المسافة المقطوعة (كما هو موجود لديك) - String cleanDistance = - distance.toString().replaceAll(RegExp(r'[^0-9.]'), ''); - if (cleanDistance.isEmpty) cleanDistance = "0.0"; + // [Fix C-3] استخدام الدالة المساعدة المشتركة لتحليل المسافة + final double totalTripDistanceMeters = _parseDistanceToMeters(); - final double totalTripDistanceMeters = double.parse(cleanDistance) * 1000; final double displacementMeters = Geolocator.distanceBetween( latLngPassengerLocation.latitude, latLngPassengerLocation.longitude, @@ -1213,68 +1457,73 @@ class MapDriverController extends GetxController if (displacementMeters > minimumThreshold || isFromSlider) { if (isFromSlider) return true; - // إذا لم يكن من السلايدر، نعرض تأكيد - bool confirmed = false; - MyDialog().getDialog('Exit Ride?'.tr, '', () { - confirmed = true; - Get.back(); + // [Fix C-2 v2] استخدام AlertDialog مباشرة مع حماية Deadlock + // إذا أغلق المستخدم الديالوج بالزر الخلفي، نُكمل بـ false + final completer = Completer(); + Get.dialog( + AlertDialog( + title: Text('Exit Ride?'.tr), + actions: [ + TextButton( + onPressed: () { + if (!completer.isCompleted) completer.complete(true); + Get.back(); + }, + child: Text('OK'.tr), + ), + ], + ), + barrierDismissible: true, + ).then((_) { + if (!completer.isCompleted) completer.complete(false); }); - return confirmed; + return await completer.future; } else { - // المسافة غير كافية - Get.find() - .speakText("You haven't moved sufficiently!".tr); + Get.find().speakText( + "You haven't moved sufficiently!".tr, + ); MyDialog().getDialog( - "Warning".tr, "You haven't moved sufficiently!".tr, () => Get.back()); + "Warning".tr, + "You haven't moved sufficiently!".tr, + () => Get.back(), + ); return false; } } - void _calculateFinalTotalCost() { - // منطق حساب السعر (كما هو موجود لديك) - if (price < 172) { - totalCost = (carType == 'Comfort' || - carType == 'Mishwar Vip' || - carType == 'Lady') - ? '200' - : '172'; - } else if (price < double.parse(totalPricePassenger)) { - totalCost = totalPricePassenger; - } else { - totalCost = (carType == 'Comfort' || - carType == 'Mishwar Vip' || - carType == 'Lady') - ? price.toStringAsFixed(2) - : totalPricePassenger; - } - paymentAmount = totalCost; - } - void cancelCheckRideFromPassenger() async { - var res = await CRUD().get( + try { + var res = await CRUD().get( link: "${AppLink.endPoint}/ride/driver_order/getOrderCancelStatus.php", - payload: { - 'order_id': (rideId), - }); //.then((value) { - var response = jsonDecode(res); - canelString = response['data']['status']; - update(); - if (canelString == 'Cancel') { - remainingTimeTimerRideBegin = 0; - remainingTimeToShowPassengerInfoWindowFromDriver = 0; - remainingTimeToPassenger = 0; - isRideStarted = false; - isRideFinished = false; - isPassengerInfoWindow = false; - clearPolyline(); - update(); - MyDialog().getDialog( - 'Order Cancelled'.tr, - 'Order Cancelled by Passenger'.tr, - () { - Get.offAll(HomeCaptain()); - }, + payload: {'order_id': (rideId)}, ); + if (res == 'failure' || res.isEmpty) return; + var response = jsonDecode(res); + if (response == null || response['data'] == null) return; + canelString = response['data']['status']?.toString() ?? 'yet'; + update(); + if (canelString == 'Cancel') { + remainingTimeTimerRideBegin = 0; + remainingTimeToShowPassengerInfoWindowFromDriver = 0; + remainingTimeToPassenger = 0; + isRideStarted = false; + isRideFinished = false; + isPassengerInfoWindow = false; + clearPolyline(); + update(); + box.remove('cached_trip_route'); + MyDialog().getDialog( + 'Order Cancelled'.tr, + 'Order Cancelled by Passenger'.tr, + () { + Get.delete< + HomeCaptainController>(); // clear old controller to fix map generation + Get.offAll(HomeCaptain()); + }, + ); + } + } catch (e) { + Log.print("Error checking ride cancel status: $e"); } } @@ -1286,7 +1535,7 @@ class MapDriverController extends GetxController /// - نستخدم سعر الدقيقة حسب الوقت، مع قواعد الرحلات البعيدة: /// >25كم أو >35كم => دقيقة = 600، سقف 60 دقيقة، ومع >35كم عفو 10 دقائق. /// - سرعة طويلة: لو المسافة المخططة > 40كم نستخدم 2600 ل.س/كم للـ Speed، -//// ونطبق نفس نسبة التخفيض على Comfort/Electric/Van. + //// ونطبق نفس نسبة التخفيض على Comfort/Electric/Van. /// - نضيف فقط "الزيادة" فوق التسعيرة المقتبسة (وقت زائد + كم زائد). /// - نعكس العمولة kazán مرة واحدة على الزيادة (وليس كل ثانية). @@ -1341,7 +1590,7 @@ class MapDriverController extends GetxController isAirport(startNameLocation ?? '') || isAirport(endNameLocation ?? ''); double lastKmForNoise = loc.totalDistance / 1000; - const double jitterMeters = 0.01; // 10 متر + const double jitterKm = 0.01; // 10 متر = 0.01 كم [Fix M-1] _rideTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (box.read(BoxName.rideStatus) != 'Begin') { @@ -1357,7 +1606,7 @@ class MapDriverController extends GetxController double currentTotalKm = loc.totalDistance / 1000; double delta = currentTotalKm - lastKmForNoise; - if (delta.abs() > jitterMeters) { + if (delta.abs() > jitterKm) { currentRideDistanceKm += delta; lastKmForNoise = currentTotalKm; } @@ -1491,20 +1740,21 @@ class MapDriverController extends GetxController double recentAngelToMarker = 0; double speed = 0; void updateMarker() { - // استخدم الماركر الذي يتحرك مع الخريطة + // 🔥 Car icon as a Map Marker — moves with GPS location on the map. + // MarkerId 'MyLocation' must match exactly with google_driver_map_page.dart. markers.removeWhere((m) => m.markerId.value == 'MyLocation'); final locCtrl = Get.find(); myLocation = locCtrl.myLocation; markers.add( Marker( - markerId: MarkerId('MyLocation'), + markerId: const MarkerId('MyLocation'), position: myLocation, icon: carIcon, rotation: locCtrl.heading, anchor: const Offset(0.5, 0.5), flat: true, - zIndex: 2, + zIndex: 100, ), ); update(); @@ -1538,10 +1788,7 @@ class MapDriverController extends GetxController var heading = 0.0; // ... يمكنك إضافة أيقونات البداية والنهاية هنا - // --- متغيرات قياس الأداء الذكي --- - final List _performanceReadings = []; - final int _readingsToCollect = 10; // اجمع 10 قراءات - bool _hasMadeDecision = false; + // --- متغيرات الأداء --- var updateInterval = 5.obs; // القيمة الافتراضية // --- متغيرات داخلية للملاحة --- @@ -1592,7 +1839,7 @@ class MapDriverController extends GetxController // 1. فحص الأردن if (isPointInPolygon(passengerPoint, CountryPolygons.jordanBoundary)) { box.write(BoxName.countryCode, 'Jordan'); - // يمكنك تعيين AppLink.endPoint هنا إذا كان منطقك الداخلي لا يزال يعتمد عليه + update(); // [Fix N-5] // box.write(BoxName.serverChosen, // AppLink.IntaleqSyriaServer); // مثال: اختر سيرفر سوريا للبيانات return 'Jordan'; @@ -1601,20 +1848,20 @@ class MapDriverController extends GetxController // 2. فحص سوريا if (isPointInPolygon(passengerPoint, CountryPolygons.syriaBoundary)) { box.write(BoxName.countryCode, 'Syria'); - // box.write(BoxName.serverChosen, AppLink.IntaleqSyriaServer); + update(); // [Fix N-5] return 'Syria'; } // 3. فحص مصر if (isPointInPolygon(passengerPoint, CountryPolygons.egyptBoundary)) { box.write(BoxName.countryCode, 'Egypt'); - // box.write(BoxName.serverChosen, AppLink.IntaleqAlexandriaServer); + update(); // [Fix N-5] return 'Egypt'; } // 4. الافتراضي (إذا كان خارج المناطق المخدومة) box.write(BoxName.countryCode, 'Jordan'); - // box.write(BoxName.serverChosen, AppLink.IntaleqSyriaServer); + update(); // [Fix N-5] return 'Unknown Location (Defaulting to Jordan)'; } @@ -1625,31 +1872,45 @@ class MapDriverController extends GetxController required LatLng origin, required LatLng destination, required Color routeColor, + String? cachedResponse, }) async { if (mapController == null) return; try { - // 1. طلب المسار من السيرفر الموحد (SaaS) لضمان الدقة وتفادي الـ 401 - final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: { - 'fromLat': origin.latitude.toString(), - 'fromLng': origin.longitude.toString(), - 'toLat': destination.latitude.toString(), - 'toLng': destination.longitude.toString(), - 'steps': 'true', // نحتاجها للملاحة والتوجيه - 'alternatives': 'false', - }); + dynamic response; - final httpResponse = await http.get(saasUrl, headers: { - 'x-api-key': Env.mapSaasKey, - 'Content-Type': 'application/json', - }); + if (cachedResponse != null && cachedResponse.isNotEmpty) { + response = jsonDecode(cachedResponse); + Log.print("✅ Using cached route response to save API calls"); + } else { + // 1. طلب المسار من السيرفر الموحد (SaaS) لضمان الدقة وتفادي الـ 401 + final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace( + queryParameters: { + 'fromLat': origin.latitude.toString(), + 'fromLng': origin.longitude.toString(), + 'toLat': destination.latitude.toString(), + 'toLng': destination.longitude.toString(), + 'steps': 'true', // نحتاجها للملاحة والتوجيه + 'alternatives': 'false', + 'locale': 'ar', + }, + ); - if (httpResponse.statusCode != 200) { - throw Exception("Routing request failed: ${httpResponse.statusCode}"); + final httpResponse = await http.get( + saasUrl, + headers: { + 'x-api-key': Env.mapSaasKey, + 'Content-Type': 'application/json', + }, + ); + + if (httpResponse.statusCode != 200) { + throw Exception("Routing request failed: ${httpResponse.statusCode}"); + } + + response = jsonDecode(httpResponse.body); } - final response = jsonDecode(httpResponse.body); - // 2. التعامل مع الـ JSON المباشر (الذي أرسله المستخدم) // إذا كان الـ response يحتوي على الحقول مباشرة في الجذر final String? encodedPoints = response['points']; @@ -1660,8 +1921,10 @@ class MapDriverController extends GetxController } // 🔥 فك التشفير باستخدام compute لضمان أداء ممتاز - List fullRoute = - await compute(PolylineUtils.decode, encodedPoints); + List fullRoute = await compute( + PolylineUtils.decode, + encodedPoints, + ); if (fullRoute.isEmpty) { mySnackeBarError("Failed to process route points".tr); @@ -1679,30 +1942,34 @@ class MapDriverController extends GetxController // ج) رسم المسار الأولي polyLines.clear(); - polyLines.add(Polyline( - polylineId: const PolylineId("upcoming_route"), - points: fullRoute, - width: 8, - color: routeColor, - )); + polyLines.add( + Polyline( + polylineId: const PolylineId("upcoming_route"), + points: fullRoute, + width: 8, + color: routeColor, + ), + ); // د) معالجة الخطوات (Instructions) للسيرفر الموحد final List instructions = response['instructions'] ?? []; if (instructions.isNotEmpty) { - routeSteps = List>.from(instructions.map((e) { - int endIdx = (e['interval'] as List)[1]; - // التأكد من أن الـ index لا يتجاوز طول المسار - if (endIdx >= fullRoute.length) endIdx = fullRoute.length - 1; + routeSteps = List>.from( + instructions.map((e) { + int endIdx = (e['interval'] as List)[1]; + // التأكد من أن الـ index لا يتجاوز طول المسار + if (endIdx >= fullRoute.length) endIdx = fullRoute.length - 1; - return { - 'html_instructions': e['text'] ?? "", - 'sign': e['sign'] ?? 0, - 'end_location': { - 'lat': fullRoute[endIdx].latitude, - 'lng': fullRoute[endIdx].longitude, - } - }; - })); + return { + 'html_instructions': e['text'] ?? "", + 'sign': e['sign'] ?? 0, + 'end_location': { + 'lat': fullRoute[endIdx].latitude, + 'lng': fullRoute[endIdx].longitude, + }, + }; + }), + ); currentStepIndex = 0; currentInstruction = routeSteps[0]['html_instructions']; @@ -1718,11 +1985,67 @@ class MapDriverController extends GetxController currentManeuverModifier = 0; } - // هـ) تحريك الكاميرا لتشمل المسار - if (fullRoute.isNotEmpty) { + // هـ) تحريك الكاميرا لتشمل المسار أو الدخول في وضع الملاحة + if (isRideStarted) { + final locCtrl = Get.find(); + safeAnimateCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: origin, + zoom: 17.5, + bearing: locCtrl.heading, + tilt: 60, + ), + ), + ); + } else if (fullRoute.isNotEmpty) { final bounds = _boundsFromLatLngList(fullRoute); - safeAnimateCamera(CameraUpdate.newLatLngBounds(bounds, - left: 80, top: 80, right: 80, bottom: 80)); + final double distOriginDest = Geolocator.distanceBetween( + origin.latitude, + origin.longitude, + destination.latitude, + destination.longitude, + ); + // When driver & passenger are on the same device, the + // C++ map engine crashes with std::domain_error because + // bounds have near-zero span. Show a dialog to inform + // the driver, then use a padded safe zoom instead. + // 🔥 [Fix Dialog] لا تُظهر التحذير إذا كانت الرحلة قد بدأت (isRideStarted) + // لأن عند بدء الرحلة، origin=موقع السائق و destination=وجهة الراكب + // وقد تكون بعيدة جداً — التحذير لا معنى له هنا + if (distOriginDest < 10 && !isRideStarted) { + _showSameDeviceWarning(); + safeAnimateCamera( + CameraUpdate.newLatLngZoom( + LatLng( + (origin.latitude + destination.latitude) / 2, + (origin.longitude + destination.longitude) / 2, + ), + 17, + ), + ); + } else if (distOriginDest < 10 && isRideStarted) { + // نفس الجهاز لكن الرحلة بدأت — فقط zoom بدون تحذير + safeAnimateCamera( + CameraUpdate.newLatLngZoom( + LatLng( + (origin.latitude + destination.latitude) / 2, + (origin.longitude + destination.longitude) / 2, + ), + 15, + ), + ); + } else { + safeAnimateCamera( + CameraUpdate.newLatLngBounds( + bounds, + left: 80, + top: 80, + right: 80, + bottom: 80, + ), + ); + } } update(); @@ -1731,7 +2054,6 @@ class MapDriverController extends GetxController } } - LatLngBounds _boundsFromLatLngList(List list) { assert(list.isNotEmpty); double? x0, x1, y0, y1; @@ -1746,34 +2068,51 @@ class MapDriverController extends GetxController if (latLng.longitude < y0!) y0 = latLng.longitude; } } + // Guard against zero-span bounds which crash the native C++ map engine + // with std::domain_error when passed to CameraUpdate.newLatLngBounds. + double latSpan = (x1! - x0!).abs(); + double lngSpan = (y1! - y0!).abs(); + const double minSpan = 0.002; // ~220 m at equator + if (latSpan < minSpan) { + final double pad = (minSpan - latSpan) / 2; + x0 = x0! - pad; + x1 = x1! + pad; + } + if (lngSpan < minSpan) { + final double pad = (minSpan - lngSpan) / 2; + y0 = y0! - pad; + y1 = y1! + pad; + } return LatLngBounds( - northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!)); + northeast: LatLng(x1!, y1!), + southwest: LatLng(x0!, y0!), + ); } - -// داخل MapDriverController + // داخل MapDriverController Future markDriverAsArrived() async { // 1. إظهار لودينج فوراً لمنع التكرار وإشعار السائق Get.dialog( - const Center( - child: CircularProgressIndicator( - color: AppColor.gold, - )), - barrierDismissible: false); + const Center(child: CircularProgressIndicator(color: AppColor.gold)), + barrierDismissible: false, + ); try { - double distance = + // [Fix M-2] تغيير اسم المتغير المحلي لتجنب Variable Shadowing مع distance العام + double distToPassenger = await calculateDistanceBetweenDriverAndPassengerLocation(); - if (distance < 100) { - // 2. طلب الـ API - await CRUD() - .post(link: "${AppLink.ride}/rides/arrive_ride.php", payload: { - "ride_id": rideId, - "driver_id": box.read(BoxName.driverID), - "passengerToken": tokenPassenger - }); + if (distToPassenger < 100) { + // 2. طلب الـ API مع مهلة 15 ثانية كأمان + await CRUD().post( + link: "${AppLink.ride}/rides/arrive_ride.php", + payload: { + "ride_id": rideId, + "driver_id": box.read(BoxName.driverID), + "passengerToken": tokenPassenger, + }, + ).timeout(const Duration(seconds: 15)); // 3. إغلاق اللودينج وتحديث الواجهة if (Get.isDialogOpen == true) Get.back(); @@ -1787,9 +2126,10 @@ class MapDriverController extends GetxController // رسم المسار للوجهة getRoute( - origin: latLngPassengerLocation, - destination: latLngPassengerDestination, - routeColor: Colors.blue); + origin: latLngPassengerLocation, + destination: latLngPassengerDestination, + routeColor: Colors.blue, + ); } else { if (Get.isDialogOpen == true) Get.back(); mySnackeBarError("You must be closer than 100 meters to arrive".tr); @@ -1800,78 +2140,68 @@ class MapDriverController extends GetxController } } - - - // ================================================================= - // 4. منطق الأداء الذكي (Smart Performance Logic) - // ================================================================= - void _analyzePerformance() { - final int sum = _performanceReadings.reduce((a, b) => a + b); - final double averageTime = sum / _performanceReadings.length; - if (averageTime > 1000) { - // إذا كانت العملية تستغرق أكثر من ثانية - _suggestOptimization(); - } - } - - void _suggestOptimization() { - Get.snackbar( - "Improve app performance".tr, - "To ensure the best experience, we suggest adjusting the settings to suit your device. Would you like to proceed?" - .tr, - duration: const Duration(seconds: 15), - mainButton: TextButton( - child: Text("Yes, optimize".tr), - onPressed: () { - updateInterval.value = 8; // غير الفترة إلى 8 ثوانٍ - // save setting to shared_preferences - box.write(BoxName.updateInterval, 8); - Get.back(); - }, - ), - ); - } + // [Fix P-3] _suggestOptimization() أزيلت — كانت ميتة ولا يستدعيها أحد // ================================================================= // 5. دوال مساعدة (Helper Functions) // ================================================================= - String _parseInstruction(String html) => html.replaceAll(RegExp(r'<[^>]*>'), ''); + /// Show a warning dialog when the driver and passenger are on the + /// same device (distance < 10 m) — the C++ map engine would crash with + /// std::domain_error if we tried to fit zero-span bounds. + void _showSameDeviceWarning() { + Get.dialog( + AlertDialog( + icon: const Icon(Icons.warning_amber_rounded, + color: Colors.orange, size: 48), + title: Text("Same device detected".tr), + content: Text( + "The rider and driver locations are very close (possibly on the same phone). " + "The map will show an approximate view." + .tr, + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text("OK".tr), + ), + ], + ), + ); + } + Future _fitToBounds(LatLngBounds b, {double padding = 60}) async { - // نستخدم الدالة الآمنة التي أنشأناها - await safeAnimateCamera(CameraUpdate.newLatLngBounds(b, - left: padding, top: padding, right: padding, bottom: padding)); + // Guard against zero-span bounds (crashes native C++ engine) + final double latSpan = (b.northeast.latitude - b.southwest.latitude).abs(); + final double lngSpan = + (b.northeast.longitude - b.southwest.longitude).abs(); + if (latSpan < 0.002 && lngSpan < 0.002) { + // Bounds are a single point — zoom instead of fit + await safeAnimateCamera( + CameraUpdate.newLatLngZoom(b.northeast, 16), + ); + return; + } + await safeAnimateCamera( + CameraUpdate.newLatLngBounds( + b, + left: padding, + top: padding, + right: padding, + bottom: padding, + ), + ); } double distanceBetweenDriverAndPassengerWhenConfirm = 0; - // فحص التعليمات الصوتية + /// [Fix M-4] هذه الدالة مكررة مع _checkNavigationStep(). + /// نحتفظ بها للاستدعاء الخارجي (startListeningStepNavigation) ولكن logica مدمج. void checkForNextStep(LatLng currentPosition) { - if (currentStepIndex >= routeSteps.length) return; - - final step = routeSteps[currentStepIndex]; - final endLocation = step['end_location']; - final endLatLng = LatLng(endLocation['lat'], endLocation['lng']); - - final distance = Geolocator.distanceBetween( - currentPosition.latitude, - currentPosition.longitude, - endLatLng.latitude, - endLatLng.longitude, - ); - - if (distance < 50) { - // 50 متر - currentStepIndex++; - if (currentStepIndex < routeSteps.length) { - currentInstruction = routeSteps[currentStepIndex]['html_instructions']; - playVoiceInstruction(currentInstruction); - update(); - } - } + _checkNavigationStep(currentPosition); } /// Calculates the distance in meters between two latitude/longitude points. @@ -1898,12 +2228,17 @@ class MapDriverController extends GetxController // فحص الوصول للوجهة للإشعارات void checkDestinationProximity() { if (isNearDestinationNotified) return; + if (myLocation.latitude == 0 || + myLocation.longitude == 0 || + latLngPassengerDestination.latitude == 0 || + latLngPassengerDestination.longitude == 0) return; double dist = Geolocator.distanceBetween( - myLocation.latitude, - myLocation.longitude, - latLngPassengerDestination.latitude, - latLngPassengerDestination.longitude); + myLocation.latitude, + myLocation.longitude, + latLngPassengerDestination.latitude, + latLngPassengerDestination.longitude, + ); if (dist < 300) { isNearDestinationNotified = true; @@ -1939,8 +2274,6 @@ class MapDriverController extends GetxController ); } - - double _distanceMeters(LatLng a, LatLng b) { // هافرساين مبسطة const R = 6371000.0; // m @@ -1961,18 +2294,29 @@ class MapDriverController extends GetxController void updateCameraFromBoundsAfterGetMap(dynamic response) { final bounds = response["routes"][0]["bounds"]; - LatLng northeast = - LatLng(bounds['northeast']['lat'], bounds['northeast']['lng']); - LatLng southwest = - LatLng(bounds['southwest']['lat'], bounds['southwest']['lng']); + LatLng northeast = LatLng( + bounds['northeast']['lat'], + bounds['northeast']['lng'], + ); + LatLng southwest = LatLng( + bounds['southwest']['lat'], + bounds['southwest']['lng'], + ); -// Create the LatLngBounds object - LatLngBounds boundsData = - LatLngBounds(northeast: northeast, southwest: southwest); + // Create the LatLngBounds object + LatLngBounds boundsData = LatLngBounds( + northeast: northeast, + southwest: southwest, + ); -// Fit the camera to the bounds - var cameraUpdate = CameraUpdate.newLatLngBounds(boundsData, - left: 140, top: 140, right: 140, bottom: 140); + // Fit the camera to the bounds + var cameraUpdate = CameraUpdate.newLatLngBounds( + boundsData, + left: 140, + top: 140, + right: 140, + bottom: 140, + ); safeAnimateCamera(cameraUpdate); } @@ -1990,44 +2334,80 @@ class MapDriverController extends GetxController } argumentLoading() async { + // 🔥 [Fix Double-Init] منع التنفيذ المتزامن (onInit + postFrameCallback) + if (_argumentLoadingInProgress) { + Log.print( + "⏳ argumentLoading: Already in progress, skipping concurrent call."); + return; + } + + // 🔥 Debounce: منع التنفيذ المتكرر السريع من build() callbacks + // نسمح بإعادة التنفيذ فقط بعد مرور 5 ثوانٍ من آخر تنفيذ ناجح + // (هذا يمنع الـ rapid rebuilds لكن يسمح بالعودة للرحلة من الهوم) + if (_lastArgumentLoadingTime != null) { + final elapsed = DateTime.now().difference(_lastArgumentLoadingTime!); + if (elapsed.inSeconds < 5) { + Log.print( + "⏭️ argumentLoading: Debounced (${elapsed.inMilliseconds}ms < 5000ms). Skipping."); + return; + } + } + + _argumentLoadingInProgress = true; + + // 🔥 إعادة تعيين flag المسار عند كل استدعاء لاحق + // (ضروري للعودة للرحلة من صفحة الهوم) + _isRouteRequested = false; + + // 🛑 حماية: إذا لم تكن هناك arguments، لا تكمل + if (Get.arguments == null || Get.arguments is! Map) { + Log.print("❌ argumentLoading: No valid arguments found. Aborting."); + _argumentLoadingInProgress = false; + return; + } try { - passengerLocation = Get.arguments['passengerLocation']; - passengerDestination = Get.arguments['passengerDestination']; - duration = Get.arguments['Duration']; - totalCost = Get.arguments['totalCost']; - passengerId = Get.arguments['passengerId']; - driverId = Get.arguments['driverId']; - distance = Get.arguments['Distance']; - passengerName = Get.arguments['name']; - passengerEmail = Get.arguments['email']; - totalPricePassenger = Get.arguments['totalPassenger']; - passengerPhone = Get.arguments['phone']; - walletChecked = Get.arguments['WalletChecked']; - tokenPassenger = Get.arguments['tokenPassenger']; - direction = Get.arguments['direction']; - durationToPassenger = Get.arguments['DurationToPassenger']; - rideId = Get.arguments['rideId']; - durationOfRideValue = Get.arguments['durationOfRideValue']; - paymentAmount = Get.arguments['paymentAmount']; - paymentMethod = Get.arguments['paymentMethod']; + passengerLocation = Get.arguments['passengerLocation']?.toString() ?? ''; + passengerDestination = + Get.arguments['passengerDestination']?.toString() ?? ''; + duration = Get.arguments['Duration']?.toString() ?? ''; + totalCost = Get.arguments['totalCost']?.toString() ?? ''; + passengerId = Get.arguments['passengerId']?.toString() ?? ''; + driverId = Get.arguments['driverId']?.toString() ?? ''; + distance = Get.arguments['Distance']?.toString() ?? '0'; + passengerName = Get.arguments['name']?.toString(); + passengerEmail = Get.arguments['email']?.toString() ?? ''; + totalPricePassenger = Get.arguments['totalPassenger']?.toString() ?? ''; + passengerPhone = Get.arguments['phone']?.toString() ?? ''; + walletChecked = Get.arguments['WalletChecked']?.toString() ?? ''; + tokenPassenger = Get.arguments['tokenPassenger']?.toString() ?? ''; + direction = Get.arguments['direction']?.toString() ?? ''; + durationToPassenger = + Get.arguments['DurationToPassenger']?.toString() ?? '100'; + rideId = Get.arguments['rideId']?.toString() ?? ''; + durationOfRideValue = + Get.arguments['durationOfRideValue']?.toString() ?? ''; + paymentAmount = Get.arguments['paymentAmount']?.toString() ?? '0'; + paymentMethod = Get.arguments['paymentMethod']?.toString() ?? ''; // 🔥 حفظ البيانات في الذاكرة المحلية فوراً (لفصل السوكيت عن الكنترولر) box.write(BoxName.passengerID, passengerId.toString()); box.write(BoxName.rideId, rideId.toString()); - isHaveSteps = Get.arguments['isHaveSteps']; - step0 = Get.arguments['step0']; - step1 = Get.arguments['step1']; - step2 = Get.arguments['step2']; - step3 = Get.arguments['step3']; - step4 = Get.arguments['step4']; - passengerWalletBurc = Get.arguments['passengerWalletBurc']; - timeOfOrder = Get.arguments['timeOfOrder']; - carType = Get.arguments['carType']; - kazan = Get.arguments['kazan']; - startNameLocation = Get.arguments['startNameLocation']; - endNameLocation = Get.arguments['endNameLocation']; + // Also save full args for return-to-ride scenarios + box.write(BoxName.rideArguments, Get.arguments); + isHaveSteps = Get.arguments['isHaveSteps']?.toString() ?? 'false'; + // [Fix N-4] ملء القائمة بدلاً من 5 متغيرات منفصلة + steps = List.generate(5, (i) { + return Get.arguments['step$i']?.toString() ?? ''; + }); + passengerWalletBurc = + Get.arguments['passengerWalletBurc']?.toString() ?? ''; + timeOfOrder = Get.arguments['timeOfOrder']?.toString() ?? ''; + carType = Get.arguments['carType']?.toString() ?? ''; + kazan = Get.arguments['kazan']?.toString() ?? ''; + startNameLocation = Get.arguments['startNameLocation']?.toString() ?? ''; + endNameLocation = Get.arguments['endNameLocation']?.toString() ?? ''; -// Parse to double + // Parse to double latlng(passengerLocation, passengerDestination); String lat = @@ -2035,41 +2415,110 @@ class MapDriverController extends GetxController String lng = Get.find().myLocation.longitude.toString(); - // Set the origin and destination coordinates for the Google Maps directions request. - Future.delayed(const Duration(seconds: 1)); - getRoute( + // Check which route to draw based on current ride status + String currentStatus = box.read(BoxName.rideStatus) ?? ''; + + // [Fix 5] إضافة await لضمان أن التأخير يعمل فعلاً قبل رسم المسار. + await Future.delayed(const Duration(seconds: 1)); + + // 🔥 [Fix Return-to-Ride] تعيين isRideStarted قبل getRoute لمنع onMapCreated + // من رسم المسار الأصفر (للراكب) بدلاً من الأزرق (للوجهة) + if (currentStatus == 'Begin') { + isRideStarted = true; + isRideBegin = true; + isPassengerInfoWindow = false; + } + + if (currentStatus == 'Begin' && + latLngPassengerDestination.latitude != 0 && + latLngPassengerDestination.longitude != 0) { + // Ride already started — draw blue route to destination + await getRoute( + origin: Get.find().myLocation, + destination: latLngPassengerDestination, + routeColor: Colors.blue, + ); + + // 🔥 [Fix Return-to-Ride] إعادة تشغيل الخدمات المفقودة عند العودة للرحلة + // بدون هذا الاستدعاء، يُرسم المسار لكن: + // - عداد السعر (_rideTimer) لا يعمل + // - الملاحة (_navigationTimer) لا تعمل + // - الماركر لا يتحرك + if (_rideTimer == null || !_rideTimer!.isActive) { + rideIsBeginPassengerTimer(); + } + await startListeningStepNavigation(); + } else { + // Ride not yet started — draw yellow route to passenger + await getRoute( origin: Get.find().myLocation, destination: latLngPassengerLocation, - routeColor: Colors.yellow // أو أي لون - ); + routeColor: Colors.yellow, + ); + // 🔥 بدء الملاحة بعد تحميل المسار (تتبع حركة السائق نحو الراكب) + if (_navigationTimer == null || !_navigationTimer!.isActive) { + startListeningStepNavigation(); + } + } + _isRouteRequested = true; update(); } catch (e) { Log.print("Error parsing arguments: $e"); + } finally { + // 🔥 [Fix Double-Init] دائماً إعادة تعيين الـ flag عند الانتهاء + _argumentLoadingInProgress = false; + // سجّل وقت الانتهاء للـ debounce + _lastArgumentLoadingTime = DateTime.now(); } } void latlng(String passengerLocation, String passengerDestination) { - double latPassengerLocation = - double.parse(passengerLocation.toString().split(',')[0]); - double lngPassengerLocation = - double.parse(passengerLocation.toString().split(',')[1]); - double latPassengerDestination = - double.parse(passengerDestination.toString().split(',')[0]); - double lngPassengerDestination = - double.parse(passengerDestination.toString().split(',')[1]); - latLngPassengerLocation = - LatLng(latPassengerLocation, lngPassengerLocation); - latLngPassengerDestination = - LatLng(latPassengerDestination, lngPassengerDestination); + try { + // ── مكان الراكب ────────────────────────────────────────────────── + final List locParts = passengerLocation.split(','); + double latPassengerLocation = locParts.length >= 1 + ? (double.tryParse(locParts[0].trim()) ?? 0.0) + : 0.0; + double lngPassengerLocation = locParts.length >= 2 + ? (double.tryParse(locParts[1].trim()) ?? 0.0) + : 0.0; + + // ── وجهة الراكب ───────────────────────────────────────────────── + final List destParts = passengerDestination.split(','); + double latPassengerDestination = destParts.length >= 1 + ? (double.tryParse(destParts[0].trim()) ?? 0.0) + : 0.0; + double lngPassengerDestination = destParts.length >= 2 + ? (double.tryParse(destParts[1].trim()) ?? 0.0) + : 0.0; + + latLngPassengerLocation = LatLng( + latPassengerLocation, + lngPassengerLocation, + ); + latLngPassengerDestination = LatLng( + latPassengerDestination, + lngPassengerDestination, + ); + + Log.print( + "📍 latlng() parsed => Pax: ($latPassengerLocation, $lngPassengerLocation) | Dest: ($latPassengerDestination, $lngPassengerDestination)"); + } catch (e, stack) { + Log.print("❌ latlng() FormatException prevented: $e"); + Log.print("Stack: $stack"); + // قيم افتراضية آمنة لمنع الكراش + latLngPassengerLocation = LatLng(0, 0); + latLngPassengerDestination = LatLng(0, 0); + } } - late Duration durationToAdd; + Duration durationToAdd = Duration.zero; int hours = 0; int minutes = 0; String carType = ''; - late String kazan; - late String startNameLocation; - late String endNameLocation; + String kazan = ''; + String startNameLocation = ''; + String endNameLocation = ''; Future runGoogleMapDirectly() async { if (box.read(BoxName.googlaMapApp) == true) { @@ -2096,7 +2545,9 @@ class MapDriverController extends GetxController } _animController = AnimationController( - vsync: this, duration: const Duration(milliseconds: 1000)); + vsync: this, + duration: const Duration(milliseconds: 1000), + ); _animController!.addListener(() { if (_oldLoc != null && _targetLoc != null) { final t = _animController!.value; @@ -2130,8 +2581,10 @@ class MapDriverController extends GetxController }); } + /// [Fix C-4] تحديث myLocation في المستمع الأساسي void _handleLocationUpdate(geo.Position pos) { final newLoc = LatLng(pos.latitude, pos.longitude); + myLocation = newLoc; // ← [Fix C-4] تحديث الموقع الفوري _oldLoc = smoothedLocation ?? newLoc; _targetLoc = newLoc; @@ -2165,7 +2618,11 @@ class MapDriverController extends GetxController final double stepLng = stepLoc['lng']; final distance = geo.Geolocator.distanceBetween( - pos.latitude, pos.longitude, stepLat, stepLng); + pos.latitude, + pos.longitude, + stepLat, + stepLng, + ); distanceToNextStep = distance > 1000 ? "${(distance / 1000).toStringAsFixed(1)} km" @@ -2223,8 +2680,6 @@ class MapDriverController extends GetxController } } - - int parseDurationToInt(dynamic value) { if (value == null) return 0; String text = value.toString(); @@ -2238,51 +2693,7 @@ class MapDriverController extends GetxController // أضف هذا المتغير في الكلاس LatLng? _lastRecordedLocation; -// Future startListeningStepNavigation() async { -// _posSub?.cancel(); -// _navigationTimer?.cancel(); - -// _posSub = Geolocator.getPositionStream( -// locationSettings: const LocationSettings( -// accuracy: LocationAccuracy.high, -// distanceFilter: 5, // قللناها لـ 5 أمتار لدقة أعلى -// ), -// ).listen((position) { -// LatLng newLoc = LatLng(position.latitude, position.longitude); - -// // 🔥 Jitter Filter: تجاهل التحركات الطفيفة جداً (أقل من 2 متر) -// if (_lastRecordedLocation != null) { -// double dist = Geolocator.distanceBetween( -// newLoc.latitude, -// newLoc.longitude, -// _lastRecordedLocation!.latitude, -// _lastRecordedLocation!.longitude); -// if (dist < 2.0) return; -// } - -// _lastRecordedLocation = newLoc; -// myLocation = newLoc; -// heading = position.heading; - -// if (!isClosed) { -// // نحدث الماركر فوراً -// updateMarker(); - -// // 🔥 تحديث المسار بذكاء (Smart Route Snapping) -// if (upcomingPathPoints.isNotEmpty) { -// _updateTraveledPolylineSmart(myLocation); -// } -// // 🔥🔥 الجزء الأهم: تحريك الكاميرا لتتبع السيارة 🔥🔥 -// if (mapController != null) { -// _animateCameraToNavigationMode(position); -// } -// // التحقق من الخطوة التالية -// checkForNextStep(myLocation); - -// update(); -// } -// }); -// } + // [Fix M-4] تم إزالة الكود المعلّق القديم لـ startListeningStepNavigation void _animateCameraToNavigationMode(LatLng target, double bearing) { mapController?.animateCamera( @@ -2304,19 +2715,25 @@ class MapDriverController extends GetxController void _updateTraveledPolylineSmart(LatLng currentPos) { if (upcomingPathPoints.isEmpty) return; - // Sliding Window: نبحث فقط في الـ 60 نقطة القادمة + // Bidirectional Sliding Window: نبحث 30 نقطة للخلف و30 نقطة للأمام لدعم الرجوع أو اتخاذ مسارات بديلة int searchWindow = 60; - int startIndex = _lastTraveledIndex; - int endIndex = min(startIndex + searchWindow, upcomingPathPoints.length); + int halfWindow = searchWindow ~/ 2; + int startIndex = max(0, _lastTraveledIndex - halfWindow); + int endIndex = + min(_lastTraveledIndex + halfWindow, upcomingPathPoints.length); double minDistance = double.infinity; - int closestIndex = startIndex; + int closestIndex = _lastTraveledIndex; bool foundCloser = false; for (int i = startIndex; i < endIndex; i++) { final point = upcomingPathPoints[i]; - final dist = Geolocator.distanceBetween(currentPos.latitude, - currentPos.longitude, point.latitude, point.longitude); + final dist = Geolocator.distanceBetween( + currentPos.latitude, + currentPos.longitude, + point.latitude, + point.longitude, + ); if (dist < minDistance) { minDistance = dist; closestIndex = i; @@ -2324,7 +2741,7 @@ class MapDriverController extends GetxController } } - if (foundCloser && minDistance < 50 && closestIndex > _lastTraveledIndex) { + if (foundCloser && minDistance < 50 && closestIndex != _lastTraveledIndex) { _lastTraveledIndex = closestIndex; final remaining = upcomingPathPoints.sublist(_lastTraveledIndex); @@ -2334,21 +2751,25 @@ class MapDriverController extends GetxController polyLines.removeWhere((p) => p.polylineId.value == 'traveled_route'); // المسار المتبقي - polyLines.add(Polyline( - polylineId: const PolylineId("upcoming_route"), - points: remaining, - width: 8, - color: isRideStarted ? Colors.blue : Colors.yellow, - )); + polyLines.add( + Polyline( + polylineId: const PolylineId("upcoming_route"), + points: remaining, + width: 8, + color: isRideStarted ? Colors.blue : Colors.yellow, + ), + ); // المسار المقطوع (رمادي) - polyLines.add(Polyline( - polylineId: const PolylineId('traveled_route'), - points: traveled, - color: Colors.grey.withValues(alpha: 0.8), - width: 7, - zIndex: 1, - )); + polyLines.add( + Polyline( + polylineId: const PolylineId('traveled_route'), + points: traveled, + color: Colors.grey.withValues(alpha: 0.8), + width: 7, + zIndex: 1, + ), + ); update(); } @@ -2357,6 +2778,8 @@ class MapDriverController extends GetxController void stopListeningStepNavigation() { _posSub?.cancel(); _posSub = null; + // [Fix 1] إعادة تشغيل المستمع الأساسي للحركة السلسة بعد إيقاف الملاحة. + _startLocationListening(); } } diff --git a/lib/controller/home/captin/order_request_controller.dart b/lib/controller/home/captin/order_request_controller.dart index 21e1b5c..782e299 100755 --- a/lib/controller/home/captin/order_request_controller.dart +++ b/lib/controller/home/captin/order_request_controller.dart @@ -219,14 +219,36 @@ class OrderRequestController extends GetxController // ---------------------------------------------------------------------- Future _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()) { + final locCtrl = Get.find(); + 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) { diff --git a/lib/controller/home/captin/v2_review_delta.html b/lib/controller/home/captin/v2_review_delta.html new file mode 100644 index 0000000..30969aa --- /dev/null +++ b/lib/controller/home/captin/v2_review_delta.html @@ -0,0 +1,212 @@ + + + +
+

مراجعة النسخة المحدّثة — V2

+

مقارنة مع المراجعة السابقة · 16 مشكلة فُحصت

+ +
+
11
مشكلة مُصلحة ✅
+
2
مشكلة جديدة أدخلتها الإصلاحات ⚠️
+
3
مشكلة لم تُعالج بعد
+
69%
تحسن من المراجعة الأولى
+
+ +
صحة المنطق البرمجي
72% ↑
+
نظافة الكود
63% ↑
+
قابلية الصيانة
58% ↑
+ + +
+
✅ مُصلح ما تم إصلاحه بشكل صحيح
+ +
+
C-1 — استبدال الحلقة التكرارية والاستدعاء الذاتي بـ Timer.periodicممتاز
+

تم حذف updateLocation() كاملاً واستبدالها بـ startUpdateLocationTimer() و stopUpdateLocationTimer(). التايمر مسجّل في onClose() و _stopAllServices(). إصلاح ممتاز.

+
ملاحظة مهمةلا يظهر في الكود استدعاء لـ startUpdateLocationTimer() من أي مكان. يجب التأكد أنها تُستدعى من الـ View أو من startRideFromDriver().
+
+ +
+
C-4 — تحديث myLocation في _handleLocationUpdate()
+
void _handleLocationUpdate(geo.Position pos) {
+  final newLoc = LatLng(pos.latitude, pos.longitude);
+  myLocation = newLoc; // ← [Fix C-4] ✅ صحيح
+  // ...
+
+ +
+
M-4 — دمج checkForNextStep() مع _checkNavigationStep()
+

checkForNextStep أصبحت wrapper بسيط يستدعي _checkNavigationStep. منطق واحد، لا تعارض.

+
+ +
+
M-5 — disposeEverything() لا تستدعي onClose() يدوياً
+
void disposeEverything() {
+  _stopAllServices(); // ✅ بدون onClose()
+}
+
+ +
+
C-3 جزئي — دالة مساعدة _parseDistanceToMeters() مشتركة
+

تم استخراج منطق تحليل المسافة إلى دالة واحدة تستخدمها كلا finishRideFromDriver() و _validateTripDistance(). يحل مشكلة التضارب في الوحدات.

+
لم يُحل كاملاًالتحقق من المسافة لا يزال يحدث مرتين (انظر مشكلة C-3 أدناه).
+
+ +
+
M-1 + M-2 + M-6 + N-1 + N-5 — إصلاحات طفيفة متعددة
+
+

M-1: jitterMetersjitterKm = 0.01

+

M-2: distance المحلية → distToPassenger

+

M-6: تعليق يوضح أن الوحدة كيلومتر ✅

+

N-1: &directionsmode?directionsmode

+

N-5: إضافة update() في getLocationArea()

+

M-3: حذف _performanceReadings والمتغيرات الميتة ✅

+
+
+
+ + +
+
🚨 جديد مشاكل أدخلتها الإصلاحات
+ +
+
🚨BUG جديد — Completer في C-2 يُسبب Deadlock عند إغلاق الديالوج بـ Backحرج
+
+

الإصلاح استخدم Completer بشكل صحيح لحل مشكلة الـ callback الآني، لكنه أدخل مشكلة أخرى: لو أغلق المستخدم الديالوج بزر الرجوع (Back) في Android بدون ضغط OK، فإن completer.future لن تكتمل أبداً، والدالة ستبقى معلّقة (deadlock) لأن _validateTripDistance() هي async وتنتظر نتيجة لن تأتي:

+
final completer = Completer<bool>();
+MyDialog().getDialog('Exit Ride?'.tr, '', () {
+  if (!completer.isCompleted) completer.complete(true);
+  Get.back();
+});
+return await completer.future; // ← ينتظر للأبد إذا أُغلق بـ Back
+
الحلأضف barrierDismissible: false للديالوج، أو استخدم completer.complete(false) عند إغلاق الديالوج بدون تأكيد (عبر WillPopScope أو onDismissed callback في MyDialog).
+
+
+ +
+
🚨C-3 لا يزال — المستخدم يرى ديالوجَي تأكيد متتاليَين عند إنهاء الرحلة بالزرحرج
+
+

رغم إضافة _parseDistanceToMeters()، تدفق الكود لا يزال يُقدّم ديالوجَين:

+
// finishRideFromDriver(isFromSlider: false):
+MyDialog().getDialog('Are you sure to exit ride?', '', () {
+  Get.back();
+  finishRideFromDriver1(); // ← isFromSlider = false افتراضياً
+});
+
+// finishRideFromDriver1():
+if (!await _validateTripDistance(false)) return; // ← يُقدّم ديالوجاً ثانياً!
+

المستخدم يرى "هل أنت متأكد؟" → يضغط OK → يرى "Exit Ride?" مرة ثانية → ينتظر مجدداً.

+
الحلاحذف الديالوج من finishRideFromDriver() وأبقه في _validateTripDistance() فقط. أو مرّر isFromSlider: true لما يأتي من موافقة مسبقة.
+
+
+
+ + +
+
⚠️ لم تُعالج مشاكل لا تزال قائمة
+ +
+
⚠️M-7 — Null checks على String غير قابلة للـ null
+
+
if (isSocialPressed == true && passengerId != null && rideId != null) {
+//                                            ^^^^^^^^^^^ دائماً non-null
+

لو passengerId == '' يمر الشرط ويُرسل بيانات فارغة للسيرفر. الفحص الصحيح: passengerId.isNotEmpty && rideId.isNotEmpty.

+
+
+ +
+
⚠️N-2 — تأخير 1 ثانية Hardcoded في argumentLoading()
+
+
await Future.delayed(const Duration(seconds: 1));
+await getRoute(...);
+

لا يزال موجوداً. Race condition يجب معالجته بـ Completer بدلاً من تخمين الوقت.

+
+
+ +
+
⚠️N-4 — step0 إلى step4 بدلاً من List<String>
+
+
String step0 = ''; String step1 = ''; // ...
+step0 = Get.arguments['step0']?.toString() ?? '';
+step1 = Get.arguments['step1']?.toString() ?? '';
+

لا تزال 5 متغيرات منفصلة. List<String> steps = List.filled(5, '') أوضح وأسهل في المعالجة.

+
+
+
+ + +
+
ℹ️ بسيطة ملاحظات إضافية على هذه النسخة
+ +
+
ℹ️_suggestOptimization() لا تزال موجودة لكن لا يستدعيها أحد
+

بعد حذف _performanceReadings و _analyzePerformance()، بقيت _suggestOptimization() معزولة. إما أن تُستدعى من مكان ما أو تُحذف.

+
+ +
+
ℹ️الاستيرادات المكررة لـ dart:math و geolocator لا تزال
+
+
import 'dart:math';
+import 'dart:math' as math;           // مكرر
+import 'package:geolocator/geolocator.dart' as geo;
+import 'package:geolocator/geolocator.dart'; // مكرر
+

يُسبب تحذيرات من المحلل ويُشوّش قراءة الكود. احذف النسخة غير المعرّفة.

+
+
+
+ +
+ diff --git a/lib/controller/home/navigation/navigation_controller.dart b/lib/controller/home/navigation/navigation_controller.dart index 02e4011..5f79096 100644 --- a/lib/controller/home/navigation/navigation_controller.dart +++ b/lib/controller/home/navigation/navigation_controller.dart @@ -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 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> 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 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 _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>.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 _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 _updatePolylinesSets( List traveled, List remaining) async { + Log.print( + "DEBUG: Updating polylines. Traveled: ${traveled.length}, Remaining: ${remaining.length}"); Set 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 getRoute(LatLng origin, LatLng destination) async { + Future 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 recalculateRoute() async { - if (myLocation == null || _finalDestination == null || isLoading) return; + Future 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 _loadCustomIcons() async { if (mapController == null) return; - try { - final carBytes = await rootBundle.load('assets/images/car.png'); - final startBytes = await rootBundle.load('assets/images/A.png'); - final destBytes = await rootBundle.load('assets/images/b.png'); - await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); - await mapController! - .addImage('start_icon', startBytes.buffer.asUint8List()); - await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List()); - } catch (e) { - Log.print("Error loading custom icons: $e"); - } + final carBytes = await rootBundle.load('assets/images/car.png'); + final startBytes = await rootBundle.load('assets/images/A.png'); + final destBytes = await rootBundle.load('assets/images/b.png'); + await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); + await mapController! + .addImage('start_icon', startBytes.buffer.asUint8List()); + await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List()); } void _checkNavigationStep(LatLng pos) { @@ -1233,18 +1267,21 @@ class NavigationController extends GetxController if (mapController == null) return; try { + // ✅ Use searchPlaces from intaleq_maps SDK final results = await mapController!.searchPlaces(q); - + if (myLocation != null) { for (final p in results) { final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0; - final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0; - p['distanceKm'] = _haversineKm(myLocation!.latitude, myLocation!.longitude, plat, plng); + final plng = + double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0; + p['distanceKm'] = _haversineKm( + myLocation!.latitude, myLocation!.longitude, plat, plng); } results.sort((a, b) => (a['distanceKm'] as double).compareTo(b['distanceKm'] as double)); } - + placesDestination = results; update(); } catch (e) { @@ -1258,7 +1295,7 @@ class NavigationController extends GetxController final lat = double.parse(place['latitude'].toString()); final lng = double.parse(place['longitude'].toString()); await startNavigationTo(LatLng(lat, lng), - infoWindowTitle: place['name'] ?? (box.read(BoxName.lang) == 'ar' ? 'وجهة' : 'Destination')); + infoWindowTitle: place['name'] ?? 'وجهة'); } void onSearchChanged(String query) { @@ -1278,6 +1315,9 @@ class NavigationController extends GetxController return R * 2 * atan2(sqrt(a), sqrt(1 - a)); } + double _kmToLatDelta(double km) => km / 111.32; + double _kmToLngDelta(double km, double lat) => + km / (111.32 * cos(lat * pi / 180)); LatLngBounds _boundsFromLatLngList(List 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(); diff --git a/lib/controller/home/profile/complaint_controller.dart b/lib/controller/home/profile/complaint_controller.dart new file mode 100644 index 0000000..c8c3e12 --- /dev/null +++ b/lib/controller/home/profile/complaint_controller.dart @@ -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(); + final complaintController = TextEditingController(); + + List ridesList = []; + Map? selectedRide; + + Map? passengerReport; + Map? 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 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 ride) { + selectedRide = ride; + audioLink = ''; + attachedFileName = ''; + update(); + } + + Future 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 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(); + } + } +} diff --git a/lib/controller/home/splash_screen_controlle.dart b/lib/controller/home/splash_screen_controlle.dart index 67788c4..c98885d 100755 --- a/lib/controller/home/splash_screen_controlle.dart +++ b/lib/controller/home/splash_screen_controlle.dart @@ -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(); diff --git a/lib/controller/local/translations.dart b/lib/controller/local/translations.dart index 3610fa5..addc89d 100755 --- a/lib/controller/local/translations.dart +++ b/lib/controller/local/translations.dart @@ -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": "يرجى الذهاب إلى سائق السيارة", diff --git a/lib/controller/voice_call_controller.dart b/lib/controller/voice_call_controller.dart new file mode 100644 index 0000000..a246411 --- /dev/null +++ b/lib/controller/voice_call_controller.dart @@ -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 _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 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 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 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 _initializeLocalStream() async { + final Map mediaConstraints = { + 'audio': { + 'echoCancellation': true, + 'noiseSuppression': true, + 'autoGainControl': true, + }, + 'video': false, // EN: Audio only / AR: صوت فقط + }; + + _localStream = + await rtc.navigator.mediaDevices.getUserMedia(mediaConstraints); + rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value); + } + + // EN: Verifies local microphone stream health on app resume and recreates/replaces track if suspended. + // AR: التحقق من سلامة مسار الميكروفون المحلي عند استئناف التطبيق وإعادة إنشائه إذا تم تعليقه. + Future _ensureMicrophoneActive() async { + if (_localStream == null || _peerConnection == null) return; + + bool needsRecreation = false; + if (_localStream!.active == false) { + needsRecreation = true; + } else { + for (var track in _localStream!.getAudioTracks()) { + if (!track.enabled && !isMuted.value) { + needsRecreation = true; + break; + } + } + } + + if (needsRecreation) { + Log.print( + "Local audio track ended or disabled. Recreating local stream..."); + try { + _localStream?.getTracks().forEach((track) => track.stop()); + _localStream?.dispose(); + _localStream = null; + + await _initializeLocalStream(); + + final senders = await _peerConnection!.getSenders(); + for (var sender in senders) { + final track = sender.track; + if (track != null && track.kind == 'audio') { + final newTracks = _localStream?.getAudioTracks(); + if (newTracks != null && newTracks.isNotEmpty) { + await sender.replaceTrack(newTracks.first); + Log.print( + "Replaced suspended/ended audio track with a new active one."); + } + break; + } + } + } catch (e) { + Log.print("Error recreating local stream on resume: $e"); + } + } else { + _localStream!.getAudioTracks().forEach((track) { + track.enabled = !isMuted.value; + }); + } + } + + // EN: Creates the peer connection object and sets up ICE servers (STUN/TURN). + // AR: إنشاء كائن الاتصال المباشر وإعداد خوادم STUN/TURN لاختراق الجدران النارية. + Future _initializePeerConnection() async { + if (_peerConnection != null) return; + + final List> iceServers = []; + if (_dynamicIceServers.isNotEmpty) { + for (var server in _dynamicIceServers) { + if (server is Map) { + iceServers.add({ + "urls": server["urls"] ?? server["url"], + if (server["username"] != null) "username": server["username"], + if (server["credential"] != null) + "credential": server["credential"], + }); + } + } + } else { + // EN: Fallback STUN servers / AR: خوام STUN الاحتياطية + iceServers.addAll([ + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"}, + ]); + } + + final Map configuration = { + "iceServers": iceServers, + }; + + _peerConnection = await rtc.createPeerConnection(configuration); + + // EN: Gather local network routing info and send to remote peer. + // AR: جمع بيانات مسارات الشبكة المحلية وإرسالها للطرف الآخر. + _peerConnection!.onIceCandidate = (candidate) { + if (candidate.candidate != null) { + _signaling.send("ice_candidate", { + "candidate": { + "candidate": candidate.candidate, + "sdpMid": candidate.sdpMid, + "sdpMLineIndex": candidate.sdpMLineIndex, + } + }); + } + }; + + // EN: Monitor connection status changes and handle disconnections. + // AR: مراقبة تغيرات حالة الاتصال ومعالجة انقطاع الشبكة. + _peerConnection!.onConnectionState = (connState) { + Log.print("RTCPeerConnectionState: $connState"); + if (connState == + rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) { + _onCallConnected(); + } else if (connState == + rtc.RTCPeerConnectionState.RTCPeerConnectionStateFailed || + connState == + rtc.RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { + _handleIceConnectionFailure(); + } + }; + + // EN: Add local audio stream to the connection to send it to the other peer. + // AR: إضافة دفق الصوت المحلي للاتصال لإرساله للطرف الآخر. + if (_localStream != null) { + _localStream!.getTracks().forEach((track) { + _peerConnection!.addTrack(track, _localStream!); + }); + } + } + + // EN: Attempts an ICE restart to reconnect the WebRTC session when disconnections occur. + // AR: محاولة إعادة تأسيس الاتصال (ICE Restart) في حالة انقطاع الشبكة. + void _handleIceConnectionFailure() { + if (_isReconnecting) return; + _isReconnecting = true; + Log.print( + "ICE connection dropped. Attempting ICE Restart reconnection for 5s..."); + + if (isCaller) { + _attemptIceRestart(); + } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(const Duration(seconds: 5), () { + if (state.value == VoiceCallState.active && + _peerConnection?.connectionState != + rtc.RTCPeerConnectionState.RTCPeerConnectionStateConnected) { + Log.print("ICE reconnection timed out. Hanging up."); + _endCallInternal("connection_lost"); + } else { + _isReconnecting = false; + Log.print("ICE Reconnection succeeded!"); + } + }); + } + + // EN: Initiates ICE Restart SDP exchange. + // AR: بدء تبادل حزم SDP لإعادة بناء مسارات الاتصال. + Future _attemptIceRestart() async { + if (_peerConnection == null || !isCaller) return; + try { + Log.print("Caller initiating WebRTC ICE Restart..."); + final constraints = { + 'mandatory': { + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': false, + }, + 'optional': [ + {'IceRestart': true} + ], + }; + final offer = await _peerConnection!.createOffer(constraints); + await _peerConnection!.setLocalDescription(offer); + _signaling.send("offer", { + "sdp": { + "sdp": offer.sdp, + "type": offer.type, + } + }); + } catch (e) { + Log.print("Error initiating WebRTC ICE Restart: $e"); + } + } + + // EN: Generates an SDP Offer to initialize the connection. + // AR: إنشاء عرض (Offer) لبدء الاتصال وتحديد قدرات الجهاز. + Future _createOffer() async { + await _initializePeerConnection(); + + final constraints = { + 'mandatory': { + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': false, + }, + 'optional': [], + }; + + final offer = await _peerConnection!.createOffer(constraints); + await _peerConnection!.setLocalDescription(offer); + + _signaling.send("offer", { + "sdp": { + "sdp": offer.sdp, + "type": offer.type, + } + }); + } + + // EN: Generates an SDP Answer in response to an Offer. + // AR: الرد بإنشاء إجابة (Answer) بناءً على العرض المستلم. + Future _createAnswer() async { + final constraints = { + 'mandatory': { + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': false, + }, + 'optional': [], + }; + + final answer = await _peerConnection!.createAnswer(constraints); + await _peerConnection!.setLocalDescription(answer); + + _signaling.send("answer", { + "sdp": { + "sdp": answer.sdp, + "type": answer.type, + } + }); + } + + // EN: Triggered when connection is fully established. Starts the 60s timer. + // AR: يُستدعى عند تأسيس الاتصال بنجاح، ويقوم ببدء مؤقت الـ 60 ثانية. + void _onCallConnected() { + _ringingTimeoutTimer?.cancel(); + _reconnectTimer?.cancel(); + _isReconnecting = false; + + if (state.value != VoiceCallState.active) { + state.value = VoiceCallState.active; + HapticFeedback.vibrate(); + + // EN: Start 120s countdown timer / AR: بدء العد التنازلي لمدة 120 ثانية + _countdownTimer?.cancel(); + elapsedSeconds.value = 120; + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (elapsedSeconds.value > 1) { + elapsedSeconds.value--; + } else { + elapsedSeconds.value = 0; + _countdownTimer?.cancel(); + // EN: Force hangup when timer reaches 0 / AR: إغلاق إجباري عند وصول المؤقت لصفر + hangup(); + } + }); + } + } + + // EN: Internal cleanup function. Closes all connections and streams. + // AR: دالة التنظيف الداخلية. تقوم بإغلاق جميع الاتصالات وتفريغ الذاكرة. + void _endCallInternal(String reason) { + _countdownTimer?.cancel(); + _ringingTimeoutTimer?.cancel(); + _reconnectTimer?.cancel(); + _stopRingtone(); + + state.value = VoiceCallState.ended; + + // EN: Close WebRTC connection / AR: إغلاق اتصال WebRTC + _peerConnection?.close(); + _peerConnection = null; + + // EN: Stop mic capture / AR: إيقاف التقاط الميكروفون + _localStream?.getTracks().forEach((track) => track.stop()); + _localStream?.dispose(); + _localStream = null; + + // EN: Disconnect WebSockets / AR: إغلاق اتصال الـ WebSockets + _signaling.disconnect(); + + // EN: Close UI BottomSheet after delay / AR: إغلاق واجهة المكالمة بعد فترة زمنية قصيرة + Future.delayed(const Duration(milliseconds: 1500), () { + if (state.value == VoiceCallState.ended) { + state.value = VoiceCallState.idle; + Get.back(); + } + }); + } + + // --- ACTIONS (UI Controls) / إجراءات الواجهة --- + + // EN: Toggles microphone mute state. + // AR: تبديل حالة كتم الميكروفون. + void toggleMute() { + isMuted.value = !isMuted.value; + _localStream?.getAudioTracks().forEach((track) { + track.enabled = !isMuted.value; + }); + } + + // EN: Toggles loudspeaker mode. + // AR: تبديل حالة مكبر الصوت الخارجي. + void toggleSpeaker() { + isSpeakerOn.value = !isSpeakerOn.value; + rtc.Helper.setSpeakerphoneOn(isSpeakerOn.value); + } + + // EN: Displays the call UI overlay. + // AR: إظهار نافذة المكالمة السفلية. + void _showCallBottomSheet() { + Get.bottomSheet( + const VoiceCallBottomSheet(), + isScrollControlled: true, + enableDrag: false, + isDismissible: false, + ); + } + + // EN: Lifecycle hook: clean up resources when controller is destroyed. + // AR: دورة الحياة: تفريغ الذاكرة وإغلاق الموارد عند تدمير المتحكم. + @override + void onClose() { + WidgetsBinding.instance.removeObserver(this); + _countdownTimer?.cancel(); + _ringingTimeoutTimer?.cancel(); + _reconnectTimer?.cancel(); + _stopRingtone(); + _ringtonePlayer?.dispose(); + _peerConnection?.close(); + _localStream?.dispose(); + _signaling.disconnect(); + super.onClose(); + } +} diff --git a/lib/main.dart b/lib/main.dart index ce296f2..fc6e0f7 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 with WidgetsBindingObserver { if (!Get.isRegistered()) { Get.put(SettingController()); } + if (!Get.isRegistered()) { + Get.lazyPut(() => VoiceCallController(), fenix: true); + } await FirebaseMessaging.instance.requestPermission(); await NotificationController().initNotifications(); @@ -394,6 +398,10 @@ class _MyAppState extends State 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 driverList = jsonDecode(savedTrip); @@ -461,7 +469,7 @@ class _MyAppState extends State with WidgetsBindingObserver { } if (!Get.isRegistered()) { - Get.put(HomeCaptainController()); + Get.put(HomeCaptainController(), permanent: true); } else { Get.find().changeRideId(); } diff --git a/lib/services/signaling_service.dart b/lib/services/signaling_service.dart new file mode 100644 index 0000000..57c58d5 --- /dev/null +++ b/lib/services/signaling_service.dart @@ -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 iceServers)? onConnected; + Function(String reason)? onDisconnected; + Function(Map offer)? onOffer; + Function(Map answer)? onAnswer; + Function(Map candidate)? onIceCandidate; + Function(String reason)? onCallEnded; + Function()? onParticipantJoined; + + bool get isConnected => _socket != null && _socket!.readyState == WebSocket.open; + + Future 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) return; + + final type = message['type']; + switch (type) { + case 'authenticated': + final iceServers = message['ice_servers'] as List? ?? []; + 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 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); + } + } +} diff --git a/lib/translations_ar.json b/lib/translations_ar.json index f36d09d..8548723 100644 --- a/lib/translations_ar.json +++ b/lib/translations_ar.json @@ -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": "", diff --git a/lib/translations_en.json b/lib/translations_en.json index c548b38..0f9f380 100644 --- a/lib/translations_en.json +++ b/lib/translations_en.json @@ -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": "", diff --git a/lib/views/auth/captin/otp_page.dart b/lib/views/auth/captin/otp_page.dart index c00a639..4a35123 100644 --- a/lib/views/auth/captin/otp_page.dart +++ b/lib/views/auth/captin/otp_page.dart @@ -307,7 +307,7 @@ class _PhoneNumberScreenState extends State { 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 { 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 { 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 { 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 { ), decoration: InputDecoration( counterText: "", - hintText: '-----', + hintText: '---', hintStyle: TextStyle( color: Colors.black.withOpacity(0.2), letterSpacing: 18, @@ -459,7 +459,7 @@ class _OtpVerificationScreenState extends State { 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), diff --git a/lib/views/auth/captin/otp_token_page.dart b/lib/views/auth/captin/otp_token_page.dart index 12a2474..65f06bb 100644 --- a/lib/views/auth/captin/otp_token_page.dart +++ b/lib/views/auth/captin/otp_token_page.dart @@ -23,9 +23,9 @@ class OtpVerificationPage extends StatefulWidget { class _OtpVerificationPageState extends State { late final OtpVerificationController controller; - final List _focusNodes = List.generate(6, (index) => FocusNode()); + final List _focusNodes = List.generate(3, (index) => FocusNode()); final List _textControllers = - List.generate(6, (index) => TextEditingController()); + List.generate(3, (index) => TextEditingController()); @override void initState() { @@ -50,7 +50,7 @@ class _OtpVerificationPageState extends State { 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 { textDirection: TextDirection.ltr, // لضمان ترتيب الحقول من اليسار لليمين child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate(5, (index) { + children: List.generate(3, (index) { return SizedBox( width: 45, height: 55, diff --git a/lib/views/home/Captin/driver_map_page.dart b/lib/views/home/Captin/driver_map_page.dart index abc817c..7f3b628 100755 --- a/lib/views/home/Captin/driver_map_page.dart +++ b/lib/views/home/Captin/driver_map_page.dart @@ -48,10 +48,11 @@ class PassengerLocationMapPage extends StatelessWidget { Widget build(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.arguments != null && Get.arguments is Map) { + // 🔥 [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( - 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, diff --git a/lib/views/home/Captin/home_captain/home_captin.dart b/lib/views/home/Captin/home_captain/home_captin.dart index a73dbe5..bebf713 100755 --- a/lib/views/home/Captin/home_captain/home_captin.dart +++ b/lib/views/home/Captin/home_captain/home_captin.dart @@ -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) { diff --git a/lib/views/home/Captin/mapDriverWidgets/google_driver_map_page.dart b/lib/views/home/Captin/mapDriverWidgets/google_driver_map_page.dart index c1acf13..e70de83 100755 --- a/lib/views/home/Captin/mapDriverWidgets/google_driver_map_page.dart +++ b/lib/views/home/Captin/mapDriverWidgets/google_driver_map_page.dart @@ -23,49 +23,59 @@ class GoogleDriverMap extends StatelessWidget { final double mapPaddingBottom = MediaQuery.of(context).size.height * 0.3; return GetBuilder( - builder: (controller) => IntaleqMap( - apiKey: AK.mapAPIKEY, - onMapCreated: (mapController) { - controller.onMapCreated(mapController); - }, - mapType: Get.isRegistered() - ? (Get.find().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() + ? (Get.find().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, - ), - }, ), ); } diff --git a/lib/views/home/Captin/mapDriverWidgets/passenger_info_window.dart b/lib/views/home/Captin/mapDriverWidgets/passenger_info_window.dart index 1d4e614..887c6ea 100755 --- a/lib/views/home/Captin/mapDriverWidgets/passenger_info_window.dart +++ b/lib/views/home/Captin/mapDriverWidgets/passenger_info_window.dart @@ -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(); + 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 للقيمة الأصلية } diff --git a/lib/views/home/Captin/mapDriverWidgets/sos_connect.dart b/lib/views/home/Captin/mapDriverWidgets/sos_connect.dart index cd7a4d6..ab72ef9 100755 --- a/lib/views/home/Captin/mapDriverWidgets/sos_connect.dart +++ b/lib/views/home/Captin/mapDriverWidgets/sos_connect.dart @@ -21,14 +21,10 @@ class SosConnect extends StatelessWidget { return GetBuilder( 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', - ); - } } diff --git a/lib/views/home/Captin/orderCaptin/order_over_lay.dart b/lib/views/home/Captin/orderCaptin/order_over_lay.dart index 4c0dc54..96d64e9 100755 --- a/lib/views/home/Captin/orderCaptin/order_over_lay.dart +++ b/lib/views/home/Captin/orderCaptin/order_over_lay.dart @@ -229,8 +229,8 @@ class _OrderOverlayState extends State // بيانات أساسية '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), diff --git a/lib/views/home/journal/schedule_page.dart b/lib/views/home/journal/schedule_page.dart index c6a766e..8ab6870 100644 --- a/lib/views/home/journal/schedule_page.dart +++ b/lib/views/home/journal/schedule_page.dart @@ -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(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)), ), ); } diff --git a/lib/views/home/profile/complaint_page.dart b/lib/views/home/profile/complaint_page.dart new file mode 100644 index 0000000..1c3418d --- /dev/null +++ b/lib/views/home/profile/complaint_page.dart @@ -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( + 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>( + value: controller.selectedRide, + dropdownColor: AppColor.surfaceColor, + items: controller.ridesList.map((ride) { + return DropdownMenuItem>( + 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>( + 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)), + ], + ), + ); + } +} diff --git a/lib/views/home/profile/profile_captain.dart b/lib/views/home/profile/profile_captain.dart index c117770..362f4db 100755 --- a/lib/views/home/profile/profile_captain.dart +++ b/lib/views/home/profile/profile_captain.dart @@ -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 data; @@ -574,7 +574,7 @@ class _InfoRow extends StatelessWidget { child: Text( value, style: theme.textTheme.bodyLarge?.copyWith( - color: theme.textTheme.bodyLarge?.color?.withOpacity(0.8), + color: theme.textTheme.bodyLarge?.color?.withOpacity(0.8), fontWeight: FontWeight.w500), textAlign: TextAlign.end, ), @@ -584,4 +584,3 @@ class _InfoRow extends StatelessWidget { ); } } - diff --git a/lib/views/notification/available_rides_page.dart b/lib/views/notification/available_rides_page.dart index 4545d33..f09219f 100755 --- a/lib/views/notification/available_rides_page.dart +++ b/lib/views/notification/available_rides_page.dart @@ -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) diff --git a/lib/views/widgets/voice_call_bottom_sheet.dart b/lib/views/widgets/voice_call_bottom_sheet.dart new file mode 100644 index 0000000..edf6ba4 --- /dev/null +++ b/lib/views/widgets/voice_call_bottom_sheet.dart @@ -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(); + 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(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, + ), + ), + ], + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 8ac2ca5..e1cca73 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 76bed44..077fd17 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + flutter_webrtc record_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 55a162e..66bbef8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/plans/driver_ride_lifecycle_report.md b/plans/driver_ride_lifecycle_report.md new file mode 100644 index 0000000..0f277d0 --- /dev/null +++ b/plans/driver_ride_lifecycle_report.md @@ -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', + { + '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 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 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> _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 | diff --git a/plans/finish_ride_updates.php b/plans/finish_ride_updates.php new file mode 100644 index 0000000..e6b9a34 --- /dev/null +++ b/plans/finish_ride_updates.php @@ -0,0 +1,290 @@ +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 +} +?> diff --git a/plans/map_driver_controller_review.md b/plans/map_driver_controller_review.md new file mode 100644 index 0000000..cc0224a --- /dev/null +++ b/plans/map_driver_controller_review.md @@ -0,0 +1,40 @@ +# تقرير مراجعة كلاس MapDriverController - النسخة النهائية + +## الإصلاحات المطبقة بالكامل ✅ (15 إصلاحاً) + +### المراجعة الأولى (V1) — 12 إصلاحاً + +| الكود | المشكلة | الحل | الحالة | +|-------|---------|------|--------| +| C-1 | `updateLocation()` for loop تسبب تسرب ذاكرة | `Timer.periodic` مع `startUpdateLocationTimer` و `stopUpdateLocationTimer` | ✅ | +| C-2 | `_validateTripDistance()` تُرجع قبل إغلاق الديالوج | `Completer` مع 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` (تحسين تجاري) | منخفض | +| N-2 | استبدال `Future.delayed` في `argumentLoading` بـ `Completer` (تحسين أداء) | منخفض | \ No newline at end of file diff --git a/plans/process_ride_payments.php b/plans/process_ride_payments.php new file mode 100644 index 0000000..ce27b25 --- /dev/null +++ b/plans/process_ride_payments.php @@ -0,0 +1,141 @@ +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()); +} diff --git a/pubspec.lock b/pubspec.lock index 61fb81d..1232112 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 37363b3..b097615 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/scratch/build_and_catch_error.py b/scratch/build_and_catch_error.py new file mode 100644 index 0000000..db002ea --- /dev/null +++ b/scratch/build_and_catch_error.py @@ -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() diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c693221..c87eaf6 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -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( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 457a134..8dfab87 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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