562 lines
22 KiB
Dart
Executable File
562 lines
22 KiB
Dart
Executable File
import 'package:siro_driver/constant/currency.dart';
|
||
import 'dart:convert';
|
||
import 'dart:developer';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_contacts/contact.dart';
|
||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
import 'package:siro_driver/constant/box_name.dart';
|
||
import 'package:siro_driver/constant/links.dart';
|
||
import 'package:siro_driver/controller/functions/crud.dart';
|
||
import 'package:siro_driver/controller/home/payment/captain_wallet_controller.dart';
|
||
import 'package:siro_driver/print.dart';
|
||
import 'package:siro_driver/controller/functions/country_logic.dart';
|
||
import 'package:siro_driver/main.dart';
|
||
import 'package:siro_driver/views/widgets/error_snakbar.dart';
|
||
import 'package:siro_driver/views/widgets/mydialoug.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
|
||
import '../../firebase/local_notification.dart';
|
||
import '../../functions/launch.dart';
|
||
import '../../notification/notification_captain_controller.dart';
|
||
|
||
class InviteController extends GetxController {
|
||
final TextEditingController invitePhoneController = TextEditingController();
|
||
List driverInvitationData = [];
|
||
List driverInvitationDataToPassengers = [];
|
||
String? couponCode;
|
||
String? driverCouponCode;
|
||
|
||
// **FIX**: Added the missing 'contacts' and 'contactMaps' definitions.
|
||
List<Contact> contacts = [];
|
||
RxList<Map<String, dynamic>> contactMaps = <Map<String, dynamic>>[].obs;
|
||
|
||
int selectedTab = 0;
|
||
PassengerStats passengerStats = PassengerStats();
|
||
void updateSelectedTab(int index) {
|
||
selectedTab = index;
|
||
update();
|
||
}
|
||
|
||
Future<void> shareDriverCode() async {
|
||
if (driverCouponCode != null) {
|
||
final String shareText =
|
||
'''Join Siro as a driver using my referral code!
|
||
Use code: $driverCouponCode
|
||
Download the Siro Captain app now and earn rewards:
|
||
${AppLink.driverDownloadLinks}
|
||
''';
|
||
await Share.share(shareText);
|
||
}
|
||
}
|
||
|
||
Future<void> sharePassengerCode() async {
|
||
if (couponCode != null) {
|
||
final String shareText = '''Get a discount on your first Siro ride!
|
||
Use my referral code: $couponCode
|
||
Download the Siro app now and enjoy your ride:
|
||
https://siromove.com/invite.php?code=$couponCode&app=rider
|
||
''';
|
||
await Share.share(shareText);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void onInit() {
|
||
super.onInit();
|
||
// لا نقرأ دفتر الهاتف هنا. القراءة تحدث فقط حين يطلب الكابتن
|
||
// اختيار جهة اتصال — انظر loadDeviceContacts أدناه.
|
||
// fetchDriverStats();
|
||
}
|
||
|
||
// --- قراءة جهات الاتصال محلياً، بطلب المستخدم وحده ---
|
||
|
||
/// كان هنا `syncContactsToServerOnce`: يقرأ دفتر هاتف الكابتن كاملاً
|
||
/// تلقائياً عند فتح شاشة الدعوة، ويرفع **كل** اسم ورقم إلى
|
||
/// `ride/egyptPhones/syrianAdd.php` — طلب لكل جهة اتصال.
|
||
///
|
||
/// أُزيل الرفع بالكامل، ولثلاثة أسباب لا واحد:
|
||
///
|
||
/// ١) سياسة Google Play: رفع دفتر العناوين جملةً يتطلب إفصاحاً بارزاً
|
||
/// وموافقة صريحة، وإذن النظام وحده لا يكفي. والتطبيق موقوف أصلاً.
|
||
///
|
||
/// ٢) البيانات ليست بيانات الكابتن ليتبرّع بها: هي أسماء وأرقام أطراف
|
||
/// ثالثة لم تُستأذن ولا تعلم بوجود سيرو.
|
||
///
|
||
/// ٣) لا ميزة تعتمد عليها. الدعوة تعمل بالمشاركة أو باختيار جهة واحدة
|
||
/// عمداً، وكلاهما لا يحتاج نسخة من الدفتر على السيرفر.
|
||
///
|
||
/// ما بقي هنا قراءة محلية بحتة لعرض قائمة يختار منها الكابتن — لا
|
||
/// شيء يغادر الجهاز.
|
||
Future<void> loadDeviceContacts() async {
|
||
if (contactMaps.isNotEmpty) return; // مُحمّلة سلفاً في هذه الجلسة
|
||
|
||
try {
|
||
if (await FlutterContacts.requestPermission(readonly: true)) {
|
||
final List<Contact> allContacts =
|
||
await FlutterContacts.getContacts(withProperties: true);
|
||
contacts = allContacts;
|
||
contactMaps.value = allContacts
|
||
.where((c) => c.phones.isNotEmpty)
|
||
.map((contact) => {
|
||
'name': contact.displayName,
|
||
'phones': contact.phones
|
||
.map((phone) => phone.normalizedNumber)
|
||
.toList(),
|
||
'emails':
|
||
contact.emails.map((email) => email.address).toList(),
|
||
})
|
||
.toList();
|
||
update();
|
||
}
|
||
} catch (e) {
|
||
// قراءة دفتر الهاتف ليست حرجة — الدعوة تعمل بالمشاركة أيضاً.
|
||
}
|
||
}
|
||
|
||
// --- NEW LOGIC: NATIVE CONTACT PICKER ---
|
||
|
||
/// **MODIFIED**: This function now opens the phone's native contact picker.
|
||
Future<void> pickContactFromNativeApp() async {
|
||
try {
|
||
log('=== START: FETCHING ALL CONTACTS FOR BOTTOM SHEET ===',
|
||
name: 'ContactPicker');
|
||
|
||
if (await FlutterContacts.requestPermission(readonly: true)) {
|
||
// عرض شاشة تحميل بسيطة ريثما يتم جلب الأسماء
|
||
Get.dialog(const Center(child: CircularProgressIndicator()),
|
||
barrierDismissible: false);
|
||
|
||
log('Permission granted. Calling FlutterContacts.getContacts()...',
|
||
name: 'ContactPicker');
|
||
|
||
// جلب جميع جهات الاتصال إجبارياً من الصفر مع خصائصها
|
||
List<Contact> allContacts =
|
||
await FlutterContacts.getContacts(withProperties: true);
|
||
|
||
log('Total Contacts Fetched from Device: ${allContacts.length}',
|
||
name: 'ContactPicker');
|
||
|
||
// فصل الأسماء لمعرفة الخلل
|
||
List<Contact> validContacts = [];
|
||
List<Contact> invalidContacts = [];
|
||
|
||
for (var c in allContacts) {
|
||
if (c.phones.isNotEmpty) {
|
||
validContacts.add(c);
|
||
} else {
|
||
invalidContacts.add(c);
|
||
}
|
||
}
|
||
|
||
log('Contacts WITH phone numbers: ${validContacts.length}',
|
||
name: 'ContactPicker');
|
||
log('Contacts WITHOUT phone numbers: ${invalidContacts.length}',
|
||
name: 'ContactPicker');
|
||
|
||
// طباعة أول 20 اسم صالح
|
||
log('--- Sample of VALID Contacts ---', name: 'ContactPicker');
|
||
for (int i = 0; i < validContacts.length && i < 20; i++) {
|
||
log('[$i] Name: ${validContacts[i].displayName}, Phone: ${validContacts[i].phones.first.number}',
|
||
name: 'ContactPicker');
|
||
}
|
||
|
||
// طباعة أول 20 اسم غير صالح (بدون أرقام) لفحص المشكلة
|
||
log('--- Sample of INVALID Contacts (No Phone) ---',
|
||
name: 'ContactPicker');
|
||
for (int i = 0; i < invalidContacts.length && i < 20; i++) {
|
||
log('[$i] Name: ${invalidContacts[i].displayName}',
|
||
name: 'ContactPicker');
|
||
}
|
||
|
||
Get.back(); // إغلاق شاشة التحميل
|
||
|
||
if (validContacts.isEmpty) {
|
||
mySnackbarError('No contacts with phone numbers found'.tr);
|
||
return;
|
||
}
|
||
|
||
// متغيرات للبحث داخل القائمة المنسدلة
|
||
RxList<Contact> filteredContacts = validContacts.obs;
|
||
TextEditingController searchController = TextEditingController();
|
||
|
||
// دالة لتنظيف النصوص من أي رموز معطوبة
|
||
String sanitizeText(String input) {
|
||
if (input.isEmpty) return '';
|
||
return input
|
||
.replaceAll(
|
||
RegExp(r'[^\x00-\x7F\u0600-\u06FF\u08A0-\u08FF\p{L}\p{N}\s]'),
|
||
'')
|
||
.trim();
|
||
}
|
||
|
||
// فتح دليل هاتف مخصص داخل التطبيق
|
||
Get.bottomSheet(
|
||
Container(
|
||
height: Get.height * 0.85,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(Get.context!).scaffoldBackgroundColor,
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
margin: const EdgeInsets.only(top: 10, bottom: 10),
|
||
width: 50,
|
||
height: 5,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(10)),
|
||
),
|
||
Text("Select a Contact".tr,
|
||
style: const TextStyle(
|
||
fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Padding(
|
||
padding: const EdgeInsets.all(12.0),
|
||
child: TextField(
|
||
controller: searchController,
|
||
decoration: InputDecoration(
|
||
hintText: "Search name or number...".tr,
|
||
prefixIcon: const Icon(Icons.search),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(10)),
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||
),
|
||
onChanged: (value) {
|
||
filteredContacts.value = validContacts.where((c) {
|
||
final nameMatch = c.displayName
|
||
.toLowerCase()
|
||
.contains(value.toLowerCase());
|
||
final phoneMatch =
|
||
c.phones.first.number.contains(value);
|
||
return nameMatch || phoneMatch;
|
||
}).toList();
|
||
},
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Obx(() => ListView.builder(
|
||
itemCount: filteredContacts.length,
|
||
itemBuilder: (context, index) {
|
||
Contact c = filteredContacts[index];
|
||
var firstPhone = c.phones.first;
|
||
|
||
String selectedPhone =
|
||
firstPhone.normalizedNumber.isNotEmpty
|
||
? firstPhone.normalizedNumber
|
||
: firstPhone.number;
|
||
|
||
String safeName = sanitizeText(c.displayName);
|
||
if (safeName.isEmpty) safeName = 'Unknown'.tr;
|
||
|
||
String safePhone = sanitizeText(selectedPhone);
|
||
String initial = safeName.isNotEmpty
|
||
? safeName[0].toUpperCase()
|
||
: '?';
|
||
|
||
return ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor:
|
||
Colors.blueAccent.withOpacity(0.1),
|
||
child: Text(initial,
|
||
style: const TextStyle(
|
||
color: Colors.blueAccent)),
|
||
),
|
||
title: Text(safeName),
|
||
subtitle: Text(safePhone,
|
||
textDirection: TextDirection.ltr),
|
||
onTap: () {
|
||
selectPhone(selectedPhone);
|
||
},
|
||
);
|
||
},
|
||
)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
isScrollControlled: true,
|
||
);
|
||
} else {
|
||
log('Permission DENIED', name: 'ContactPicker');
|
||
mySnackbarError('Contact permission is required to pick contacts'.tr);
|
||
}
|
||
} catch (e) {
|
||
if (Get.isDialogOpen ?? false) Get.back();
|
||
log('CRITICAL ERROR: $e', name: 'ContactPicker');
|
||
mySnackbarError('An error occurred while loading contacts: $e'.tr);
|
||
}
|
||
log('=== END: FETCHING CONTACTS ===', name: 'ContactPicker');
|
||
}
|
||
|
||
/// **FIX**: Added the missing 'selectPhone' method.
|
||
void selectPhone(String phone) {
|
||
// Format the selected phone number and update the text field.
|
||
invitePhoneController.text = CountryLogic.formatCurrentCountryPhone(phone);
|
||
update();
|
||
Get.back(); // Close the contacts dialog after selection.
|
||
}
|
||
|
||
void fetchDriverStats() async {
|
||
try {
|
||
var response = await CRUD().get(link: AppLink.getInviteDriver, payload: {
|
||
"driverId": box.read(BoxName.driverID),
|
||
});
|
||
if (response != 'failure') {
|
||
var data = jsonDecode(response);
|
||
driverInvitationData = data['message'];
|
||
update();
|
||
}
|
||
} catch (e) {
|
||
// Handle error gracefully
|
||
}
|
||
}
|
||
|
||
void fetchDriverStatsPassengers() async {
|
||
try {
|
||
var response = await CRUD()
|
||
.get(link: AppLink.getDriverInvitationToPassengers, payload: {
|
||
"driverId": box.read(BoxName.driverID),
|
||
});
|
||
if (response != 'failure') {
|
||
var data = jsonDecode(response);
|
||
driverInvitationDataToPassengers = data['message'];
|
||
update();
|
||
}
|
||
} catch (e) {
|
||
// Handle error gracefully
|
||
}
|
||
}
|
||
|
||
void onSelectDriverInvitation(int index) async {
|
||
MyDialog().getDialog(
|
||
int.parse((driverInvitationData[index]['countOfInvitDriver'])) < 100
|
||
? '${'When'.tr} ${(driverInvitationData[index]['invitorName'])} ${"complete, you can claim your gift".tr} '
|
||
: 'You deserve the gift'.tr,
|
||
'${(driverInvitationData[index]['invitorName'])} ${(driverInvitationData[index]['countOfInvitDriver'])} / 100 ${'Trip'.tr}',
|
||
() async {
|
||
bool isAvailable = await LocalAuthentication().isDeviceSupported();
|
||
if (int.parse((driverInvitationData[index]['countOfInvitDriver'])) <
|
||
100) {
|
||
Get.back();
|
||
} else if (isAvailable) {
|
||
bool didAuthenticate = await LocalAuthentication().authenticate(
|
||
localizedReason: 'Use Touch ID or Face ID to confirm payment',
|
||
// options: const AuthenticationOptions(
|
||
biometricOnly: true,
|
||
sensitiveTransaction: true,
|
||
);
|
||
if (didAuthenticate) {
|
||
if ((driverInvitationData[index]['isGiftToken']).toString() ==
|
||
'0') {
|
||
Get.back();
|
||
// Server-side claim validation
|
||
var response = await CRUD().post(
|
||
link: AppLink.claimInviteReward,
|
||
payload: {
|
||
'invite_id': (driverInvitationData[index]['id']).toString(),
|
||
'driver_id': box.read(BoxName.driverID).toString(),
|
||
'country_code': box.read(BoxName.countryCode).toString(),
|
||
}
|
||
);
|
||
|
||
if (response != 'failure') {
|
||
var data = jsonDecode(response);
|
||
if (data['status'] == 'success') {
|
||
NotificationController().showNotification(
|
||
"You have got a gift for invitation".tr,
|
||
'${"You have 500".tr} ${CurrencyHelper.currency}',
|
||
'tone1',
|
||
'');
|
||
// refresh stats
|
||
fetchDriverStats();
|
||
} else {
|
||
mySnackbarError(data['message'] ?? 'Claim failed'.tr);
|
||
}
|
||
}
|
||
} else {
|
||
Get.back();
|
||
MyDialog().getDialog("You have got a gift".tr,
|
||
"Share the app with another new driver".tr, () => Get.back());
|
||
}
|
||
} else {
|
||
MyDialog()
|
||
.getDialog('Authentication failed'.tr, '', () => Get.back());
|
||
}
|
||
} else {
|
||
MyDialog().getDialog(
|
||
'Biometric Authentication'.tr,
|
||
'You should use Touch ID or Face ID to confirm payment'.tr,
|
||
() => Get.back());
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
void onSelectPassengerInvitation(int index) async {
|
||
bool isAvailable = await LocalAuthentication().isDeviceSupported();
|
||
MyDialog().getDialog(
|
||
int.parse(driverInvitationDataToPassengers[index]['countOfInvitDriver']
|
||
.toString()) <
|
||
3
|
||
? '${'When'.tr} ${(driverInvitationDataToPassengers[index]['passengerName'].toString())} ${"complete, you can claim your gift".tr} '
|
||
: 'You deserve the gift'.tr,
|
||
'${(driverInvitationDataToPassengers[index]['passengerName'].toString())} ${driverInvitationDataToPassengers[index]['countOfInvitDriver']} / 3 ${'Trip'.tr}',
|
||
() async {
|
||
if (int.parse(driverInvitationDataToPassengers[index]
|
||
['countOfInvitDriver']
|
||
.toString()) <
|
||
3) {
|
||
Get.back();
|
||
} else if (isAvailable) {
|
||
bool didAuthenticate = await LocalAuthentication().authenticate(
|
||
localizedReason: 'Use Touch ID or Face ID to confirm payment',
|
||
// options: const AuthenticationOptions(
|
||
biometricOnly: true,
|
||
sensitiveTransaction: true,
|
||
);
|
||
if (didAuthenticate) {
|
||
if (driverInvitationDataToPassengers[index]['isGiftToken']
|
||
.toString() ==
|
||
'0') {
|
||
Get.back();
|
||
await Get.find<CaptainWalletController>()
|
||
.addDriverWallet('paymentMethod', '200', '200');
|
||
await Get.find<CaptainWalletController>()
|
||
.addDriverWalletToInvitor('paymentMethod',
|
||
driverInvitationData[index]['driverInviterId'], '200');
|
||
await CRUD().post(
|
||
link: AppLink.updatePassengerGift,
|
||
payload: {'id': driverInvitationDataToPassengers[index]['id']},
|
||
);
|
||
NotificationCaptainController().addNotificationCaptain(
|
||
driverInvitationDataToPassengers[index]['passengerInviterId']
|
||
.toString(),
|
||
"You have got a gift for invitation".tr,
|
||
'${"You have 200".tr} ${CurrencyHelper.currency}',
|
||
false,
|
||
);
|
||
} else {
|
||
Get.back();
|
||
MyDialog().getDialog(
|
||
"You have got a gift".tr,
|
||
"Share the app with another new passenger".tr,
|
||
() => Get.back(),
|
||
);
|
||
}
|
||
} else {
|
||
MyDialog()
|
||
.getDialog('Authentication failed'.tr, '', () => Get.back());
|
||
}
|
||
} else {
|
||
MyDialog().getDialog(
|
||
'Biometric Authentication'.tr,
|
||
'You should use Touch ID or Face ID to confirm payment'.tr,
|
||
() => Get.back());
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
// removed normalizeSyrianPhone
|
||
|
||
/// Sends an invitation to a potential new driver.
|
||
void sendInvite() async {
|
||
if (invitePhoneController.text.isEmpty) {
|
||
mySnackbarError('Please enter a phone number'.tr);
|
||
return;
|
||
}
|
||
String formattedPhoneNumber =
|
||
CountryLogic.formatCurrentCountryPhone(invitePhoneController.text);
|
||
if (formattedPhoneNumber.length != 12) {
|
||
mySnackbarError('Please enter a correct phone'.tr);
|
||
return;
|
||
}
|
||
|
||
var response = await CRUD().post(link: AppLink.addInviteDriver, payload: {
|
||
"driverId": box.read(BoxName.driverID),
|
||
"inviterDriverPhone": formattedPhoneNumber,
|
||
});
|
||
|
||
if (response != 'failure' && response is Map) {
|
||
var d = response;
|
||
mySnackbarSuccess('Invite sent successfully'.tr);
|
||
String inviteCode = d['message']['inviteCode'].toString();
|
||
String message = '${'*Siro DRIVER CODE*'.tr}\n\n'
|
||
'${"Use this code in registration".tr}\n'
|
||
'${"To get a gift for both".tr}\n\n'
|
||
'${"The period of this code is 24 hours".tr}\n\n'
|
||
'${'before'.tr} *${d['message']['expirationTime'].toString()}*\n\n'
|
||
'${"Code:".tr} *${inviteCode}*\n\n'
|
||
'${"Quick Invite Link:".tr}\n'
|
||
'${AppLink.inviteRedirectUrl}?code=$inviteCode&app=driver\n\n'
|
||
'${"💡 Note: If the link above is not clickable, please save this contact or reply to this message to activate all links.".tr}';
|
||
|
||
launchCommunication('whatsapp', formattedPhoneNumber, message);
|
||
invitePhoneController.clear();
|
||
} else {
|
||
mySnackbarError("Invite code already used".tr);
|
||
}
|
||
}
|
||
|
||
/// Sends an invitation to a potential new passenger.
|
||
void sendInviteToPassenger() async {
|
||
if (invitePhoneController.text.isEmpty) {
|
||
mySnackbarError('Please enter a phone number'.tr);
|
||
return;
|
||
}
|
||
|
||
String formattedPhoneNumber = CountryLogic.formatCurrentCountryPhone(invitePhoneController.text);
|
||
|
||
if (formattedPhoneNumber.length < 12) {
|
||
// +963 + 9 digits = 12+
|
||
mySnackbarError('Please enter a correct phone'.tr);
|
||
return;
|
||
}
|
||
|
||
var response = await CRUD().post(
|
||
link: AppLink.addInvitationPassenger,
|
||
payload: {
|
||
"driverId": box.read(BoxName.driverID),
|
||
"inviterPassengerPhone": formattedPhoneNumber,
|
||
},
|
||
);
|
||
|
||
if (response != 'failure' && response is Map) {
|
||
var d = response;
|
||
mySnackbarSuccess('Invite sent successfully'.tr);
|
||
String inviteCode = d['message']['inviteCode'].toString();
|
||
|
||
String message = '${'*Siro APP CODE*'.tr}\n\n'
|
||
'${"Use this code in registration".tr}\n\n'
|
||
'${"To get a gift for both".tr}\n\n'
|
||
'${"The period of this code is 24 hours".tr}\n\n'
|
||
'${'before'.tr} *${d['message']['expirationTime'].toString()}*\n\n'
|
||
'${"Code:".tr} *${inviteCode}*\n\n'
|
||
'${"Quick Invite Link:".tr}\n'
|
||
'${AppLink.inviteRedirectUrl}?code=$inviteCode&app=rider\n\n'
|
||
'${"💡 Note: If the link above is not clickable, please save this contact or reply to this message to activate all links.".tr}';
|
||
|
||
launchCommunication('whatsapp', formattedPhoneNumber, message);
|
||
invitePhoneController.clear();
|
||
} else {
|
||
mySnackbarError("Invite code already used".tr);
|
||
}
|
||
}
|
||
}
|
||
|
||
class PassengerStats {
|
||
final int totalInvites;
|
||
final int activeUsers;
|
||
final double totalEarnings;
|
||
|
||
PassengerStats({
|
||
this.totalInvites = 0,
|
||
this.activeUsers = 0,
|
||
this.totalEarnings = 0.0,
|
||
});
|
||
}
|