first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.siro.trip_overlay_plugin">
|
||||
</manifest>
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.intaleq_driver.trip_overlay_plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class TripOverlayPlugin :
|
||||
FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var context: Context
|
||||
private var activity: Activity? = null
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "trip_overlay_plugin"
|
||||
|
||||
const val TAG = "TripOverlayPlugin"
|
||||
const val REQUEST_CODE_OVERLAY = 1001
|
||||
|
||||
// Static reference so TripOverlayService can call back into Flutter
|
||||
var methodChannel: MethodChannel? = null
|
||||
|
||||
/**
|
||||
* Called by TripOverlayService when the driver taps Accept. Sends event Flutter → Dart
|
||||
* side.
|
||||
*/
|
||||
fun notifyTripAccepted(tripId: String) {
|
||||
methodChannel?.invokeMethod("onTripAccepted", mapOf("tripId" to tripId))
|
||||
Log.d(TAG, "notifyTripAccepted: $tripId")
|
||||
}
|
||||
|
||||
/** Called by TripOverlayService when the driver taps Reject or timer expires. */
|
||||
fun notifyTripRejected(tripId: String) {
|
||||
methodChannel?.invokeMethod("onTripRejected", mapOf("tripId" to tripId))
|
||||
Log.d(TAG, "notifyTripRejected: $tripId")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FlutterPlugin ───────────────────────────────────────────────────────
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
channel = MethodChannel(binding.binaryMessenger, CHANNEL)
|
||||
channel.setMethodCallHandler(this)
|
||||
methodChannel = channel
|
||||
Log.d(TAG, "Plugin attached to engine")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
}
|
||||
|
||||
// ─── MethodCallHandler ───────────────────────────────────────────────────
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
"isPermissionGranted" -> {
|
||||
result.success(isOverlayPermissionGranted())
|
||||
}
|
||||
"requestPermission" -> {
|
||||
requestOverlayPermission()
|
||||
result.success(null)
|
||||
}
|
||||
"showOverlay" -> {
|
||||
val tripDataJson =
|
||||
call.argument<String>("tripData")
|
||||
?: run {
|
||||
result.error("INVALID_ARGS", "tripData is required", null)
|
||||
return
|
||||
}
|
||||
val autoCloseSeconds = call.argument<Int>("autoCloseSeconds") ?: 30
|
||||
val success = showOverlay(tripDataJson, autoCloseSeconds)
|
||||
result.success(success)
|
||||
}
|
||||
"hideOverlay" -> {
|
||||
hideOverlay()
|
||||
result.success(null)
|
||||
}
|
||||
"isOverlayActive" -> {
|
||||
result.success(TripOverlayService.isRunning)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permission Helpers ───────────────────────────────────────────────────
|
||||
|
||||
private fun isOverlayPermissionGranted(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(context)
|
||||
} else {
|
||||
true // Pre-M devices don't need runtime permission
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestOverlayPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
)
|
||||
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
activity?.startActivityForResult(intent, REQUEST_CODE_OVERLAY)
|
||||
?: context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay Control ──────────────────────────────────────────────────────
|
||||
|
||||
private fun showOverlay(tripDataJson: String, autoCloseSeconds: Int): Boolean {
|
||||
if (!isOverlayPermissionGranted()) {
|
||||
Log.w(TAG, "Overlay permission not granted")
|
||||
return false
|
||||
}
|
||||
val intent =
|
||||
Intent(context, TripOverlayService::class.java).apply {
|
||||
action = TripOverlayService.ACTION_SHOW
|
||||
putExtra(TripOverlayService.EXTRA_TRIP_DATA, tripDataJson)
|
||||
putExtra(TripOverlayService.EXTRA_AUTO_CLOSE_SECONDS, autoCloseSeconds)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
Log.d(TAG, "showOverlay called with tripData: $tripDataJson")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hideOverlay() {
|
||||
val intent =
|
||||
Intent(context, TripOverlayService::class.java).apply {
|
||||
action = TripOverlayService.ACTION_HIDE
|
||||
}
|
||||
context.startService(intent)
|
||||
Log.d(TAG, "hideOverlay called")
|
||||
}
|
||||
|
||||
// ─── ActivityAware ────────────────────────────────────────────────────────
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
// ─── ActivityResultListener ───────────────────────────────────────────────
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == REQUEST_CODE_OVERLAY) {
|
||||
Log.d(TAG, "Overlay permission result received")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.intaleq_driver.trip_overlay_plugin
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* TripOverlayReceiver
|
||||
*
|
||||
* A BroadcastReceiver you can trigger directly from your FCM background handler (or anywhere
|
||||
* outside Flutter context) to show the overlay without needing an active MethodChannel.
|
||||
*
|
||||
* Usage from your backgroundMessageHandler in main.dart: — Call the static helper
|
||||
* [TripOverlayReceiver.show(context, tripDataJson)]
|
||||
*
|
||||
* Or register in AndroidManifest and send an explicit broadcast.
|
||||
*
|
||||
* Register in AndroidManifest.xml: <receiver
|
||||
* ```
|
||||
* android:name="com.trip_overlay.TripOverlayReceiver"
|
||||
* android:exported="false" />
|
||||
* ```
|
||||
*/
|
||||
class TripOverlayReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "TripOverlayReceiver"
|
||||
const val ACTION = "com.intaleq_driver.SHOW_OVERLAY"
|
||||
const val EXTRA_TRIP_DATA = "trip_data"
|
||||
const val EXTRA_AUTO_CLOSE = "auto_close_seconds"
|
||||
|
||||
/**
|
||||
* Convenience method — call this from your FCM handler (Kotlin/Java) or from a Flutter
|
||||
* MethodChannel invocation.
|
||||
*/
|
||||
fun show(context: Context, tripDataJson: String, autoCloseSeconds: Int = 30) {
|
||||
val intent =
|
||||
Intent(context, TripOverlayService::class.java).apply {
|
||||
action = TripOverlayService.ACTION_SHOW
|
||||
putExtra(TripOverlayService.EXTRA_TRIP_DATA, tripDataJson)
|
||||
putExtra(TripOverlayService.EXTRA_AUTO_CLOSE_SECONDS, autoCloseSeconds)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
Log.d(TAG, "Dispatched show overlay request")
|
||||
}
|
||||
|
||||
/** Hide the overlay programmatically from native side */
|
||||
fun hide(context: Context) {
|
||||
val intent =
|
||||
Intent(context, TripOverlayService::class.java).apply {
|
||||
action = TripOverlayService.ACTION_HIDE
|
||||
}
|
||||
context.startService(intent)
|
||||
Log.d(TAG, "Dispatched hide overlay request")
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TripData JSON string from FCM message data map. Keys match what your server sends
|
||||
* inside the FCM data payload.
|
||||
*
|
||||
* Expected FCM data keys: tripId, passengerName, pickupAddress, dropoffAddress, distanceKm,
|
||||
* estimatedFare, estimatedMinutes, pickupLat, pickupLng
|
||||
*/
|
||||
fun buildTripJsonFromFcmData(data: Map<String, String>): String {
|
||||
return try {
|
||||
JSONObject()
|
||||
.apply {
|
||||
put("tripId", data["tripId"] ?: "")
|
||||
put("passengerName", data["passengerName"] ?: "غير معروف")
|
||||
put("pickupAddress", data["pickupAddress"] ?: "")
|
||||
put("dropoffAddress", data["dropoffAddress"] ?: "")
|
||||
put("distanceKm", data["distanceKm"]?.toDoubleOrNull() ?: 0.0)
|
||||
put("estimatedFare", data["estimatedFare"]?.toDoubleOrNull() ?: 0.0)
|
||||
put("estimatedMinutes", data["estimatedMinutes"]?.toIntOrNull() ?: 0)
|
||||
put("pickupLat", data["pickupLat"]?.toDoubleOrNull() ?: 0.0)
|
||||
put("pickupLng", data["pickupLng"]?.toDoubleOrNull() ?: 0.0)
|
||||
put("passengerAvatarUrl", data["passengerAvatarUrl"] ?: "")
|
||||
}
|
||||
.toString()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error building trip JSON: ${e.message}")
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Broadcast received ───────────────────────────────────────────────────
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION) return
|
||||
|
||||
val tripData =
|
||||
intent.getStringExtra(EXTRA_TRIP_DATA)
|
||||
?: run {
|
||||
Log.e(TAG, "No trip data in broadcast intent")
|
||||
return
|
||||
}
|
||||
val autoClose = intent.getIntExtra(EXTRA_AUTO_CLOSE, 30)
|
||||
show(context, tripData, autoClose)
|
||||
Log.d(TAG, "Received broadcast, showing overlay")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package com.intaleq_driver.trip_overlay_plugin
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.media.MediaPlayer
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.os.CountDownTimer
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import androidx.core.app.NotificationCompat
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONObject
|
||||
|
||||
class TripOverlayService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "TripOverlayService"
|
||||
const val ACTION_SHOW = "SHOW"
|
||||
const val ACTION_HIDE = "HIDE"
|
||||
const val EXTRA_TRIP_DATA = "trip_data"
|
||||
const val EXTRA_AUTO_CLOSE_SECONDS = "auto_close_seconds"
|
||||
private const val NOTIFICATION_ID = 9900
|
||||
private const val CHANNEL_ID = "trip_overlay_service_channel"
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
}
|
||||
|
||||
private var windowManager: WindowManager? = null
|
||||
private var overlayView: View? = null
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
private var currentTripId: String = ""
|
||||
private var mediaPlayer: MediaPlayer? = null // 🔴 مشغل الصوت
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_SHOW -> {
|
||||
val tripDataJson = intent.getStringExtra(EXTRA_TRIP_DATA) ?: return START_NOT_STICKY
|
||||
val autoClose = intent.getIntExtra(EXTRA_AUTO_CLOSE_SECONDS, 15)
|
||||
startForegroundWithNotification()
|
||||
showTripOverlay(tripDataJson, autoClose)
|
||||
}
|
||||
ACTION_HIDE -> dismissOverlay(reason = "programmatic")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
removeOverlayView()
|
||||
countDownTimer?.cancel()
|
||||
stopSound() // 🔴 إيقاف الصوت عند التدمير
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 🔴 الصوت والإشعارات 🔴
|
||||
// ==========================================================
|
||||
private fun playSound() {
|
||||
try {
|
||||
// استخدام صوت التنبيه الافتراضي القوي في الجهاز
|
||||
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
mediaPlayer = MediaPlayer.create(this, soundUri)
|
||||
mediaPlayer?.isLooping = true // تكرار الصوت حتى يتخذ السائق قراراً
|
||||
mediaPlayer?.start()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error playing sound: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSound() {
|
||||
try {
|
||||
mediaPlayer?.takeIf { it.isPlaying }?.stop()
|
||||
mediaPlayer?.release()
|
||||
mediaPlayer = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping sound: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Trip Overlay Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundWithNotification() {
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("طلب رحلة جديد")
|
||||
.setContentText("يوجد طلب رحلة في انتظارك")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_map)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 🔴 بناء وتصميم النافذة (Premium UI) 🔴
|
||||
// ==========================================================
|
||||
private fun showTripOverlay(tripDataJson: String, autoCloseSeconds: Int) {
|
||||
removeOverlayView()
|
||||
|
||||
val tripData =
|
||||
parseTripData(tripDataJson)
|
||||
?: run {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
currentTripId = tripData.tripId
|
||||
|
||||
val view = buildOverlayView(tripData, autoCloseSeconds)
|
||||
overlayView = view
|
||||
|
||||
val type =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
else @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE
|
||||
|
||||
val params =
|
||||
WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
|
||||
PixelFormat.TRANSLUCENT
|
||||
)
|
||||
.apply {
|
||||
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
val metrics = resources.displayMetrics
|
||||
y = (metrics.heightPixels * 0.15).toInt() // النزول للأسفل بنسبة 15%
|
||||
}
|
||||
|
||||
try {
|
||||
windowManager?.addView(view, params)
|
||||
isRunning = true
|
||||
playSound() // 🔴 تشغيل الصوت
|
||||
startCountdown(autoCloseSeconds)
|
||||
} catch (e: Exception) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildOverlayView(trip: TripInfo, autoCloseSeconds: Int): View {
|
||||
val ctx = this
|
||||
|
||||
// الخلفية الرئيسية بستايل الكروت الحديثة (حواف دائرية كبيرة)
|
||||
val card =
|
||||
FrameLayout(ctx).apply {
|
||||
background =
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = 60f
|
||||
setColor(Color.WHITE)
|
||||
setStroke(2, Color.parseColor("#E0E0E0")) // إطار خفيف
|
||||
}
|
||||
elevation = 40f
|
||||
setPadding(0, 0, 0, 0)
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
.apply {
|
||||
setMargins(40, 0, 40, 0) // هوامش جانبية
|
||||
}
|
||||
}
|
||||
|
||||
val root = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||
card.addView(root)
|
||||
|
||||
// ── 1. صورة الخريطة (Static Map) ──
|
||||
val mapImageView =
|
||||
ImageView(ctx).apply {
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
350
|
||||
) // ارتفاع الخريطة
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setBackgroundColor(Color.parseColor("#F3F4F6")) // لون رمادي فاتح كخلفية بديلة
|
||||
}
|
||||
root.addView(mapImageView)
|
||||
|
||||
// 🔴 دالة تحميل الخريطة (شغالة وتحتاج فقط مفتاحك)
|
||||
loadStaticMap(mapImageView, trip.pickupLat, trip.pickupLng)
|
||||
|
||||
// ── حاوية التفاصيل ──
|
||||
val detailsContainer =
|
||||
LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(40, 30, 40, 40)
|
||||
}
|
||||
root.addView(detailsContainer)
|
||||
|
||||
// ── 2. السعر والمسافة (خط كبير وواضح) ──
|
||||
val priceRow =
|
||||
LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
}
|
||||
|
||||
val priceText =
|
||||
TextView(ctx).apply {
|
||||
text = "${trip.estimatedFare} ل.س"
|
||||
textSize = 24f
|
||||
setTextColor(Color.parseColor("#27AE60")) // لون أخضر فخم
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val distanceText =
|
||||
TextView(ctx).apply {
|
||||
text = "المسافة: ${trip.distanceKm} كم"
|
||||
textSize = 14f
|
||||
setTextColor(Color.parseColor("#7F8C8D"))
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
priceRow.addView(priceText)
|
||||
priceRow.addView(distanceText)
|
||||
detailsContainer.addView(priceRow)
|
||||
|
||||
detailsContainer.addView(divider(ctx, 30))
|
||||
|
||||
// ── 3. المواقع (الانطلاق والوصول) ──
|
||||
detailsContainer.addView(labeledRow(ctx, "🟢 من:", trip.pickupAddress))
|
||||
detailsContainer.addView(labeledRow(ctx, "🔴 إلى:", trip.dropoffAddress))
|
||||
detailsContainer.addView(labeledRow(ctx, "👤 الراكب:", trip.passengerName))
|
||||
|
||||
detailsContainer.addView(divider(ctx, 30))
|
||||
|
||||
// ── 4. شريط الوقت ──
|
||||
val countdownLabel =
|
||||
TextView(ctx).apply {
|
||||
text = "ينتهي خلال $autoCloseSeconds ثانية"
|
||||
textSize = 12f
|
||||
setTextColor(Color.parseColor("#E74C3C"))
|
||||
gravity = Gravity.CENTER
|
||||
id = android.R.id.text1
|
||||
}
|
||||
val progressBar =
|
||||
ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
max = autoCloseSeconds
|
||||
progress = autoCloseSeconds
|
||||
id = android.R.id.progress
|
||||
progressTintList =
|
||||
android.content.res.ColorStateList.valueOf(Color.parseColor("#E74C3C"))
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 15)
|
||||
.apply {
|
||||
topMargin = 10
|
||||
bottomMargin = 30
|
||||
}
|
||||
}
|
||||
detailsContainer.addView(countdownLabel)
|
||||
detailsContainer.addView(progressBar)
|
||||
|
||||
// ── 5. الأزرار (تصميم كبسولة فخم) ──
|
||||
val buttonsRow = LinearLayout(ctx).apply { orientation = LinearLayout.HORIZONTAL }
|
||||
|
||||
val rejectBtn =
|
||||
Button(ctx).apply {
|
||||
text = "رفض"
|
||||
textSize = 16f
|
||||
setTextColor(Color.parseColor("#E74C3C")) // نص أحمر
|
||||
background =
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = 100f
|
||||
setColor(Color.parseColor("#FDEDEC")) // خلفية حمراء باهتة فخمة
|
||||
}
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
.apply { rightMargin = 20 }
|
||||
setOnClickListener { dismissOverlay(reason = "rejected") }
|
||||
}
|
||||
|
||||
val acceptBtn =
|
||||
Button(ctx).apply {
|
||||
text = "قبول الطلب"
|
||||
textSize = 16f
|
||||
setTextColor(Color.WHITE)
|
||||
background =
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = 100f
|
||||
setColor(Color.parseColor("#27AE60")) // لون أخضر فخم
|
||||
}
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1.5f
|
||||
) // زر القبول أكبر قليلاً
|
||||
setOnClickListener { onTripAccepted() }
|
||||
}
|
||||
|
||||
buttonsRow.addView(rejectBtn)
|
||||
buttonsRow.addView(acceptBtn)
|
||||
detailsContainer.addView(buttonsRow)
|
||||
|
||||
card.tag = mapOf("countdownLabel" to countdownLabel, "progressBar" to progressBar)
|
||||
return card
|
||||
}
|
||||
// 🔴 دالة احترافية لقراءة المفتاح المحقون من Gradle 🔴
|
||||
private fun getGoogleMapsApiKey(): String {
|
||||
return try {
|
||||
// الوصول إلى بيانات التطبيق في الـ Manifest
|
||||
val appInfo =
|
||||
packageManager.getApplicationInfo(
|
||||
packageName,
|
||||
android.content.pm.PackageManager.GET_META_DATA
|
||||
)
|
||||
val bundle = appInfo.metaData
|
||||
|
||||
// قراءة المفتاح (تأكد أن هذا هو نفس الاسم الموجود في AndroidManifest.xml لديك)
|
||||
val apiKey = bundle?.getString("com.google.android.geo.API_KEY")
|
||||
|
||||
apiKey ?: "" // إرجاع المفتاح أو نص فارغ إذا لم يجده
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ فشل في قراءة مفتاح الخريطة من Manifest: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 🔴 جلب صورة الخريطة (Static Map) 🔴
|
||||
// ==========================================================
|
||||
// ==========================================================
|
||||
// 🔴 جلب صورة الخريطة (Static Map) 🔴
|
||||
// ==========================================================
|
||||
private fun loadStaticMap(imageView: ImageView, lat: Double, lng: Double) {
|
||||
if (lat == 0.0 || lng == 0.0) {
|
||||
Log.e(TAG, "⚠️ الإحداثيات صفر، تم تجاهل تحميل الخريطة")
|
||||
return
|
||||
}
|
||||
|
||||
// 🟢 جلب المفتاح بأمان تام من الـ Properties عبر الـ Manifest 🟢
|
||||
val apiKey = getGoogleMapsApiKey()
|
||||
|
||||
if (apiKey.isEmpty()) {
|
||||
Log.e(TAG, "⚠️ مفتاح جوجل ماب غير موجود أو فارغ!")
|
||||
return
|
||||
}
|
||||
|
||||
// رابط احترافي: زووم مناسب، وضع خريطة نظيف، ودبوس أخضر بارز
|
||||
val mapUrl =
|
||||
"https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x350&maptype=roadmap&markers=color:0x27AE60%7C$lat,$lng&key=$apiKey"
|
||||
|
||||
thread {
|
||||
try {
|
||||
val url = URL(mapUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.doInput = true
|
||||
connection.connectTimeout = 5000 // تحديد وقت أقصى للاتصال
|
||||
connection.readTimeout = 5000
|
||||
connection.connect()
|
||||
|
||||
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
|
||||
val bitmap = BitmapFactory.decodeStream(connection.inputStream)
|
||||
// عرض الصورة في الواجهة (Main Thread)
|
||||
Handler(Looper.getMainLooper()).post { imageView.setImageBitmap(bitmap) }
|
||||
} else {
|
||||
Log.e(TAG, "❌ خطأ من سيرفر جوجل: ${connection.responseCode}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ فشل الاتصال بخدمة الخرائط: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 🔴 أدوات مساعدة للتصميم 🔴
|
||||
// ==========================================================
|
||||
private fun labeledRow(ctx: Context, label: String, value: String): LinearLayout {
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 8, 0, 8)
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
|
||||
addView(
|
||||
TextView(ctx).apply {
|
||||
text = label
|
||||
textSize = 14f
|
||||
setTextColor(Color.parseColor("#7F8C8D"))
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
.apply { rightMargin = 16 }
|
||||
}
|
||||
)
|
||||
addView(
|
||||
TextView(ctx).apply {
|
||||
text = value
|
||||
textSize = 15f
|
||||
setTextColor(Color.parseColor("#2C3E50"))
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
maxLines = 1
|
||||
ellipsize = android.text.TextUtils.TruncateAt.END
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statChip(ctx: Context, value: String, label: String): LinearLayout {
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
|
||||
addView(
|
||||
TextView(ctx).apply {
|
||||
text = value
|
||||
textSize = 16f
|
||||
setTextColor(Color.parseColor("#2C3E50"))
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
)
|
||||
addView(
|
||||
TextView(ctx).apply {
|
||||
text = label
|
||||
textSize = 12f
|
||||
setTextColor(Color.parseColor("#95A5A6"))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun divider(ctx: Context, margin: Int = 16): View {
|
||||
return View(ctx).apply {
|
||||
setBackgroundColor(Color.parseColor("#EEEEEE"))
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 2).apply {
|
||||
setMargins(0, margin, 0, margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 🔴 التحكم والمؤقت 🔴
|
||||
// ==========================================================
|
||||
private fun startCountdown(totalSeconds: Int) {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer =
|
||||
object : CountDownTimer(totalSeconds * 1000L, 1000L) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
updateCountdownUI((millisUntilFinished / 1000).toInt())
|
||||
}
|
||||
override fun onFinish() {
|
||||
dismissOverlay(reason = "timeout")
|
||||
}
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun updateCountdownUI(secondsLeft: Int) {
|
||||
val tagMap = overlayView?.tag as? Map<*, *> ?: return
|
||||
val label = tagMap["countdownLabel"] as? TextView ?: return
|
||||
val bar = tagMap["progressBar"] as? ProgressBar ?: return
|
||||
label.text = "ينتهي خلال $secondsLeft ثانية"
|
||||
bar.progress = secondsLeft
|
||||
}
|
||||
|
||||
private fun onTripAccepted() {
|
||||
stopSound() // 🔴
|
||||
TripOverlayPlugin.notifyTripAccepted(currentTripId)
|
||||
bringAppToForeground()
|
||||
removeOverlayView()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun dismissOverlay(reason: String) {
|
||||
stopSound() // 🔴
|
||||
countDownTimer?.cancel()
|
||||
if (reason == "rejected" || reason == "timeout") {
|
||||
TripOverlayPlugin.notifyTripRejected(currentTripId)
|
||||
}
|
||||
removeOverlayView()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun bringAppToForeground() {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?: return
|
||||
launchIntent.apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
)
|
||||
putExtra("acceptedTripId", currentTripId)
|
||||
}
|
||||
startActivity(launchIntent)
|
||||
}
|
||||
|
||||
private fun removeOverlayView() {
|
||||
overlayView?.let { windowManager?.removeView(it) }
|
||||
overlayView = null
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private fun parseTripData(json: String): TripInfo? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
TripInfo(
|
||||
tripId = obj.getString("tripId"),
|
||||
passengerName = obj.getString("passengerName"),
|
||||
pickupAddress = obj.getString("pickupAddress"),
|
||||
dropoffAddress = obj.getString("dropoffAddress"),
|
||||
distanceKm = obj.getDouble("distanceKm"),
|
||||
estimatedFare = obj.getDouble("estimatedFare"),
|
||||
estimatedMinutes = obj.getInt("estimatedMinutes"),
|
||||
pickupLat = obj.getDouble("pickupLat"),
|
||||
pickupLng = obj.getDouble("pickupLng")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class TripInfo(
|
||||
val tripId: String,
|
||||
val passengerName: String,
|
||||
val pickupAddress: String,
|
||||
val dropoffAddress: String,
|
||||
val distanceKm: Double,
|
||||
val estimatedFare: Double,
|
||||
val estimatedMinutes: Int,
|
||||
val pickupLat: Double,
|
||||
val pickupLng: Double
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user