Files
Siro/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart
T

325 lines
11 KiB
Dart

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;
/// نوع الطلب: 'exclusive' خلال ثواني السبق الحصري، 'public' بعده،
/// و null في وضع البثّ الحر (عندها لا تظهر أي شارة).
final String? offerType;
/// ثواني السبق المتبقية. بعد انقضائها تقلب النافذة الشارة إلى "طلب عام"
/// من نفسها — النافذة لا تُغلق، فالسائق يظل قادراً على القبول.
final int offerExpiresIn;
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,
this.offerType,
this.offerExpiresIn = 0,
});
Map<String, dynamic> toMap() => {
'tripId': tripId,
'passengerName': passengerName,
'pickupAddress': pickupAddress,
'dropoffAddress': dropoffAddress,
'distanceKm': distanceKm,
'estimatedFare': estimatedFare,
'estimatedMinutes': estimatedMinutes,
'pickupLat': pickupLat,
'pickupLng': pickupLng,
'passengerAvatarUrl': passengerAvatarUrl ?? '',
'offerType': offerType ?? '',
'offerExpiresIn': offerExpiresIn,
};
factory TripData.fromMap(Map<String, dynamic> 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,
offerType: (map['offerType'] ?? '').toString().isEmpty
? null
: map['offerType'].toString(),
offerExpiresIn: int.tryParse('${map['offerExpiresIn'] ?? 0}') ?? 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, dynamic>);
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<String, dynamic> 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<TripAcceptedResult> _tripAcceptedController =
StreamController<TripAcceptedResult>.broadcast();
// Stream controller for trip rejected/expired events
static final StreamController<String> _tripRejectedController =
StreamController<String>.broadcast();
// قناة طلبات التوصيل منفصلة عن قناة الرحلات — لا يخلط المستمع بين النوعين
static final StreamController<String> _foodAcceptedController =
StreamController<String>.broadcast();
static final StreamController<String> _foodRejectedController =
StreamController<String>.broadcast();
/// معرّف طلب التوصيل الذي قبله السائق من النافذة العائمة
static Stream<String> get onFoodOrderAccepted => _foodAcceptedController.stream;
/// معرّف طلب التوصيل الذي رفضه السائق أو انتهت مهلته
static Stream<String> get onFoodOrderRejected => _foodRejectedController.stream;
static bool _isInitialized = false;
/// Stream that fires when the driver taps "Accept" in the overlay
static Stream<TripAcceptedResult> get onTripAccepted =>
_tripAcceptedController.stream;
/// Stream that fires when the driver rejects or overlay times out
static Stream<String> get onTripRejected => _tripRejectedController.stream;
/// Initialize the plugin — call this once in main() or initState()
static Future<void> initialize() async {
if (_isInitialized) return;
_channel.setMethodCallHandler(_handleMethodCall);
_isInitialized = true;
}
/// Handle incoming calls FROM Android → Flutter
static Future<dynamic> _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<bool> isPermissionGranted() async {
if (!isSupported) return false;
try {
final result = await _channel.invokeMethod<bool>('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<void> 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<bool> 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<bool>('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<bool> 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<bool>('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<void> 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<bool> isOverlayActive() async {
if (!isSupported) return false;
try {
final result = await _channel.invokeMethod<bool>('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();
}
}