Food delivery driver module + masked calls + TURN
This commit is contained in:
@@ -114,4 +114,7 @@ class BoxName {
|
||||
static const String isDestinationMatch =
|
||||
'isDestinationMatch'; // 🆕 AI Destination Matching
|
||||
static const String isPrime = 'isPrime'; // 👑 Siro Prime subscription status
|
||||
|
||||
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
|
||||
static const String turnIceCache = 'turnIceCache';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../constant/links.dart';
|
||||
import '../../main.dart';
|
||||
import '../../print.dart';
|
||||
import '../../services/signaling_service.dart';
|
||||
import '../../services/turn_credentials_service.dart';
|
||||
import '../../views/widgets/voice_call_bottom_sheet.dart';
|
||||
import 'functions/crud.dart';
|
||||
|
||||
@@ -231,11 +232,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
|
||||
// EN: Initiates an outgoing call.
|
||||
// AR: يبدأ مكالمة صادرة.
|
||||
/// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (طلبات الطعام)
|
||||
/// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها،
|
||||
/// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة.
|
||||
Future<void> startCall({
|
||||
required String rideIdVal,
|
||||
required String driverId,
|
||||
required String passengerId,
|
||||
required String remoteNameVal,
|
||||
String? sessionEndpoint,
|
||||
Map<String, String>? sessionPayload,
|
||||
}) async {
|
||||
if (state.value != VoiceCallState.idle) return;
|
||||
|
||||
@@ -266,8 +272,9 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
// 2. EN: Call PHP Backend to create Node.js session & notify Driver via FCM.
|
||||
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM.
|
||||
final response = await CRUD().post(
|
||||
link: "${AppLink.server}/ride/call/passenger/create_call_session.php",
|
||||
payload: {'ride_id': rideIdVal},
|
||||
link: sessionEndpoint ??
|
||||
"${AppLink.server}/ride/call/passenger/create_call_session.php",
|
||||
payload: sessionPayload ?? {'ride_id': rideIdVal},
|
||||
);
|
||||
|
||||
if (response == null ||
|
||||
@@ -278,8 +285,15 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = response['data'];
|
||||
sessionId.value = data['session_id'];
|
||||
// واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في
|
||||
// 'message' (غلاف jsonSuccess الموحّد) — نقبل الشكلين.
|
||||
final data = response['data'] ?? response['message'];
|
||||
if (data is! Map || data['session_id'] == null) {
|
||||
_endCallInternal("session_creation_failed");
|
||||
mySnackbarError("Error starting voice call".tr);
|
||||
return;
|
||||
}
|
||||
sessionId.value = data['session_id'].toString();
|
||||
|
||||
// 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
|
||||
await _signaling.connect(sessionId.value, currentUserId);
|
||||
@@ -473,7 +487,15 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// خوادم TURN من الباك إند — بيانات اعتماد مؤقتة. بلا TURN تفشل المكالمة
|
||||
// صامتةً حين يكون الطرفان خلف CGNAT (شبكة الجوال)، وهي الحالة الغالبة.
|
||||
// نُضيفها قبل خوادم الإشارات: STUN يبقى أولاً في الترتيب داخل القائمة.
|
||||
final turnServers = await TurnCredentialsService.getIceServers();
|
||||
iceServers.addAll(turnServers);
|
||||
|
||||
if (iceServers.isEmpty) {
|
||||
// EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية
|
||||
iceServers.addAll([
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// turn_credentials_service.dart — جلب بيانات اعتماد TURN المؤقتة وتخزينها
|
||||
//
|
||||
// خادم الإشارات (Node) يُعيد STUN فقط، وSTUN وحده لا يكفي حين يكون الطرفان
|
||||
// خلف CGNAT — وهي الحالة الغالبة لسائق وزبون على بيانات الجوال. نجلب TURN
|
||||
// من الباك إند ونضمّه إلى قائمة ICE قبل إنشاء الاتصال.
|
||||
//
|
||||
// البيانات مؤقتة (HMAC بمهلة) لا كلمة مرور ثابتة داخل التطبيق، ونُخزّنها
|
||||
// محلياً حتى قرب انتهائها فلا نُثقل بنداء شبكة قبل كل مكالمة.
|
||||
import 'dart:convert';
|
||||
|
||||
import '../constant/box_name.dart';
|
||||
import '../constant/links.dart';
|
||||
import '../controller/functions/crud.dart';
|
||||
import '../main.dart';
|
||||
import '../print.dart';
|
||||
|
||||
class TurnCredentialsService {
|
||||
static const String _cacheKey = BoxName.turnIceCache;
|
||||
|
||||
/// خوادم ICE جاهزة لتمريرها إلى createPeerConnection.
|
||||
/// تعيد قائمة فارغة عند أي فشل — والمُنادي يكمل بـ STUN كما كان سابقاً.
|
||||
static Future<List<Map<String, dynamic>>> getIceServers() async {
|
||||
final cached = _readCache();
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final res = await CRUD().post(
|
||||
link: '${AppLink.server}/ride/call/turn_credentials.php',
|
||||
);
|
||||
|
||||
if (res is! Map || res['status'] != 'success') return [];
|
||||
|
||||
// غلاف jsonSuccess يضع الحمولة في message
|
||||
final payload = res['message'];
|
||||
if (payload is! Map || payload['ice_servers'] is! List) return [];
|
||||
|
||||
final servers = (payload['ice_servers'] as List)
|
||||
.whereType<Map>()
|
||||
.map((s) => Map<String, dynamic>.from(s))
|
||||
.toList();
|
||||
|
||||
final ttl = int.tryParse(payload['ttl']?.toString() ?? '0') ?? 0;
|
||||
if (ttl > 0 && servers.isNotEmpty) {
|
||||
// ننتهي قبل الخادم بخمس دقائق تفادياً لسباق انتهاء الصلاحية أثناء مكالمة
|
||||
final expiresAt = DateTime.now().add(Duration(seconds: ttl - 300));
|
||||
box.write(_cacheKey, jsonEncode({
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
'servers': servers,
|
||||
}));
|
||||
}
|
||||
|
||||
return servers;
|
||||
} catch (e) {
|
||||
Log.print('⚠️ [TURN] تعذّر جلب بيانات الاعتماد: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>>? _readCache() {
|
||||
try {
|
||||
final raw = box.read(_cacheKey);
|
||||
if (raw == null) return null;
|
||||
|
||||
final data = jsonDecode(raw.toString());
|
||||
final expiresAt = DateTime.tryParse(data['expires_at']?.toString() ?? '');
|
||||
if (expiresAt == null || DateTime.now().isAfter(expiresAt)) return null;
|
||||
|
||||
final servers = (data['servers'] as List)
|
||||
.whereType<Map>()
|
||||
.map((s) => Map<String, dynamic>.from(s))
|
||||
.toList();
|
||||
return servers.isEmpty ? null : servers;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache() => box.remove(_cacheKey);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import 'package:get/get.dart';
|
||||
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../constant/colors.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../main.dart';
|
||||
import '../../controller/food/food_controller.dart';
|
||||
import '../../controller/voice_call_controller.dart';
|
||||
import '../../controller/food/food_models.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
import 'food_home_page.dart';
|
||||
@@ -127,7 +129,27 @@ class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
|
||||
Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
// الاتصال بالسائق متاح فقط أثناء التوصيل، وعبر قناة مقنّعة:
|
||||
// لا رقم هاتف يُعرض لأي طرف — جلسة صوتية مؤقتة تُقفل بانتهاء الطلب.
|
||||
if (order.status == 'courier_assigned' || order.status == 'picked_up')
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppColor.primaryColor),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: () => _callCourier(order.id),
|
||||
icon: Icon(Icons.phone_in_talk_rounded, color: AppColor.primaryColor),
|
||||
label: Text(
|
||||
_isAr ? 'الاتصال بالسائق' : 'Call the courier',
|
||||
style: TextStyle(color: AppColor.primaryColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (order.status == 'pending')
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -172,6 +194,27 @@ class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// القناة نفسها المستعملة في مكالمات الرحلات (WebRTC عبر خادم الإشارات)،
|
||||
// لكن الجلسة تُنشأ من واجهة الطعام التي تتحقق أن الطلب لي وأنه قيد التوصيل.
|
||||
Future<void> _callCourier(int orderId) async {
|
||||
// مسجَّل عالمياً بـ lazyPut(fenix) في app_bindings — والـ put احتياط
|
||||
VoiceCallController voiceCtrl;
|
||||
try {
|
||||
voiceCtrl = Get.find<VoiceCallController>();
|
||||
} catch (_) {
|
||||
voiceCtrl = Get.put(VoiceCallController());
|
||||
}
|
||||
|
||||
await voiceCtrl.startCall(
|
||||
rideIdVal: 'food_$orderId',
|
||||
driverId: '', // هوية السائق لا تصل التطبيق — الخادم يحلّها من الطلب
|
||||
passengerId: box.read(BoxName.passengerID).toString(),
|
||||
remoteNameVal: _isAr ? 'سائق التوصيل' : 'Delivery courier',
|
||||
sessionEndpoint: '${AppLink.server}/food/order/call_courier.php',
|
||||
sessionPayload: {'order_id': orderId.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusTimeline(String currentStatus) {
|
||||
final currentIndex = foodOrderStatusFlow.indexOf(currentStatus);
|
||||
return Column(
|
||||
|
||||
@@ -1297,10 +1297,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1313,10 +1313,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1901,10 +1901,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.9"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user