import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/services.dart'; /// Model for trip data passed to the overlay class TripData { final String tripId; final String passengerName; final String pickupAddress; final String dropoffAddress; final double distanceKm; final double estimatedFare; final int estimatedMinutes; final double pickupLat; final double pickupLng; final String? passengerAvatarUrl; TripData({ required this.tripId, required this.passengerName, required this.pickupAddress, required this.dropoffAddress, required this.distanceKm, required this.estimatedFare, required this.estimatedMinutes, required this.pickupLat, required this.pickupLng, this.passengerAvatarUrl, }); Map toMap() => { 'tripId': tripId, 'passengerName': passengerName, 'pickupAddress': pickupAddress, 'dropoffAddress': dropoffAddress, 'distanceKm': distanceKm, 'estimatedFare': estimatedFare, 'estimatedMinutes': estimatedMinutes, 'pickupLat': pickupLat, 'pickupLng': pickupLng, 'passengerAvatarUrl': passengerAvatarUrl ?? '', }; factory TripData.fromMap(Map map) => TripData( tripId: map['tripId'] ?? '', passengerName: map['passengerName'] ?? '', pickupAddress: map['pickupAddress'] ?? '', dropoffAddress: map['dropoffAddress'] ?? '', distanceKm: (map['distanceKm'] ?? 0.0).toDouble(), estimatedFare: (map['estimatedFare'] ?? 0.0).toDouble(), estimatedMinutes: map['estimatedMinutes'] ?? 0, pickupLat: (map['pickupLat'] ?? 0.0).toDouble(), pickupLng: (map['pickupLng'] ?? 0.0).toDouble(), passengerAvatarUrl: map['passengerAvatarUrl'], ); factory TripData.fromJson(String json) => TripData.fromMap(jsonDecode(json) as Map); String toJson() => jsonEncode(toMap()); } /// بيانات طلب توصيل الطعام المعروضة في النافذة العائمة. /// المبالغ تصل **منسّقة نصّاً** عمداً: التنسيق والعملة يخصّان التطبيق لا /// الطبقة الأصلية، فلا نكرّر منطق قسمة الوحدة الصغرى داخل Kotlin. class FoodOrderOverlayData { final String orderId; final String merchantName; final String merchantAddress; final String deliveryFeeText; final String cashToCollectText; final String distanceText; final int itemsCount; final bool isCash; FoodOrderOverlayData({ required this.orderId, required this.merchantName, required this.deliveryFeeText, this.merchantAddress = '', this.cashToCollectText = '', this.distanceText = '', this.itemsCount = 0, this.isCash = false, }); Map toMap() => { 'orderId': orderId, 'merchantName': merchantName, 'merchantAddress': merchantAddress, 'deliveryFeeText': deliveryFeeText, 'cashToCollectText': cashToCollectText, 'distanceText': distanceText, 'itemsCount': itemsCount, 'isCash': isCash, }; String toJson() => jsonEncode(toMap()); } /// Result returned when the driver accepts a trip class TripAcceptedResult { final String tripId; final DateTime acceptedAt; TripAcceptedResult({required this.tripId, required this.acceptedAt}); } /// Main plugin class — single entry point for Flutter side class TripOverlayPlugin { static const MethodChannel _channel = MethodChannel('trip_overlay_plugin'); // Stream controller for trip accepted events coming FROM Android overlay static final StreamController _tripAcceptedController = StreamController.broadcast(); // Stream controller for trip rejected/expired events static final StreamController _tripRejectedController = StreamController.broadcast(); // قناة طلبات التوصيل منفصلة عن قناة الرحلات — لا يخلط المستمع بين النوعين static final StreamController _foodAcceptedController = StreamController.broadcast(); static final StreamController _foodRejectedController = StreamController.broadcast(); /// معرّف طلب التوصيل الذي قبله السائق من النافذة العائمة static Stream get onFoodOrderAccepted => _foodAcceptedController.stream; /// معرّف طلب التوصيل الذي رفضه السائق أو انتهت مهلته static Stream get onFoodOrderRejected => _foodRejectedController.stream; static bool _isInitialized = false; /// Stream that fires when the driver taps "Accept" in the overlay static Stream get onTripAccepted => _tripAcceptedController.stream; /// Stream that fires when the driver rejects or overlay times out static Stream get onTripRejected => _tripRejectedController.stream; /// Initialize the plugin — call this once in main() or initState() static Future initialize() async { if (_isInitialized) return; _channel.setMethodCallHandler(_handleMethodCall); _isInitialized = true; } /// Handle incoming calls FROM Android → Flutter static Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'onTripAccepted': final tripId = call.arguments['tripId'] as String; _tripAcceptedController.add( TripAcceptedResult(tripId: tripId, acceptedAt: DateTime.now()), ); break; case 'onTripRejected': final tripId = call.arguments['tripId'] as String; _tripRejectedController.add(tripId); break; case 'onFoodOrderAccepted': _foodAcceptedController.add(call.arguments['orderId'].toString()); break; case 'onFoodOrderRejected': _foodRejectedController.add(call.arguments['orderId'].toString()); break; default: throw PlatformException( code: 'UNKNOWN_METHOD', message: 'Method ${call.method} not implemented', ); } } /// هذه الإضافة **أندرويد فقط** — لا يوجد تنفيذ iOS ولا مجلد ios/ ولا مدخل /// في pubspec.plugin.platforms. iOS لا يسمح بنوافذ فوق التطبيقات أصلاً. /// /// بلا هذا الحرس كان أي نداء يرمي MissingPluginException على iOS، وأخطر /// موضع: backgroundMessageHandler ينادي showOverlay **قبل** حفظ /// pending_driver_list، فيسقط الاستثناء ويُلغي الحفظ ⇒ يفتح الكابتن الإشعار /// فلا يجد طلباً وتبدأ الشاشة من الصفر. static bool get isSupported => Platform.isAndroid; /// Check if SYSTEM_ALERT_WINDOW permission is granted static Future isPermissionGranted() async { if (!isSupported) return false; try { final result = await _channel.invokeMethod('isPermissionGranted'); return result ?? false; } on PlatformException catch (e) { debugPrint('[TripOverlay] isPermissionGranted failed: ${e.message}'); return false; } on MissingPluginException { return false; } } /// Open system settings to grant SYSTEM_ALERT_WINDOW permission static Future requestPermission() async { if (!isSupported) return; try { await _channel.invokeMethod('requestPermission'); } on PlatformException catch (e) { debugPrint('[TripOverlay] requestPermission failed: ${e.message}'); } on MissingPluginException { // لا شيء — غير مدعوم على هذه المنصة } } /// Show the trip overlay with the given [tripData] /// [autoCloseSeconds] — how long before auto-dismiss (default 30s) /// /// يرجع false ولا يرمي أبداً: المُنادي يجب أن يكمل مساره (حفظ الطلب المعلّق، /// الإشعار المحلي) حتى لو تعذّرت النافذة. static Future showOverlay( TripData tripData, { int autoCloseSeconds = 30, }) async { if (!isSupported) return false; final granted = await isPermissionGranted(); if (!granted) { await requestPermission(); return false; } try { final result = await _channel.invokeMethod('showOverlay', { 'tripData': tripData.toJson(), 'autoCloseSeconds': autoCloseSeconds, }); return result ?? false; } on PlatformException catch (e) { debugPrint('[TripOverlay] showOverlay failed: ${e.message}'); return false; } on MissingPluginException { return false; } } /// نافذة عرض توصيل طعام. تعيد false ولا ترمي أبداً — المُنادي يكمل مساره /// (إشعار محلي، حفظ العرض) حتى لو تعذّرت النافذة أو نقصت الصلاحية. static Future showFoodOverlay( FoodOrderOverlayData data, { int autoCloseSeconds = 20, }) async { if (!isSupported) return false; final granted = await isPermissionGranted(); if (!granted) { await requestPermission(); return false; } try { final result = await _channel.invokeMethod('showFoodOverlay', { 'foodData': data.toJson(), 'autoCloseSeconds': autoCloseSeconds, }); return result ?? false; } on PlatformException catch (e) { debugPrint('[TripOverlay] showFoodOverlay failed: ${e.message}'); return false; } on MissingPluginException { return false; } } /// Programmatically close the overlay (e.g. if trip was cancelled) static Future hideOverlay() async { if (!isSupported) return; try { await _channel.invokeMethod('hideOverlay'); } on PlatformException catch (e) { debugPrint('[TripOverlay] hideOverlay failed: ${e.message}'); } on MissingPluginException { // لا شيء } } /// Check if the overlay is currently visible static Future isOverlayActive() async { if (!isSupported) return false; try { final result = await _channel.invokeMethod('isOverlayActive'); return result ?? false; } on PlatformException catch (e) { debugPrint('[TripOverlay] isOverlayActive failed: ${e.message}'); return false; } on MissingPluginException { return false; } } /// Dispose streams — call in app's dispose() static void dispose() { _tripAcceptedController.close(); _tripRejectedController.close(); } }