Food: background location + native delivery overlay

This commit is contained in:
Hamza-Ayed
2026-08-04 03:02:04 +03:00
parent d695834d19
commit ca55aaebe8
11 changed files with 772 additions and 31 deletions
@@ -46,6 +46,17 @@ class TripOverlayPlugin :
methodChannel?.invokeMethod("onTripRejected", mapOf("tripId" to tripId))
Log.d(TAG, "notifyTripRejected: $tripId")
}
/** طلبات توصيل الطعام — قناة منفصلة عن الرحلات في جهة Dart أيضاً */
fun notifyFoodOrderAccepted(orderId: String) {
methodChannel?.invokeMethod("onFoodOrderAccepted", mapOf("orderId" to orderId))
Log.d(TAG, "notifyFoodOrderAccepted: $orderId")
}
fun notifyFoodOrderRejected(orderId: String) {
methodChannel?.invokeMethod("onFoodOrderRejected", mapOf("orderId" to orderId))
Log.d(TAG, "notifyFoodOrderRejected: $orderId")
}
}
// ─── FlutterPlugin ───────────────────────────────────────────────────────
@@ -85,6 +96,16 @@ class TripOverlayPlugin :
val success = showOverlay(tripDataJson, autoCloseSeconds)
result.success(success)
}
"showFoodOverlay" -> {
val foodDataJson =
call.argument<String>("foodData")
?: run {
result.error("INVALID_ARGS", "foodData is required", null)
return
}
val autoCloseSeconds = call.argument<Int>("autoCloseSeconds") ?: 20
result.success(showFoodOverlay(foodDataJson, autoCloseSeconds))
}
"hideOverlay" -> {
hideOverlay()
result.success(null)
@@ -141,6 +162,26 @@ class TripOverlayPlugin :
return true
}
private fun showFoodOverlay(foodDataJson: String, autoCloseSeconds: Int): Boolean {
return try {
val intent =
Intent(context, TripOverlayService::class.java).apply {
action = TripOverlayService.ACTION_SHOW_FOOD
putExtra(TripOverlayService.EXTRA_FOOD_DATA, foodDataJson)
putExtra(TripOverlayService.EXTRA_AUTO_CLOSE_SECONDS, autoCloseSeconds)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
true
} catch (e: Exception) {
Log.e(TAG, "showFoodOverlay failed: ${e.message}")
false
}
}
private fun hideOverlay() {
val intent =
Intent(context, TripOverlayService::class.java).apply {
@@ -28,8 +28,10 @@ class TripOverlayService : Service() {
companion object {
const val TAG = "TripOverlayService"
const val ACTION_SHOW = "SHOW"
const val ACTION_SHOW_FOOD = "SHOW_FOOD"
const val ACTION_HIDE = "HIDE"
const val EXTRA_TRIP_DATA = "trip_data"
const val EXTRA_FOOD_DATA = "food_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"
@@ -43,6 +45,9 @@ class TripOverlayService : Service() {
private var overlayView: View? = null
private var countDownTimer: CountDownTimer? = null
private var currentTripId: String = ""
private var currentFoodOrderId: String = ""
// نوع النافذة المعروضة الآن — يحدّد لمن نُرسل نتيجة القبول/الرفض
private var isFoodOverlay: Boolean = false
private var mediaPlayer: MediaPlayer? = null // 🔴 مشغل الصوت
override fun onBind(intent: Intent?): IBinder? = null
@@ -61,6 +66,11 @@ class TripOverlayService : Service() {
startForegroundWithNotification()
showTripOverlay(tripDataJson, autoClose)
}
ACTION_SHOW_FOOD -> {
val foodDataJson = intent.getStringExtra(EXTRA_FOOD_DATA) ?: return START_NOT_STICKY
val autoClose = intent.getIntExtra(EXTRA_AUTO_CLOSE_SECONDS, 20)
showFoodOverlay(foodDataJson, autoClose)
}
ACTION_HIDE -> dismissOverlay(reason = "programmatic")
}
return START_NOT_STICKY
@@ -171,6 +181,291 @@ class TripOverlayService : Service() {
}
}
// ═══════════════════════════════════════════════════════════════════
// نافذة طلبات التوصيل — تصميم مستقل عن نافذة الرحلة، ويشترك معها في
// البنية المثبتة فقط (الصوت، العدّاد، صلاحية النافذة، الخدمة الأمامية).
// ═══════════════════════════════════════════════════════════════════
private fun showFoodOverlay(foodDataJson: String, autoCloseSeconds: Int) {
removeOverlayView()
val food =
parseFoodData(foodDataJson)
?: run {
stopSelf()
return
}
currentFoodOrderId = food.orderId
isFoodOverlay = true
val view = buildFoodOverlayView(food, 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()
}
try {
windowManager?.addView(view, params)
isRunning = true
playSound()
startCountdown(autoCloseSeconds)
} catch (e: Exception) {
Log.e(TAG, "Food overlay failed: ${e.message}")
stopSelf()
}
}
private fun buildFoodOverlayView(food: FoodInfo, autoCloseSeconds: Int): View {
val ctx = this
val orange = Color.parseColor("#FF6B35")
val card =
FrameLayout(ctx).apply {
background =
GradientDrawable().apply {
cornerRadius = 60f
setColor(Color.WHITE)
setStroke(3, orange)
}
elevation = 40f
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)
// ── ترويسة برتقالية: هوية بصرية تفصل التوصيل عن الرحلة من النظرة الأولى ──
root.addView(
LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
background =
GradientDrawable().apply {
cornerRadii =
floatArrayOf(60f, 60f, 60f, 60f, 0f, 0f, 0f, 0f)
setColor(orange)
}
setPadding(40, 34, 40, 34)
addView(
TextView(ctx).apply {
text = "\uD83C\uDF54 طلب توصيل جديد"
textSize = 17f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
)
addView(
TextView(ctx).apply {
text = food.deliveryFeeText
textSize = 30f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
)
addView(
TextView(ctx).apply {
text = "أجرة التوصيل"
textSize = 12f
setTextColor(Color.parseColor("#FFE0D2"))
gravity = Gravity.CENTER
}
)
}
)
val body =
LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(40, 30, 40, 30)
}
root.addView(body)
// ── المطعم ووجهة التسليم ──
body.addView(labeledRow(ctx, "\uD83C\uDFEA المطعم", food.merchantName))
if (food.merchantAddress.isNotEmpty()) {
body.addView(labeledRow(ctx, "\uD83D\uDCCD العنوان", food.merchantAddress))
}
if (food.distanceText.isNotEmpty()) {
body.addView(labeledRow(ctx, "\uD83D\uDEE3\uFE0F المسافة", food.distanceText))
}
if (food.itemsCount > 0) {
body.addView(labeledRow(ctx, "\uD83E\uDDFE الأصناف", "${food.itemsCount}"))
}
body.addView(
labeledRow(ctx, "\uD83D\uDCB3 الدفع", if (food.isCash) "نقداً" else "محفظة")
)
// ── تنبيه التحصيل النقدي: قرار مالي يحتاجه السائق قبل القبول لا بعده ──
if (food.isCash && food.cashToCollectText.isNotEmpty()) {
body.addView(
TextView(ctx).apply {
text = "\uD83D\uDCB5 تحصّل من الزبون: ${food.cashToCollectText}"
textSize = 13f
setTextColor(Color.parseColor("#8A6100"))
background =
GradientDrawable().apply {
cornerRadius = 24f
setColor(Color.parseColor("#FFF4DC"))
}
setPadding(24, 18, 24, 18)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
.apply { topMargin = 16 }
}
)
}
// ── العدّاد ──
val countdownLabel =
TextView(ctx).apply {
text = "ينتهي خلال $autoCloseSeconds ثانية"
textSize = 13f
setTextColor(Color.parseColor("#7F8C8D"))
gravity = Gravity.CENTER
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
.apply { topMargin = 24 }
}
val progressBar =
ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply {
max = autoCloseSeconds
progress = autoCloseSeconds
progressDrawable?.colorFilter =
android.graphics.PorterDuffColorFilter(
orange,
android.graphics.PorterDuff.Mode.SRC_IN
)
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
15
)
.apply { topMargin = 10 }
}
body.addView(countdownLabel)
body.addView(progressBar)
// ── الأزرار ──
val buttonsRow =
LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
.apply { topMargin = 26 }
}
buttonsRow.addView(
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") }
}
)
buttonsRow.addView(
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 { onFoodOrderAccepted() }
}
)
body.addView(buttonsRow)
card.tag = mapOf("countdownLabel" to countdownLabel, "progressBar" to progressBar)
return card
}
private fun onFoodOrderAccepted() {
stopSound()
countDownTimer?.cancel()
TripOverlayPlugin.notifyFoodOrderAccepted(currentFoodOrderId)
bringAppToForeground()
removeOverlayView()
stopSelf()
}
private fun parseFoodData(json: String): FoodInfo? {
return try {
val obj = JSONObject(json)
FoodInfo(
orderId = obj.getString("orderId"),
merchantName = obj.optString("merchantName", ""),
merchantAddress = obj.optString("merchantAddress", ""),
deliveryFeeText = obj.optString("deliveryFeeText", ""),
cashToCollectText = obj.optString("cashToCollectText", ""),
distanceText = obj.optString("distanceText", ""),
itemsCount = obj.optInt("itemsCount", 0),
isCash = obj.optBoolean("isCash", false)
)
} catch (e: Exception) {
Log.e(TAG, "parseFoodData failed: ${e.message}")
null
}
}
data class FoodInfo(
val orderId: String,
val merchantName: String,
val merchantAddress: String,
val deliveryFeeText: String,
val cashToCollectText: String,
val distanceText: String,
val itemsCount: Int,
val isCash: Boolean
)
private fun buildOverlayView(trip: TripInfo, autoCloseSeconds: Int): View {
val ctx = this
@@ -502,7 +797,12 @@ class TripOverlayService : Service() {
stopSound() // 🔴
countDownTimer?.cancel()
if (reason == "rejected" || reason == "timeout") {
TripOverlayPlugin.notifyTripRejected(currentTripId)
// النتيجة تذهب لقناة النوع المعروض — لا نخلط عرض توصيل بعرض رحلة
if (isFoodOverlay) {
TripOverlayPlugin.notifyFoodOrderRejected(currentFoodOrderId)
} else {
TripOverlayPlugin.notifyTripRejected(currentTripId)
}
}
removeOverlayView()
stopSelf()
@@ -516,7 +816,11 @@ class TripOverlayService : Service() {
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
Intent.FLAG_ACTIVITY_SINGLE_TOP
)
putExtra("acceptedTripId", currentTripId)
if (isFoodOverlay) {
putExtra("acceptedFoodOrderId", currentFoodOrderId)
} else {
putExtra("acceptedTripId", currentTripId)
}
}
startActivity(launchIntent)
}