feat: Gemini AI (doc OCR + face match) + role-assignment endpoints

- GeminiService (integrations/gemini, @Global): extractDocument (OCR fields) + faceMatch; graceful if no GEMINI_API_KEY
- documents: face-match now real via Gemini; POST /admin/documents/:id/ocr extracts fields for CS auto-fill
- roles: PATCH /admin/users/:id/role (admin) + POST /platform/users/role (bootstrap via x-platform-secret)
- config: gemini + platform.secret; .env.example entries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:01:26 +03:00
co-authored by Claude Opus 4.8
parent 04200db438
commit 6234e66471
1541 changed files with 267713 additions and 16 deletions
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../constant/box_name.dart';
import '../../../constant/colors.dart';
import '../../../constant/style.dart';
import '../../../main.dart';
import '../../widgets/elevated_btn.dart';
import '../../widgets/my_scafold.dart';
import '../../widgets/mydialoug.dart';
import 'ai_eg_controller.dart';
class CriminalDocumemtPage extends StatelessWidget {
const CriminalDocumemtPage({super.key});
@override
Widget build(BuildContext context) {
Get.put(AI()); // التأكد من وجود الكنترولر
return MyScafolld(
title: "صحيفة الحالة الجنائية".tr, // تعريب العنوان
isleading: true,
body: [
GetBuilder<AI>(builder: (controller) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// رسالة توضيحية للسائق
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.blue),
const SizedBox(width: 10),
Expanded(
child: Text(
"هذه الوثيقة تساعد في توثيق حسابك وزيادة فرص قبول الرحلات."
.tr,
style: const TextStyle(fontSize: 12),
),
),
],
),
),
const SizedBox(height: 20),
// بطاقة رفع الفيش
_buildCriminalRecordCard(controller),
const SizedBox(height: 40),
// زر الحفظ (يظهر فقط إذا تم الرفع والتحقق)
if (controller.responseCriminalRecordEgypt.isNotEmpty)
MyElevatedButton(
title: 'تأكيد وحفظ'.tr,
// width: double.infinity,
onPressed: () => _handleSave(controller),
),
],
),
);
})
],
);
}
// --- Widget بطاقة الرفع والعرض ---
Widget _buildCriminalRecordCard(AI ai) {
bool hasData = ai.responseCriminalRecordEgypt.isNotEmpty;
if (hasData) {
var data = ai.responseCriminalRecordEgypt;
// جلب اسم السائق المسجل حالياً في التطبيق (من الـ Box أو السيرفر)
// ملاحظة: تأكد أنك تحفظ الاسم العربي في BoxName.nameArabic عند تسجيل الدخول
String currentDriverName = box.read(BoxName.nameArabic) ?? '';
// مقارنة تقريبية (إزالة المسافات)
bool isNameMatch = _compareNames(data['FullName'], currentDriverName);
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("البيانات المستخرجة".tr, style: AppStyle.headTitle2),
IconButton(
icon: const Icon(Icons.edit, color: AppColor.primaryColor),
onPressed: () => ai.allMethodForAI('criminal_record'),
)
],
),
const Divider(),
_infoRow("الاسم في الفيش".tr, data['FullName'],
isError: !isNameMatch),
if (!isNameMatch)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"الاسم لا يطابق اسمك المسجل: $currentDriverName".tr,
style: const TextStyle(color: Colors.red, fontSize: 10),
),
),
_infoRow("الرقم القومي".tr, data['NationalID']),
_infoRow("النتيجة".tr, data['InspectionResult'],
isSuccess:
data['InspectionResult'].toString().contains("لا يوجد")),
_infoRow("تاريخ الإصدار".tr, data['IssueDate']),
],
),
),
);
}
// حالة الانتظار (زر الرفع)
return GestureDetector(
onTap: () => ai.allMethodForAI('criminal_record'),
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: Colors.grey[300]!, width: 2, style: BorderStyle.solid),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('assets/images/6.png',
height: 80, color: Colors.grey), // أيقونة بصمة أو مستند
const SizedBox(height: 15),
Text(
"اضغط لرفع صورة الفيش".tr,
style: AppStyle.title.copyWith(color: Colors.grey[600]),
),
],
),
),
);
}
Widget _infoRow(String label, String? value,
{bool isError = false, bool isSuccess = false}) {
Color textColor = Colors.black87;
if (isError) textColor = Colors.red;
if (isSuccess) textColor = Colors.green;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Expanded(
child: Text(
value ?? "---",
textAlign: TextAlign.end,
style: TextStyle(fontWeight: FontWeight.bold, color: textColor),
),
),
],
),
);
}
// --- منطق المقارنة والحفظ ---
bool _compareNames(String? extractedName, String storedName) {
if (extractedName == null) return false;
// تنظيف الأسماء (إزالة مسافات، توحيد الهمزات) للمقارنة الدقيقة
String clean1 = extractedName
.replaceAll(' ', '')
.replaceAll('أ', 'ا')
.replaceAll('إ', 'ا');
String clean2 = storedName
.replaceAll(' ', '')
.replaceAll('أ', 'ا')
.replaceAll('إ', 'ا');
// نكتفي بمطابقة جزئية (مثلاً 50% من الأحرف) أو مطابقة كاملة حسب صرامة النظام
return clean1.contains(clean2) || clean2.contains(clean1);
}
void _handleSave(AI controller) async {
String currentDriverName = box.read(BoxName.nameArabic) ?? '';
String extractedName =
controller.responseCriminalRecordEgypt['FullName'] ?? '';
if (!_compareNames(extractedName, currentDriverName)) {
Get.defaultDialog(
title: "خطأ في التطابق".tr,
middleText:
"اسمك في الفيش لا يطابق الاسم المسجل في حسابك.\nيرجى التأكد من الوثيقة."
.tr,
confirmTextColor: Colors.white,
onConfirm: () => Get.back(),
);
return;
}
// إذا كان التطابق صحيحاً، نقوم برفع البيانات للسيرفر
// هنا نفترض وجود دالة في الكنترولر لحفظ الفيش فقط
await controller.saveCriminalRecordOnly();
}
}