Update: 2026-07-02 05:27:04
This commit is contained in:
@@ -392,6 +392,7 @@ class AppLink {
|
||||
|
||||
// Endpoint لجلب التسعيرة من السيرفر (Server-Side Pricing)
|
||||
static String get getPrices => "$server/ride/pricing/get.php";
|
||||
static String get getCompetitorContext => "$server/api/ride/get_competitor_context.php";
|
||||
|
||||
static String get addRateToDriver => "$server/ride/rate/addRateToDriver.php";
|
||||
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
|
||||
|
||||
@@ -199,7 +199,8 @@ class LoginController extends GetxController {
|
||||
final Map<String, dynamic> payload = jsonDecode(decodedPayload);
|
||||
|
||||
if (payload['token_type'] == 'registration') {
|
||||
Log.print("isTokenValid: Token is a registration token, treating as invalid for session.");
|
||||
Log.print(
|
||||
"isTokenValid: Token is a registration token, treating as invalid for session.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -288,13 +289,14 @@ class LoginController extends GetxController {
|
||||
);
|
||||
|
||||
if (res == 'token_expired' || res == 'failure' || res == 'error') {
|
||||
Log.print('loginUsingCredentials: $res. Redirecting to PhoneNumberScreen to re-verify.');
|
||||
Log.print(
|
||||
'loginUsingCredentials: $res. Redirecting to PhoneNumberScreen to re-verify.');
|
||||
box.erase();
|
||||
storage.deleteAll();
|
||||
Get.offAll(() => PhoneNumberScreen());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 2) فك JSON مرة واحدة والتحقق من النجاح
|
||||
final decoded = res is String ? jsonDecode(res) : res;
|
||||
if (decoded is! Map || decoded.isEmpty) return;
|
||||
@@ -355,7 +357,8 @@ class LoginController extends GetxController {
|
||||
final localFP = (await DeviceHelper.getDeviceFingerprint()).toString();
|
||||
await storage.write(key: BoxName.fingerPrint, value: localFP);
|
||||
await box.write(BoxName.firstTimeLoadKey, 'false');
|
||||
await getJWT(force: true); // Fetch access token after clearing firstTimeLoadKey
|
||||
await getJWT(
|
||||
force: true); // Fetch access token after clearing firstTimeLoadKey
|
||||
|
||||
// ── 5. المقارنة: FCM token + fingerprint ──────────────────────
|
||||
if (email != '962798583052@intaleqapp.com') {
|
||||
@@ -364,7 +367,8 @@ class LoginController extends GetxController {
|
||||
tokenResp != 'failure' &&
|
||||
tokenResp != 'error' &&
|
||||
tokenResp != 'token_expired') {
|
||||
final tokenJson = tokenResp is String ? jsonDecode(tokenResp) : tokenResp;
|
||||
final tokenJson =
|
||||
tokenResp is String ? jsonDecode(tokenResp) : tokenResp;
|
||||
final serverData = tokenJson['data'] ?? tokenJson['message'];
|
||||
if (serverData is Map) {
|
||||
serverFCM = serverData['token']?.toString() ?? '';
|
||||
@@ -484,6 +488,7 @@ class LoginController extends GetxController {
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
var jsonDecoeded = jsonDecode(response.body);
|
||||
Log.print("Tester Login Response: $jsonDecoeded");
|
||||
if (jsonDecoeded['status'] == 'success' &&
|
||||
jsonDecoeded['data'][0]['verified'].toString() == '1') {
|
||||
var d = jsonDecoeded['data'][0];
|
||||
@@ -507,11 +512,15 @@ class LoginController extends GetxController {
|
||||
sendPassengerLocation();
|
||||
Get.offAll(() => const MapPagePassenger());
|
||||
} else {
|
||||
Log.print(
|
||||
"Tester Login Failed due to condition mismatch: status=${jsonDecoeded['status']}, verified=${jsonDecoeded['data']?[0]?['verified']}");
|
||||
Get.offAll(() => LoginPage());
|
||||
isloading = false;
|
||||
update();
|
||||
}
|
||||
} else {
|
||||
Log.print(
|
||||
"Tester Login Failed with status code: ${response.statusCode}, body: ${response.body}");
|
||||
isloading = false;
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -1947,6 +1947,15 @@ class RideLifecycleController extends GetxController {
|
||||
|
||||
double costForDriver = 0;
|
||||
|
||||
// ─── بيانات مقارنة أسعار المنافسين ───
|
||||
bool hasCompetitorData = false;
|
||||
double? competitorAvgPrice;
|
||||
double? savingsPercent;
|
||||
String? savingsLabel;
|
||||
String? topCompetitor;
|
||||
String? driverExtraLabel;
|
||||
double? driverExtraAmount;
|
||||
|
||||
Future bottomSheet() async {
|
||||
durationToAdd = Duration(seconds: durationToRide);
|
||||
hours = durationToAdd.inHours;
|
||||
@@ -2019,6 +2028,46 @@ class RideLifecycleController extends GetxController {
|
||||
|
||||
update();
|
||||
mapEngine.changeBottomSheetShown(forceValue: true);
|
||||
|
||||
// جلب مقارنة أسعار المنافسين في الخلفية بعد عرض الأسعار
|
||||
fetchCompetitorContext();
|
||||
}
|
||||
|
||||
/// يجلب مقارنة سعر سيرو مع المنافسين ويحدّث الواجهة بشكل غير مُوقِف
|
||||
Future<void> fetchCompetitorContext() async {
|
||||
try {
|
||||
final String passengerLat = mapEngine.polylineCoordinates.isNotEmpty
|
||||
? mapEngine.polylineCoordinates.first.latitude.toString()
|
||||
: newMyLocation.latitude.toString();
|
||||
final String passengerLng = mapEngine.polylineCoordinates.isNotEmpty
|
||||
? mapEngine.polylineCoordinates.first.longitude.toString()
|
||||
: newMyLocation.longitude.toString();
|
||||
|
||||
final res = await CRUD().post(
|
||||
link: AppLink.getCompetitorContext,
|
||||
payload: {
|
||||
'passenger_lat': passengerLat,
|
||||
'passenger_lng': passengerLng,
|
||||
'distance': distance.toString(),
|
||||
'country': box.read(BoxName.countryCode) ?? '',
|
||||
'siro_price': totalPassengerSpeed,
|
||||
'car_type': 'Speed',
|
||||
},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
hasCompetitorData = res['has_competitor_data'] == true;
|
||||
competitorAvgPrice = double.tryParse(res['competitor_avg_price']?.toString() ?? '');
|
||||
savingsPercent = double.tryParse(res['savings_percent']?.toString() ?? '');
|
||||
savingsLabel = res['savings_label']?.toString();
|
||||
topCompetitor = res['top_competitor']?.toString();
|
||||
driverExtraLabel = res['driver_extra_label']?.toString();
|
||||
driverExtraAmount = double.tryParse(res['driver_extra_amount']?.toString() ?? '');
|
||||
update();
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print('[CompetitorContext] fetch failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// حساب المسار بين السائق والراكب وعرضه على الخريطة.
|
||||
@@ -2426,7 +2475,12 @@ class RideLifecycleController extends GetxController {
|
||||
double tripDurationInMinutes = durationToPassenger / 5;
|
||||
int loopCount = tripDurationInMinutes.ceil();
|
||||
for (var i = 0; i < loopCount; i++) {
|
||||
// [Fix Loop-1] فحص إغلاق الكنترولر قبل وبعد التأخير لمنع crash
|
||||
if (isClosed ||
|
||||
currentRideState.value == RideState.finished ||
|
||||
currentRideState.value == RideState.preCheckReview) return;
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (isClosed) return;
|
||||
if (rideTimerBegin == true || statusRide == 'Apply') {
|
||||
await getDriverCarsLocationToPassengerAfterApplied();
|
||||
}
|
||||
@@ -2438,7 +2492,12 @@ class RideLifecycleController extends GetxController {
|
||||
int loopCount = tripDurationInMinutes.ceil();
|
||||
mapEngine.clearMarkersExceptStartEndAndDriver();
|
||||
for (var i = 0; i < loopCount; i++) {
|
||||
// [Fix Loop-2] فحص إغلاق الكنترولر لمنع استدعاء الدوال على كنترولر مغلق
|
||||
if (isClosed ||
|
||||
currentRideState.value == RideState.finished ||
|
||||
currentRideState.value == RideState.preCheckReview) return;
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
if (isClosed) return;
|
||||
await getDriverCarsLocationToPassengerAfterApplied();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:get/get.dart';
|
||||
import '../../constant/colors.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../controller/home/map_passenger_controller.dart';
|
||||
import '../../controller/rate/rate_conroller.dart';
|
||||
import '../widgets/elevated_btn.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../../constant/info.dart';
|
||||
import '../../../controller/functions/tts.dart';
|
||||
import '../../../controller/home/map/ride_lifecycle_controller.dart';
|
||||
import '../../widgets/mydialoug.dart';
|
||||
import '../../widgets/competitor_price_badge.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CAR TYPE MODEL
|
||||
@@ -156,6 +157,17 @@ class CarDetailsTypeToChoose extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// ── Competitor Price Savings Badge ─────────────────────
|
||||
if (controller.savingsLabel != null &&
|
||||
controller.savingsLabel!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
child: CompetitorPriceBadge(
|
||||
badgeText: controller.savingsLabel,
|
||||
savingsPercent: controller.savingsPercent ?? 0.0,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Promo Code & Actions ─────────────────────────────
|
||||
_buildPromoButton(context, controller),
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// CompetitorPriceBadge
|
||||
/// ─────────────────────
|
||||
/// ويدجت يعرض مقارنة ذكية بين سعر سيرو وسعر المنافسين.
|
||||
/// يُستخدم في شاشة عرض الأسعار (bottomSheet) ويظهر فقط إذا توفرت
|
||||
/// بيانات المنافسين من API (has_competitor_data = true).
|
||||
class CompetitorPriceBadge extends StatelessWidget {
|
||||
/// نسبة التوفير للراكب (مثال: 8.5 تعني "أوفر بـ 8.5%")
|
||||
final double? savingsPercent;
|
||||
|
||||
/// النص التوضيحي الجاهز من السيرفر (مثال: "أوفر بـ 8% من التطبيقات الأخرى ⚡")
|
||||
final String? savingsLabel;
|
||||
|
||||
/// اسم أكبر منافس للمقارنة (مثال: "Careem")
|
||||
final String? topCompetitor;
|
||||
|
||||
const CompetitorPriceBadge({
|
||||
super.key,
|
||||
this.savingsPercent,
|
||||
this.savingsLabel,
|
||||
this.topCompetitor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// لا تُظهر الويدجت إذا لم تتوفر بيانات
|
||||
if (savingsLabel == null || savingsLabel!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final double pct = savingsPercent ?? 0.0;
|
||||
final bool isSignificant = pct >= 5.0;
|
||||
final bool isModerate = pct >= 2.0 && pct < 5.0;
|
||||
|
||||
// اختيار ألوان حسب قوة التوفير
|
||||
final Color bgColor = isSignificant
|
||||
? const Color(0xFF1A3A2A)
|
||||
: isModerate
|
||||
? const Color(0xFF1A2A3A)
|
||||
: const Color(0xFF1F1F2E);
|
||||
final Color borderColor = isSignificant
|
||||
? const Color(0xFF34D399)
|
||||
: isModerate
|
||||
? const Color(0xFF60A5FA)
|
||||
: const Color(0xFF818CF8);
|
||||
final Color textColor = isSignificant
|
||||
? const Color(0xFF34D399)
|
||||
: isModerate
|
||||
? const Color(0xFF60A5FA)
|
||||
: const Color(0xFFA5B4FC);
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: 1.0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor.withOpacity(0.5), width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: borderColor.withOpacity(0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// أيقونة التوفير
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: borderColor.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isSignificant ? Icons.trending_down_rounded : Icons.price_check_rounded,
|
||||
color: textColor,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// نص المقارنة
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
savingsLabel!,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
if (topCompetitor != null && topCompetitor!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'مقارنةً بـ ${_formatCompetitorName(topCompetitor!)} والتطبيقات الأخرى',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// شارة النسبة
|
||||
if (pct >= 2.0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: borderColor.withOpacity(0.18),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'-${pct.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCompetitorName(String raw) {
|
||||
final names = {
|
||||
'careem': 'كريم',
|
||||
'uber': 'أوبر',
|
||||
'yallago': 'يلاكو',
|
||||
'zaken': 'زاكن',
|
||||
'tufaddal': 'تفضل',
|
||||
'jeeny': 'جيني',
|
||||
'taxif': 'تاكسيف',
|
||||
};
|
||||
return names[raw.toLowerCase()] ?? raw;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user