From bfb6368ec8b500dc8cd077fc29a2b80e52a60598 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 7 May 2026 03:06:15 +0300 Subject: [PATCH] Update: 2026-05-07 03:06:15 --- app/modules_app/payments/bot_webhook.php | 149 +++++++ app/modules_app/payments/create.php | 107 +++++ app/modules_app/payments/list.php | 65 +++ app/modules_app/payments/my_requests.php | 35 ++ app/modules_app/payments/review.php | 107 +++++ app/modules_app/payments/stats.php | 79 ++++ app/modules_app/payments/upload_receipt.php | 284 ++++++++++++++ app/modules_app/payments/verify_reference.php | 154 ++++++++ app/modules_app/voice/grok_intent.php | 101 +++++ musadaq-app/lib/app/routes/app_pages.dart | 35 +- musadaq-app/lib/app/routes/app_routes.dart | 7 + .../services/upload_progress_service.dart | 48 +++ .../lib/core/storage/secure_storage.dart | 9 + .../dashboard/views/dashboard_view.dart | 229 ++++++----- .../invoice_detail_controller.dart | 67 ++++ .../controllers/invoices_controller.dart | 55 +++ .../invoices/views/invoice_detail_view.dart | 205 ++++++++++ .../invoices/views/invoices_list_view.dart | 281 +++++++++++++ .../main_shell/views/main_shell_view.dart | 219 +++++++++++ .../views/notifications_view.dart | 86 ++++ .../controllers/scanner_controller.dart | 125 +++--- .../controllers/settings_controller.dart | 51 +++ .../settings/views/settings_view.dart | 303 ++++++++++++++ .../payment_receipt_controller.dart | 69 ++++ .../controllers/subscription_controller.dart | 82 ++++ .../views/payment_receipt_view.dart | 144 +++++++ .../subscription/views/subscription_view.dart | 370 ++++++++++++++++++ public/index.php | 14 +- 28 files changed, 3292 insertions(+), 188 deletions(-) create mode 100644 app/modules_app/payments/bot_webhook.php create mode 100644 app/modules_app/payments/create.php create mode 100644 app/modules_app/payments/list.php create mode 100644 app/modules_app/payments/my_requests.php create mode 100644 app/modules_app/payments/review.php create mode 100644 app/modules_app/payments/stats.php create mode 100644 app/modules_app/payments/upload_receipt.php create mode 100644 app/modules_app/payments/verify_reference.php create mode 100644 app/modules_app/voice/grok_intent.php create mode 100644 musadaq-app/lib/core/services/upload_progress_service.dart create mode 100644 musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart create mode 100644 musadaq-app/lib/features/invoices/controllers/invoices_controller.dart create mode 100644 musadaq-app/lib/features/invoices/views/invoice_detail_view.dart create mode 100644 musadaq-app/lib/features/invoices/views/invoices_list_view.dart create mode 100644 musadaq-app/lib/features/main_shell/views/main_shell_view.dart create mode 100644 musadaq-app/lib/features/notifications/views/notifications_view.dart create mode 100644 musadaq-app/lib/features/settings/controllers/settings_controller.dart create mode 100644 musadaq-app/lib/features/settings/views/settings_view.dart create mode 100644 musadaq-app/lib/features/subscription/controllers/payment_receipt_controller.dart create mode 100644 musadaq-app/lib/features/subscription/controllers/subscription_controller.dart create mode 100644 musadaq-app/lib/features/subscription/views/payment_receipt_view.dart create mode 100644 musadaq-app/lib/features/subscription/views/subscription_view.dart diff --git a/app/modules_app/payments/bot_webhook.php b/app/modules_app/payments/bot_webhook.php new file mode 100644 index 0000000..bfd7b5c --- /dev/null +++ b/app/modules_app/payments/bot_webhook.php @@ -0,0 +1,149 @@ + 'required' +]); + +if ($errors) { + json_error('رسالة البنك مطلوبة.', 422); +} + +$rawMessage = $data['raw_message']; +$bankReference = trim($data['bank_reference'] ?? ''); +$amount = (float)($data['amount'] ?? 0); +$senderName = $data['sender_name'] ?? 'غير معروف'; + +if (empty($bankReference) || $amount <= 0) { + json_error('بيانات التحويل غير مكتملة.', 422); +} + +$db = Database::getInstance(); + +try { + $db->beginTransaction(); + + // 1. Insert into bank_transactions + $stmt = $db->prepare(" + INSERT INTO bank_transactions (bank_reference, amount, sender_name, raw_message, is_claimed, created_at) + VALUES (?, ?, ?, ?, 0, NOW()) + ON DUPLICATE KEY UPDATE raw_message = VALUES(raw_message) + "); + $stmt->execute([$bankReference, $amount, $senderName, $rawMessage]); + $transactionId = $db->lastInsertId(); + + if (!$transactionId) { + $transactionId = $db->query("SELECT id FROM bank_transactions WHERE bank_reference = '$bankReference'")->fetchColumn(); + } + + // 2. Check if there is a pending payment request waiting for this reference + $stmt = $db->prepare("SELECT * FROM payment_requests WHERE bank_reference = ? AND status IN ('pending', 'uploaded')"); + $stmt->execute([$bankReference]); + $payment = $stmt->fetch(); + + $message = 'تم استلام وتخزين الحوالة البنكية.'; + + if ($payment) { + // Match found! Check amount + $expectedAmount = (float)$payment['amount_jod']; + + if (abs($expectedAmount - $amount) < 0.01) { + // Amount matches exactly -> Auto Approve + activateSubscription($db, $payment, $payment['user_id']); + + $stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?"); + $stmt->execute([$payment['id']]); + + $stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?"); + $stmt->execute([$transactionId]); + + $message = 'تم استلام الحوالة ومطابقتها وتفعيل الاشتراك بنجاح.'; + } else { + // Amount mismatch -> Needs manual review + $stmt = $db->prepare("UPDATE payment_requests SET admin_notes = 'تم وصول الحوالة ولكن المبلغ غير متطابق' WHERE id = ?"); + $stmt->execute([$payment['id']]); + $message = 'تم استلام الحوالة، لكن المبلغ لم يتطابق مع الطلب.'; + } + } + + $db->commit(); + json_success(['status' => 'received'], $message); + +} catch (\Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + error_log("Bot Webhook Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء معالجة رسالة البوت.', 500); +} + +/** + * Auto-activate subscription upon verified payment + */ +function activateSubscription(\PDO $db, array $payment, string $userId): void +{ + $stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$payment['plan_id']]); + $plan = $stmt->fetch(); + + if (!$plan) return; + + $startDate = date('Y-m-d H:i:s'); + $endDate = date('Y-m-d H:i:s', strtotime('+30 days')); + + $stmt = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at) + VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()) + ON DUPLICATE KEY UPDATE + plan_id = VALUES(plan_id), + max_companies = VALUES(max_companies), + max_invoices_per_month = VALUES(max_invoices_per_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + status = 'active', + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + updated_at = NOW() + "); + + $stmt->execute([ + 't_id' => $payment['tenant_id'], + 'p_id' => $plan['id'], + 'max_c' => $plan['max_companies'], + 'max_i' => $plan['max_invoices_month'], + 'max_u' => $plan['max_users'], + 'price' => $plan['price_jod'], + 'start' => $startDate, + 'end' => $endDate + ]); + + // Log activation + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)"); + $logStmt->execute([ + $payment['tenant_id'], + $userId, + $payment['id'], + json_encode(['plan_id' => $plan['id'], 'auto_verified' => true, 'source' => 'bot_webhook']) + ]); +} diff --git a/app/modules_app/payments/create.php b/app/modules_app/payments/create.php new file mode 100644 index 0000000..e5f576c --- /dev/null +++ b/app/modules_app/payments/create.php @@ -0,0 +1,107 @@ + 'required', +]); +if ($errors) { + json_error('معرف الباقة مطلوب.', 422); +} + +$db = Database::getInstance(); +$tenantId = $decoded['tenant_id']; +$userId = $decoded['user_id']; +$planId = $data['plan_id']; + +try { + // 1. Get plan details + $stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$planId]); + $plan = $stmt->fetch(); + + if (!$plan) { + json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422); + } + + // 2. Check for existing pending payment for this tenant + $stmt = $db->prepare("SELECT id FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1"); + $stmt->execute([$tenantId]); + $existing = $stmt->fetch(); + + if ($existing) { + json_error('لديك طلب دفع قائم بالفعل. يرجى إتمامه أو إلغاؤه أولاً.', 409); + } + + // 3. Generate unique reference number (MSQ-XXXXXX) + $referenceNumber = 'MSQ-' . strtoupper(substr(md5(uniqid((string)mt_rand(), true)), 0, 8)); + + // 4. Get CliQ alias from config + $cliqAlias = env('CLIQ_ALIAS', 'musadaq-pay'); + + // 5. Get payer name + $stmt = $db->prepare("SELECT name, phone FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + // 6. Create payment request + $paymentId = generate_uuid(); + $stmt = $db->prepare(" + INSERT INTO payment_requests (id, tenant_id, user_id, plan_id, amount_jod, internal_reference, cliq_alias, payer_name, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW()) + "); + $stmt->execute([ + $paymentId, + $tenantId, + $userId, + $planId, + $plan['price_jod'], + $referenceNumber, + $cliqAlias, + $user['name'] ?? '' + ]); + + // 7. Log + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'payment.created', 'payment', ?, ?)"); + $logStmt->execute([ + $tenantId, + $userId, + $paymentId, + json_encode(['plan_id' => $planId, 'amount' => $plan['price_jod'], 'ref' => $referenceNumber]) + ]); + + json_success([ + 'payment_id' => $paymentId, + 'reference_number' => $referenceNumber, + 'cliq_alias' => $cliqAlias, + 'amount_jod' => (float)$plan['price_jod'], + 'plan_name' => $plan['name_ar'] ?? $plan['name_en'], + 'payer_name' => $user['name'] ?? '', + 'instructions' => "قم بالتحويل عبر CliQ إلى الاسم المستعار: {$cliqAlias} بمبلغ {$plan['price_jod']} دينار أردني.", + ], 'تم إنشاء طلب الدفع بنجاح'); + +} catch (\Exception $e) { + error_log("Payment Create Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء إنشاء طلب الدفع.', 500); +} diff --git a/app/modules_app/payments/list.php b/app/modules_app/payments/list.php new file mode 100644 index 0000000..1726f74 --- /dev/null +++ b/app/modules_app/payments/list.php @@ -0,0 +1,65 @@ +prepare(" + SELECT pr.*, + u.name AS user_name, u.phone AS user_phone, + sp.name_ar AS plan_name_ar, sp.name_en AS plan_name_en + FROM payment_requests pr + LEFT JOIN users u ON pr.user_id = u.id + LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id + $where + ORDER BY pr.created_at DESC + LIMIT $limit OFFSET $offset + "); + $stmt->execute($params); + $payments = $stmt->fetchAll(); + + // Total count + $countStmt = $db->prepare("SELECT COUNT(*) as total FROM payment_requests pr $where"); + $countStmt->execute($params); + $total = $countStmt->fetch()['total']; + + json_success([ + 'payments' => $payments, + 'pagination' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => (int)$total, + 'pages' => ceil($total / $limit) + ] + ], 'طلبات الدفع'); + +} catch (\Exception $e) { + error_log("Payment List Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500); +} diff --git a/app/modules_app/payments/my_requests.php b/app/modules_app/payments/my_requests.php new file mode 100644 index 0000000..5a9b4b7 --- /dev/null +++ b/app/modules_app/payments/my_requests.php @@ -0,0 +1,35 @@ +prepare(" + SELECT pr.id, pr.plan_id, pr.amount_jod, pr.reference_number, pr.cliq_alias, + pr.status, pr.ai_match_score, pr.created_at, pr.verified_at, + sp.name_ar AS plan_name + FROM payment_requests pr + LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id + WHERE pr.tenant_id = ? + ORDER BY pr.created_at DESC + "); + $stmt->execute([$tenantId]); + $requests = $stmt->fetchAll(); + + json_success($requests, 'طلبات الدفع الخاصة بك'); + +} catch (\Exception $e) { + error_log("My Payment Requests Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء جلب طلبات الدفع.', 500); +} diff --git a/app/modules_app/payments/review.php b/app/modules_app/payments/review.php new file mode 100644 index 0000000..8ce2165 --- /dev/null +++ b/app/modules_app/payments/review.php @@ -0,0 +1,107 @@ +prepare("SELECT * FROM payment_requests WHERE id = ? AND status IN ('pending','uploaded','verified')"); + $stmt->execute([$paymentId]); + $payment = $stmt->fetch(); + + if (!$payment) { + json_error('طلب الدفع غير موجود أو تم معالجته.', 404); + } + + $db->beginTransaction(); + + if ($action === 'approve') { + // Activate subscription + $stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$payment['plan_id']]); + $plan = $stmt->fetch(); + + if ($plan) { + $startDate = date('Y-m-d H:i:s'); + $endDate = date('Y-m-d H:i:s', strtotime('+30 days')); + + $stmt = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at) + VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()) + ON DUPLICATE KEY UPDATE + plan_id = VALUES(plan_id), + max_companies = VALUES(max_companies), + max_invoices_per_month = VALUES(max_invoices_per_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + status = 'active', + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + updated_at = NOW() + "); + $stmt->execute([ + 't_id' => $payment['tenant_id'], + 'p_id' => $plan['id'], + 'max_c' => $plan['max_companies'], + 'max_i' => $plan['max_invoices_month'], + 'max_u' => $plan['max_users'], + 'price' => $plan['price_jod'], + 'start' => $startDate, + 'end' => $endDate + ]); + } + + $stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', admin_notes = ?, verified_at = NOW(), updated_at = NOW() WHERE id = ?"); + $stmt->execute([$notes, $paymentId]); + } else { + $stmt = $db->prepare("UPDATE payment_requests SET status = 'rejected', admin_notes = ?, updated_at = NOW() WHERE id = ?"); + $stmt->execute([$notes, $paymentId]); + } + + // Audit log + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, ?, 'payment', ?, ?)"); + $logStmt->execute([ + $payment['tenant_id'], + $decoded['user_id'], + "payment.{$action}d", + $paymentId, + json_encode(['notes' => $notes, 'reviewer' => $decoded['user_id']]) + ]); + + $db->commit(); + + json_success([ + 'payment_id' => $paymentId, + 'new_status' => $action === 'approve' ? 'approved' : 'rejected' + ], $action === 'approve' ? 'تم اعتماد الدفع وتفعيل الاشتراك' : 'تم رفض طلب الدفع'); + +} catch (\Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + error_log("Payment Review Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء مراجعة طلب الدفع.', 500); +} diff --git a/app/modules_app/payments/stats.php b/app/modules_app/payments/stats.php new file mode 100644 index 0000000..0318206 --- /dev/null +++ b/app/modules_app/payments/stats.php @@ -0,0 +1,79 @@ +query("SELECT COALESCE(SUM(amount_jod), 0) as total_revenue FROM payment_requests WHERE status = 'approved'"); + $totalRevenue = (float)$stmt->fetch()['total_revenue']; + + // This month revenue + $stmt = $db->query("SELECT COALESCE(SUM(amount_jod), 0) as month_revenue FROM payment_requests WHERE status = 'approved' AND MONTH(verified_at) = MONTH(NOW()) AND YEAR(verified_at) = YEAR(NOW())"); + $monthRevenue = (float)$stmt->fetch()['month_revenue']; + + // Payment counts by status + $stmt = $db->query(" + SELECT status, COUNT(*) as count + FROM payment_requests + GROUP BY status + "); + $statusCounts = []; + while ($row = $stmt->fetch()) { + $statusCounts[$row['status']] = (int)$row['count']; + } + + // Active subscriptions count + $stmt = $db->query("SELECT COUNT(*) as active FROM subscriptions WHERE status = 'active' AND current_period_end > NOW()"); + $activeSubscriptions = (int)$stmt->fetch()['active']; + + // Revenue by plan + $stmt = $db->query(" + SELECT sp.name_ar, sp.name_en, COUNT(pr.id) as count, COALESCE(SUM(pr.amount_jod), 0) as revenue + FROM payment_requests pr + LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id + WHERE pr.status = 'approved' + GROUP BY pr.plan_id + ORDER BY revenue DESC + "); + $revenueByPlan = $stmt->fetchAll(); + + // Recent payments (last 10) + $stmt = $db->query(" + SELECT pr.id, pr.amount_jod, pr.status, pr.reference_number, pr.ai_match_score, pr.created_at, pr.verified_at, + u.name AS payer_name, sp.name_ar AS plan_name + FROM payment_requests pr + LEFT JOIN users u ON pr.user_id = u.id + LEFT JOIN subscription_plans sp ON pr.plan_id = sp.id + ORDER BY pr.created_at DESC + LIMIT 10 + "); + $recentPayments = $stmt->fetchAll(); + + json_success([ + 'total_revenue' => $totalRevenue, + 'month_revenue' => $monthRevenue, + 'active_subscriptions' => $activeSubscriptions, + 'payment_counts' => $statusCounts, + 'revenue_by_plan' => $revenueByPlan, + 'recent_payments' => $recentPayments, + ], 'إحصائيات الإيرادات والاشتراكات'); + +} catch (\Exception $e) { + error_log("Payment Stats Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء جلب الإحصائيات.', 500); +} diff --git a/app/modules_app/payments/upload_receipt.php b/app/modules_app/payments/upload_receipt.php new file mode 100644 index 0000000..13b88d9 --- /dev/null +++ b/app/modules_app/payments/upload_receipt.php @@ -0,0 +1,284 @@ +prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending','uploaded')"); + $stmt->execute([$paymentId, $tenantId]); + $payment = $stmt->fetch(); + + if (!$payment) { + json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404); + } + + // 2. Save receipt image + $uploadDir = STORAGE_PATH . '/receipts/' . $tenantId; + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0750, true); + } + + $ext = pathinfo($_FILES['receipt']['name'], PATHINFO_EXTENSION) ?: 'jpg'; + $filename = $paymentId . '_' . time() . '.' . $ext; + $filepath = $uploadDir . '/' . $filename; + + if (!move_uploaded_file($_FILES['receipt']['tmp_name'], $filepath)) { + json_error('فشل في حفظ صورة الوصل.', 500); + } + + // 3. AI Analysis of receipt image + $aiResult = analyzeReceipt($filepath, $payment); + + // 4. Calculate match score + $matchScore = calculateMatchScore($aiResult, $payment); + + // 5. Update payment request + $newStatus = $matchScore >= 85.0 ? 'verified' : 'uploaded'; + + $stmt = $db->prepare(" + UPDATE payment_requests + SET receipt_image_path = ?, + ai_extracted_data = ?, + ai_match_score = ?, + status = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $filepath, + json_encode($aiResult, JSON_UNESCAPED_UNICODE), + $matchScore, + $newStatus, + $paymentId + ]); + + // 6. If high confidence match, auto-activate subscription + if ($matchScore >= 85.0) { + activateSubscription($db, $payment, $decoded['user_id']); + + $stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', verified_at = NOW() WHERE id = ?"); + $stmt->execute([$paymentId]); + + json_success([ + 'status' => 'approved', + 'match_score' => $matchScore, + 'message' => 'تم التحقق من الدفع وتفعيل الاشتراك تلقائياً!', + 'extracted' => $aiResult, + ], 'تم اعتماد الدفع وتفعيل الاشتراك'); + } + + json_success([ + 'status' => $newStatus, + 'match_score' => $matchScore, + 'message' => $matchScore >= 60 + ? 'تم رفع الوصل. جاري المراجعة من الإدارة.' + : 'لم نتمكن من التحقق التلقائي. تم إرسال الطلب للمراجعة اليدوية.', + 'extracted' => $aiResult, + ], 'تم رفع وصل الدفع'); + +} catch (\Exception $e) { + error_log("Payment Receipt Upload Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء معالجة وصل الدفع.', 500); +} + +/** + * Analyze receipt image using Gemini AI + */ +function analyzeReceipt(string $imagePath, array $payment): array +{ + $apiKey = env('GEMINI_API_KEY'); + if (!$apiKey) { + return ['error' => 'AI API key not configured']; + } + + $imageData = base64_encode(file_get_contents($imagePath)); + $mimeType = mime_content_type($imagePath) ?: 'image/jpeg'; + + $prompt = <<, + "currency": "<العملة: JOD/USD/etc>", + "sender_name": "<اسم المرسل/الدافع>", + "receiver_name": "<اسم المستقبل>", + "reference_number": "<رقم المرجع أو رقم العملية>", + "transfer_date": "<تاريخ التحويل YYYY-MM-DD>", + "bank_name": "<اسم البنك>", + "is_valid_receipt": , + "confidence": <نسبة الثقة 0-100> +} + +المبلغ المتوقع: {$payment['amount_jod']} دينار أردني +رقم المرجع المتوقع: {$payment['reference_number']} +الاسم المستعار CliQ: {$payment['cliq_alias']} +PROMPT; + + $model = env('GEMINI_MODEL', 'gemini-1.5-flash'); + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + + $payload = [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => $imageData + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'responseMimeType' => 'application/json', + 'temperature' => 0.1 + ] + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 30 + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("Gemini Receipt Analysis Error: $response"); + return ['error' => 'AI analysis failed', 'is_valid_receipt' => false]; + } + + $respData = json_decode($response, true); + $jsonText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $parsed = json_decode($jsonText, true); + + return $parsed ?: ['error' => 'Failed to parse AI response', 'is_valid_receipt' => false]; +} + +/** + * Calculate match score between AI extraction and expected payment + */ +function calculateMatchScore(array $aiResult, array $payment): float +{ + if (!($aiResult['is_valid_receipt'] ?? false)) return 0.0; + + $score = 0.0; + + // Amount match (40 points) + $extractedAmount = (float)($aiResult['amount'] ?? 0); + $expectedAmount = (float)$payment['amount_jod']; + if (abs($extractedAmount - $expectedAmount) < 0.01) { + $score += 40; + } elseif (abs($extractedAmount - $expectedAmount) < 1.0) { + $score += 20; + } + + // Reference number match (30 points) + $extractedRef = strtoupper(trim($aiResult['reference_number'] ?? '')); + $expectedRef = strtoupper(trim($payment['reference_number'])); + if ($extractedRef === $expectedRef) { + $score += 30; + } elseif (str_contains($extractedRef, $expectedRef) || str_contains($expectedRef, $extractedRef)) { + $score += 15; + } + + // Receiver name / CliQ alias match (15 points) + $receiverName = strtolower($aiResult['receiver_name'] ?? ''); + $cliqAlias = strtolower($payment['cliq_alias']); + if (str_contains($receiverName, $cliqAlias) || str_contains($cliqAlias, $receiverName)) { + $score += 15; + } + + // AI confidence boost (15 points) + $confidence = (float)($aiResult['confidence'] ?? 0); + $score += ($confidence / 100) * 15; + + return min(round($score, 2), 100.0); +} + +/** + * Auto-activate subscription upon verified payment + */ +function activateSubscription(\PDO $db, array $payment, string $userId): void +{ + $stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$payment['plan_id']]); + $plan = $stmt->fetch(); + + if (!$plan) return; + + $startDate = date('Y-m-d H:i:s'); + $endDate = date('Y-m-d H:i:s', strtotime('+30 days')); + + $stmt = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at) + VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()) + ON DUPLICATE KEY UPDATE + plan_id = VALUES(plan_id), + max_companies = VALUES(max_companies), + max_invoices_per_month = VALUES(max_invoices_per_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + status = 'active', + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + updated_at = NOW() + "); + + $stmt->execute([ + 't_id' => $payment['tenant_id'], + 'p_id' => $plan['id'], + 'max_c' => $plan['max_companies'], + 'max_i' => $plan['max_invoices_month'], + 'max_u' => $plan['max_users'], + 'price' => $plan['price_jod'], + 'start' => $startDate, + 'end' => $endDate + ]); + + // Log activation + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)"); + $logStmt->execute([ + $payment['tenant_id'], + $userId, + $payment['id'], + json_encode(['plan_id' => $plan['id'], 'auto_verified' => true]) + ]); +} diff --git a/app/modules_app/payments/verify_reference.php b/app/modules_app/payments/verify_reference.php new file mode 100644 index 0000000..8dfdd60 --- /dev/null +++ b/app/modules_app/payments/verify_reference.php @@ -0,0 +1,154 @@ + 'required', + 'bank_reference' => 'required' +]); + +if ($errors || empty($bankReference)) { + json_error('رقم المرجع البنكي مطلوب.', 422); +} + +$db = Database::getInstance(); +$tenantId = $decoded['tenant_id']; + +try { + // 1. Verify payment request exists and belongs to this tenant + $stmt = $db->prepare("SELECT * FROM payment_requests WHERE id = ? AND tenant_id = ? AND status IN ('pending', 'uploaded')"); + $stmt->execute([$paymentId, $tenantId]); + $payment = $stmt->fetch(); + + if (!$payment) { + json_error('طلب الدفع غير موجود أو تم معالجته بالفعل.', 404); + } + + $db->beginTransaction(); + + // 2. Check if the bot has already recorded this transaction + $stmt = $db->prepare("SELECT * FROM bank_transactions WHERE bank_reference = ? AND is_claimed = 0 LIMIT 1"); + $stmt->execute([$bankReference]); + $transaction = $stmt->fetch(); + + if ($transaction) { + // Match found! Check amount + $expectedAmount = (float)$payment['amount_jod']; + $receivedAmount = (float)$transaction['amount']; + + if (abs($expectedAmount - $receivedAmount) < 0.01) { + // Amount matches exactly -> Auto Approve + activateSubscription($db, $payment, $decoded['user_id']); + + $stmt = $db->prepare("UPDATE payment_requests SET status = 'approved', bank_reference = ?, verified_at = NOW() WHERE id = ?"); + $stmt->execute([$bankReference, $paymentId]); + + $stmt = $db->prepare("UPDATE bank_transactions SET is_claimed = 1 WHERE id = ?"); + $stmt->execute([$transaction['id']]); + + $db->commit(); + json_success([ + 'status' => 'approved', + 'message' => 'تم التحقق من الدفع وتفعيل الاشتراك تلقائياً!' + ], 'تم اعتماد الدفع وتفعيل الاشتراك'); + } else { + // Amount mismatch -> Needs manual review + $stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ?, admin_notes = 'المبلغ غير متطابق' WHERE id = ?"); + $stmt->execute([$bankReference, $paymentId]); + $db->commit(); + + json_success([ + 'status' => 'uploaded', + 'message' => 'تم العثور على الحوالة ولكن المبلغ غير متطابق. تم تحويل الطلب للمراجعة الإدارية.' + ], 'قيد المراجعة'); + } + } else { + // No matching transaction found yet. Wait for the bot. + $stmt = $db->prepare("UPDATE payment_requests SET status = 'uploaded', bank_reference = ? WHERE id = ?"); + $stmt->execute([$bankReference, $paymentId]); + $db->commit(); + + json_success([ + 'status' => 'uploaded', + 'message' => 'تم حفظ رقم المرجع بنجاح. سيتم تفعيل الاشتراك تلقائياً فور وصول تأكيد الحوالة من البنك.' + ], 'تم حفظ المرجع (بانتظار التأكيد)'); + } + +} catch (\Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + error_log("Verify Reference Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء معالجة رقم المرجع.', 500); +} + +/** + * Auto-activate subscription upon verified payment + */ +function activateSubscription(\PDO $db, array $payment, string $userId): void +{ + $stmt = $db->prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$payment['plan_id']]); + $plan = $stmt->fetch(); + + if (!$plan) return; + + $startDate = date('Y-m-d H:i:s'); + $endDate = date('Y-m-d H:i:s', strtotime('+30 days')); + + $stmt = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, updated_at) + VALUES (:t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW()) + ON DUPLICATE KEY UPDATE + plan_id = VALUES(plan_id), + max_companies = VALUES(max_companies), + max_invoices_per_month = VALUES(max_invoices_per_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + status = 'active', + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + updated_at = NOW() + "); + + $stmt->execute([ + 't_id' => $payment['tenant_id'], + 'p_id' => $plan['id'], + 'max_c' => $plan['max_companies'], + 'max_i' => $plan['max_invoices_month'], + 'max_u' => $plan['max_users'], + 'price' => $plan['price_jod'], + 'start' => $startDate, + 'end' => $endDate + ]); + + // Log activation + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'subscription.activated', 'payment', ?, ?)"); + $logStmt->execute([ + $payment['tenant_id'], + $userId, + $payment['id'], + json_encode(['plan_id' => $plan['id'], 'auto_verified' => true]) + ]); +} diff --git a/app/modules_app/voice/grok_intent.php b/app/modules_app/voice/grok_intent.php new file mode 100644 index 0000000..1e26410 --- /dev/null +++ b/app/modules_app/voice/grok_intent.php @@ -0,0 +1,101 @@ + 'required']); +if ($errors) { + json_error('النص مطلوب', 422); +} + +$apiKey = env('XAI_API_KEY'); // Ensure this is set in .env +if (!$apiKey) { + json_error('xAI API Key غير متوفر', 500); +} + +$text = $data['text']; + +$systemPrompt = << 'grok-1', // Update to the correct Grok model when available + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $text] + ], + 'response_format' => ['type' => 'json_object'], + 'temperature' => 0.2 +]; + +$url = "https://api.x.ai/v1/chat/completions"; + +$ch = curl_init($url); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey + ] +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$error = curl_error($ch); +curl_close($ch); + +if ($httpCode !== 200) { + error_log("Grok Error: $response | $error"); + json_error('فشل في تحليل الأمر بواسطة Grok', 500); +} + +$respData = json_decode($response, true); +if (!isset($respData['choices'][0]['message']['content'])) { + json_error('رد غير متوقع من Grok AI', 500); +} + +$jsonText = $respData['choices'][0]['message']['content']; +$parsed = json_decode($jsonText, true); + +if (!$parsed) { + json_error('فشل في تحليل الرد كـ JSON', 500); +} + +json_success($parsed, 'تم تحليل الأمر بواسطة Grok'); diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index 33abcbf..fc91edc 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -4,10 +4,16 @@ import '../../features/auth/views/phone_input_view.dart'; import '../../features/auth/views/otp_verify_view.dart'; import '../../features/auth/views/biometric_setup_view.dart'; import '../../features/auth/views/biometric_auth_view.dart'; +import '../../features/main_shell/views/main_shell_view.dart'; import '../../features/dashboard/views/dashboard_view.dart'; import '../../features/dashboard/controllers/dashboard_controller.dart'; import '../../features/scanner/views/scanner_view.dart'; import '../../features/scanner/controllers/scanner_controller.dart'; +import '../../features/invoices/controllers/invoices_controller.dart'; +import '../../features/settings/controllers/settings_controller.dart'; +import '../../features/subscription/views/subscription_view.dart'; +import '../../features/subscription/views/payment_receipt_view.dart'; +import '../../features/invoices/views/invoice_detail_view.dart'; import '../../core/storage/secure_storage.dart'; part 'app_routes.dart'; @@ -60,11 +66,24 @@ class AppPages { name: AppRoutes.BIOMETRIC_AUTH, page: () => const BiometricAuthView(), ), + // NEW: Main Shell (replaces standalone dashboard route as the home) GetPage( - name: AppRoutes.DASHBOARD, - page: () => const DashboardView(), + name: AppRoutes.MAIN, + page: () => const MainShellView(), binding: BindingsBuilder(() { Get.put(DashboardController()); + Get.put(InvoicesController()); + Get.put(SettingsController()); + }), + ), + // Keep dashboard as standalone for backward compatibility + GetPage( + name: AppRoutes.DASHBOARD, + page: () => const MainShellView(), // Now redirects to MainShell + binding: BindingsBuilder(() { + Get.put(DashboardController()); + Get.put(InvoicesController()); + Get.put(SettingsController()); }), ), GetPage( @@ -74,5 +93,17 @@ class AppPages { Get.put(ScannerController()); }), ), + GetPage( + name: AppRoutes.SUBSCRIPTION, + page: () => const SubscriptionView(), + ), + GetPage( + name: AppRoutes.PAYMENT_RECEIPT, + page: () => const PaymentReceiptView(), + ), + GetPage( + name: AppRoutes.INVOICE_DETAIL, + page: () => const InvoiceDetailView(), + ), ]; } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 9314a02..d4653b8 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -7,6 +7,13 @@ abstract class AppRoutes { static const BIOMETRIC_SETUP = '/biometric-setup'; static const BIOMETRIC_AUTH = '/biometric-auth'; static const LOGIN = '/login'; + static const MAIN = '/main'; static const DASHBOARD = '/dashboard'; static const SCANNER = '/scanner'; + static const INVOICES = '/invoices'; + static const SETTINGS = '/settings'; + static const NOTIFICATIONS = '/notifications'; + static const SUBSCRIPTION = '/subscription'; + static const PAYMENT_RECEIPT = '/payment-receipt'; + static const INVOICE_DETAIL = '/invoice-detail'; } diff --git a/musadaq-app/lib/core/services/upload_progress_service.dart b/musadaq-app/lib/core/services/upload_progress_service.dart new file mode 100644 index 0000000..f19c115 --- /dev/null +++ b/musadaq-app/lib/core/services/upload_progress_service.dart @@ -0,0 +1,48 @@ +import 'package:get/get.dart'; + +class UploadProgressService extends GetxService { + var isUploading = false.obs; + var progress = 0.0.obs; + var companyName = ''.obs; + var totalImages = 0.obs; + var currentImageIndex = 0.obs; + var status = 'uploading'.obs; // uploading, processing, done + + void startUpload(String company, int total) { + isUploading.value = true; + companyName.value = company; + totalImages.value = total; + currentImageIndex.value = 0; + progress.value = 0.0; + status.value = 'uploading'; + } + + void updateProgress(double p, int current) { + progress.value = p; + currentImageIndex.value = current; + } + + void startProcessing() { + status.value = 'processing'; + progress.value = 0.5; // generic progress for processing until FCM hits + } + + void updateProcessingProgress(int processed, int total) { + status.value = 'processing'; + progress.value = processed / total; + currentImageIndex.value = processed; + totalImages.value = total; + } + + void complete() { + status.value = 'done'; + progress.value = 1.0; + Future.delayed(const Duration(seconds: 3), () { + isUploading.value = false; + }); + } + + void fail() { + isUploading.value = false; + } +} diff --git a/musadaq-app/lib/core/storage/secure_storage.dart b/musadaq-app/lib/core/storage/secure_storage.dart index ca75b71..d055512 100644 --- a/musadaq-app/lib/core/storage/secure_storage.dart +++ b/musadaq-app/lib/core/storage/secure_storage.dart @@ -26,4 +26,13 @@ class SecureStorage { Future clearAll() async { await _storage.deleteAll(); } + + // Generic read/write for user profile data + Future write(String key, String value) async { + await _storage.write(key: key, value: value); + } + + Future read(String key) async { + return await _storage.read(key: key); + } } diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index bf7620f..f55d53c 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -8,90 +8,92 @@ class DashboardView extends GetView { @override Widget build(BuildContext context) { - // We instantiate the controller here if not bound, though we should use binding in routes. - // For safety, let's put it here or rely on the router binding. - Get.put(DashboardController()); + final isDark = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: const Color(0xFFF5F7FA), - appBar: AppBar( - title: const Text('لوحة التحكم - مُصادَق', style: TextStyle(fontWeight: FontWeight.bold)), - backgroundColor: const Color(0xFF0F4C81), - foregroundColor: Colors.white, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => controller.refreshData(), + return Column( + children: [ + // Custom Top Bar + Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), + color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Center( + child: Text( + 'مُصادَق', + style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ), + IconButton( + icon: const Icon(Icons.notifications_outlined, color: Colors.white), + onPressed: () => Get.snackbar('قريباً', 'الإشعارات ستتوفر قريباً'), + ), + IconButton( + icon: const Icon(Icons.refresh, color: Colors.white), + onPressed: () => controller.refreshData(), + ), + ], ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => controller.logout(), - ) - ], - ), - body: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); - } + ), + + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } - final stats = controller.stats; - final role = controller.userRole.value; + final stats = controller.stats; + final role = controller.userRole.value; - return RefreshIndicator( - onRefresh: () async => controller.refreshData(), - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildWelcomeHeader(role), - const SizedBox(height: 24), - - // Action Buttons - _buildQuickActions(), - const SizedBox(height: 32), - - // Invoice Stats - const Text('إحصائيات الفواتير', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - _buildInvoiceStats(stats), - - // Role Specific Stats (Companies, Users, Tenants) - if (role == 'admin' || role == 'super_admin') ...[ - const SizedBox(height: 24), - const Text('نظرة عامة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - _buildRoleSpecificStats(stats, role), - ], - - // Quota - if (role == 'admin' && stats['subscription'] != null) ...[ - const SizedBox(height: 24), - _buildQuotaMeter(stats['subscription']), - ], - - const SizedBox(height: 32), - const Text('أحدث النشاطات', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - _buildRecentActivity(), - const SizedBox(height: 40), - ], - ), - ), - ); - }), + return RefreshIndicator( + onRefresh: () async => controller.refreshData(), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWelcomeHeader(role, isDark), + const SizedBox(height: 24), + _buildQuickActions(isDark), + const SizedBox(height: 32), + const Text('إحصائيات الفواتير', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildInvoiceStats(stats, isDark), + if (role == 'admin' || role == 'super_admin') ...[ + const SizedBox(height: 24), + const Text('نظرة عامة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildRoleSpecificStats(stats, role, isDark), + ], + if (role == 'admin' && stats['subscription'] != null) ...[ + const SizedBox(height: 24), + _buildQuotaMeter(stats['subscription'], isDark), + ], + const SizedBox(height: 32), + const Text('أحدث النشاطات', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildRecentActivity(isDark), + const SizedBox(height: 40), + ], + ), + ), + ); + }), + ), + ], ); } - Widget _buildWelcomeHeader(String role) { + Widget _buildWelcomeHeader(String role, bool isDark) { String roleName = 'مستخدم'; switch (role) { case 'super_admin': roleName = 'مدير النظام'; break; case 'admin': roleName = 'مدير المكتب'; break; case 'accountant': roleName = 'محاسب'; break; - case 'viewer': roleName = 'مشاهد'; break; } return Row( @@ -106,20 +108,20 @@ class DashboardView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('مرحباً بك في مُصادَق 👋', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - Text('صلاحيات: $roleName', style: const TextStyle(color: Colors.grey, fontSize: 14)), + Text('صلاحيات: $roleName', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 14)), ], ), ], ); } - Widget _buildQuickActions() { + Widget _buildQuickActions(bool isDark) { return Row( children: [ Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.document_scanner), - label: const Text('المسح الضوئي', style: TextStyle(fontWeight: FontWeight.bold)), + label: const Text('المسح الضوئي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF0F4C81), foregroundColor: Colors.white, @@ -130,69 +132,66 @@ class DashboardView extends GetView { ), ), const SizedBox(width: 12), - if (controller.userRole.value == 'admin') - Expanded( - child: OutlinedButton.icon( - icon: const Icon(Icons.business), - label: const Text('إدارة الشركات', style: TextStyle(fontWeight: FontWeight.bold)), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF0F4C81), - side: const BorderSide(color: Color(0xFF0F4C81)), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - Get.snackbar('قريباً', 'سيتم إطلاق هذه الميزة قريباً'); - }, + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.mic_rounded), + label: const Text('المساعد الصوتي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + style: OutlinedButton.styleFrom( + foregroundColor: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81), + side: BorderSide(color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81)), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), + onPressed: () { + Get.snackbar('المساعد الصوتي', 'يتم تجهيز خوادم AI (Grok & Gemini) للاستماع لأوامرك...'); + }, ), + ), ], ); } - Widget _buildInvoiceStats(Map stats) { + Widget _buildInvoiceStats(Map stats, bool isDark) { final inv = stats['invoices'] ?? {'total': 0, 'pending': 0, 'approved': 0}; return Row( children: [ - _buildStatCard('الكل', inv['total'].toString(), Icons.receipt_long, Colors.blue), + _buildStatCard('الكل', inv['total'].toString(), Icons.receipt_long, Colors.blue, isDark), const SizedBox(width: 12), - _buildStatCard('قيد المعالجة', inv['pending'].toString(), Icons.hourglass_empty, Colors.orange), + _buildStatCard('قيد المعالجة', inv['pending'].toString(), Icons.hourglass_empty, Colors.orange, isDark), const SizedBox(width: 12), - _buildStatCard('معتمدة', inv['approved'].toString(), Icons.check_circle, Colors.green), + _buildStatCard('معتمدة', inv['approved'].toString(), Icons.check_circle, Colors.green, isDark), ], ); } - Widget _buildRoleSpecificStats(Map stats, String role) { + Widget _buildRoleSpecificStats(Map stats, String role, bool isDark) { if (role == 'super_admin') { return Row( children: [ - _buildStatCard('المستأجرين', (stats['tenants'] ?? 0).toString(), Icons.business_center, Colors.indigo), + _buildStatCard('المستأجرين', (stats['tenants'] ?? 0).toString(), Icons.business_center, Colors.indigo, isDark), const SizedBox(width: 12), - _buildStatCard('المستخدمين', (stats['total_users'] ?? 0).toString(), Icons.people, Colors.purple), + _buildStatCard('المستخدمين', (stats['total_users'] ?? 0).toString(), Icons.people, Colors.purple, isDark), ], ); } else { return Row( children: [ - _buildStatCard('الشركات', (stats['companies'] ?? 0).toString(), Icons.business, Colors.indigo), + _buildStatCard('الشركات', (stats['companies'] ?? 0).toString(), Icons.business, Colors.indigo, isDark), const SizedBox(width: 12), - _buildStatCard('المستخدمين', (stats['users'] ?? 0).toString(), Icons.people, Colors.purple), + _buildStatCard('المستخدمين', (stats['users'] ?? 0).toString(), Icons.people, Colors.purple, isDark), ], ); } } - Widget _buildStatCard(String title, String count, IconData icon, Color color) { + Widget _buildStatCard(String title, String count, IconData icon, Color color, bool isDark) { return Expanded( child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)), - ], + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -200,14 +199,14 @@ class DashboardView extends GetView { Icon(icon, color: color, size: 28), const SizedBox(height: 12), Text(count, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)), + Text(title, style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 12)), ], ), ), ); } - Widget _buildQuotaMeter(Map subscription) { + Widget _buildQuotaMeter(Map subscription, bool isDark) { int limit = subscription['limit'] ?? 100; int used = subscription['used'] ?? 0; double progress = limit > 0 ? (used / limit) : 0; @@ -215,9 +214,9 @@ class DashboardView extends GetView { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0)), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -226,7 +225,7 @@ class DashboardView extends GetView { const SizedBox(height: 12), LinearProgressIndicator( value: progress, - backgroundColor: Colors.grey.shade200, + backgroundColor: isDark ? Colors.white10 : Colors.grey.shade200, color: progress > 0.9 ? Colors.red : const Color(0xFF0F4C81), minHeight: 8, borderRadius: BorderRadius.circular(4), @@ -236,7 +235,7 @@ class DashboardView extends GetView { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('$used فاتورة', style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF0F4C81))), - Text('من $limit', style: const TextStyle(color: Colors.grey)), + Text('من $limit', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey)), ], ) ], @@ -244,9 +243,9 @@ class DashboardView extends GetView { ); } - Widget _buildRecentActivity() { + Widget _buildRecentActivity(bool isDark) { if (controller.recentActivities.isEmpty) { - return const Center(child: Text('لا توجد نشاطات حديثة', style: TextStyle(color: Colors.grey))); + return Center(child: Text('لا توجد نشاطات حديثة', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey))); } return ListView.builder( @@ -258,14 +257,14 @@ class DashboardView extends GetView { return Card( margin: const EdgeInsets.only(bottom: 8), elevation: 0, - color: Colors.white, + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, shape: RoundedRectangleBorder( - side: const BorderSide(color: Color(0xFFE2E8F0)), + side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200), borderRadius: BorderRadius.circular(12), ), child: ListTile( leading: CircleAvatar( - backgroundColor: const Color(0xFFF1F5F9), + backgroundColor: isDark ? Colors.white10 : const Color(0xFFF1F5F9), child: Icon(_getActivityIcon(act['action']), color: const Color(0xFF64748B), size: 18), ), title: Text(_formatAction(act['action'])), @@ -282,6 +281,7 @@ class DashboardView extends GetView { IconData _getActivityIcon(String action) { if (action.contains('approved')) return Icons.check_circle; + if (action.contains('extracted')) return Icons.auto_awesome; if (action.contains('created')) return Icons.add_circle; if (action.contains('deleted')) return Icons.delete; if (action.contains('login')) return Icons.login; @@ -302,7 +302,6 @@ class DashboardView extends GetView { } String _timeAgo(String datetime) { - // A simple timeAgo formatter for demo purposes try { final dt = DateTime.parse(datetime); final diff = DateTime.now().difference(dt); diff --git a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart new file mode 100644 index 0000000..b409d10 --- /dev/null +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -0,0 +1,67 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; + +class InvoiceDetailController extends GetxController { + var invoice = {}.obs; + var isLoading = true.obs; + String? invoiceId; + + @override + void onInit() { + super.onInit(); + if (Get.arguments != null) { + invoiceId = Get.arguments['id']; + if (invoiceId != null) { + fetchInvoiceDetails(); + } + } + } + + Future fetchInvoiceDetails() async { + try { + isLoading.value = true; + final res = await DioClient().client.get('invoices/view', queryParameters: {'id': invoiceId}); + + if (res.data['success'] == true && res.data['data'] != null) { + invoice.value = res.data['data']; + } else { + AppSnackbar.showError('خطأ', 'لم يتم العثور على الفاتورة'); + Get.back(); + } + } catch (e) { + AppLogger.error('Failed to fetch invoice details', e); + AppSnackbar.showError('خطأ', 'فشل تحميل بيانات الفاتورة'); + Get.back(); + } finally { + isLoading.value = false; + } + } + + Future approveInvoice() async { + try { + final res = await DioClient().client.post('invoices/approve', data: {'invoice_id': invoiceId}); + if (res.data['success'] == true) { + AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح'); + // Refresh the detail view + fetchInvoiceDetails(); + } else { + AppSnackbar.showError('خطأ', 'فشل اعتماد الفاتورة'); + } + } catch (e) { + AppLogger.error('Failed to approve invoice', e); + AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع'); + } + } + + void viewOriginalImage() { + final imagePath = invoice['file_path']; + if (imagePath != null && imagePath.isNotEmpty) { + // In a real app, you would download/show the image. For now, just a snackbar or open URL. + AppSnackbar.showInfo('قريباً', 'سيتم عرض الصورة قريباً'); + } else { + AppSnackbar.showWarning('عذراً', 'لا توجد صورة مرتبطة بهذه الفاتورة'); + } + } +} diff --git a/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart new file mode 100644 index 0000000..e5385c2 --- /dev/null +++ b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart @@ -0,0 +1,55 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/logger.dart'; + +class InvoicesController extends GetxController { + var invoices = >[].obs; + var isLoading = true.obs; + var filterStatus = 'all'.obs; + var searchQuery = ''.obs; + var isSearching = false.obs; + + @override + void onInit() { + super.onInit(); + loadInvoices(); + } + + List> get filteredInvoices { + var list = invoices.toList(); + + if (filterStatus.value != 'all') { + list = list.where((inv) => inv['status'] == filterStatus.value).toList(); + } + + if (searchQuery.value.isNotEmpty) { + final q = searchQuery.value.toLowerCase(); + list = list.where((inv) { + final name = (inv['supplier_name'] ?? '').toString().toLowerCase(); + final num = (inv['invoice_number'] ?? '').toString().toLowerCase(); + return name.contains(q) || num.contains(q); + }).toList(); + } + + return list; + } + + void toggleSearch() { + isSearching.value = !isSearching.value; + if (!isSearching.value) searchQuery.value = ''; + } + + Future loadInvoices() async { + try { + isLoading.value = true; + final res = await DioClient().client.get('invoices'); + if (res.data['success'] == true && res.data['data'] != null) { + invoices.value = List>.from(res.data['data']); + } + } catch (e) { + AppLogger.error('Failed to load invoices', e); + } finally { + isLoading.value = false; + } + } +} diff --git a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart new file mode 100644 index 0000000..85c72eb --- /dev/null +++ b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/invoice_detail_controller.dart'; + +class InvoiceDetailView extends StatelessWidget { + const InvoiceDetailView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(InvoiceDetailController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('تفاصيل الفاتورة', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } + + if (controller.invoice.isEmpty) { + return const Center(child: Text('لم يتم العثور على الفاتورة')); + } + + final inv = controller.invoice; + final status = inv['status'] ?? 'pending'; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + Text( + inv['supplier_name'] ?? inv['company_name'] ?? 'بدون اسم', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF0F172A), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'فاتورة ضريبية', + style: TextStyle(color: isDark ? Colors.white70 : Colors.grey.shade600), + ), + const SizedBox(height: 16), + Text( + '${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w900, + color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81), + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 16), + _buildStatusChip(status), + ], + ), + ), + + const SizedBox(height: 16), + + // Details Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('المعلومات الأساسية', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _buildInfoRow('رقم الفاتورة', inv['invoice_number'] ?? '—', isDark), + const Divider(height: 24), + _buildInfoRow('تاريخ الإصدار', inv['invoice_date'] ?? '—', isDark), + const Divider(height: 24), + _buildInfoRow('الرقم الضريبي', inv['tax_number'] ?? '—', isDark), + ], + ), + ), + + const SizedBox(height: 16), + + // Amounts Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('تفاصيل المبلغ', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _buildInfoRow('المبلغ الخاضع للضريبة', '${inv['subtotal'] ?? '0.00'} JOD', isDark), + const Divider(height: 24), + _buildInfoRow('قيمة الضريبة', '${inv['tax_amount'] ?? '0.00'} JOD', isDark), + const Divider(height: 24), + _buildInfoRow('الإجمالي', '${inv['grand_total'] ?? '0.00'} JOD', isDark, isBold: true), + ], + ), + ), + + const SizedBox(height: 32), + + // Action Buttons + if (status == 'extracted') ...[ + SizedBox( + height: 52, + child: ElevatedButton.icon( + onPressed: () => controller.approveInvoice(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.check_circle_outline), + label: const Text('اعتماد الفاتورة نهائياً', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ), + const SizedBox(height: 12), + ], + + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: () => controller.viewOriginalImage(), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF0F4C81), + side: const BorderSide(color: Color(0xFF0F4C81)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.image_outlined), + label: const Text('عرض صورة الفاتورة الأصلية', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + }), + ); + } + + Widget _buildStatusChip(String status) { + Color color; + String text; + + switch (status) { + case 'approved': color = const Color(0xFF10B981); text = '✓ معتمدة'; break; + case 'extracted': color = const Color(0xFF3B82F6); text = 'جاهزة للتدقيق'; break; + default: color = const Color(0xFFF59E0B); text = 'قيد المعالجة'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), + ); + } + + Widget _buildInfoRow(String label, String value, bool isDark, {bool isBold = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 14, color: isDark ? Colors.white70 : Colors.grey.shade600)), + Text( + value, + style: TextStyle( + fontWeight: isBold ? FontWeight.w900 : FontWeight.w600, + fontSize: isBold ? 18 : 15, + color: isDark ? Colors.white : Colors.black87, + fontFamily: value.contains(RegExp(r'[0-9]')) ? 'monospace' : null, + ), + ), + ], + ); + } +} diff --git a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart new file mode 100644 index 0000000..4e7b176 --- /dev/null +++ b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/invoices_controller.dart'; + +class InvoicesListView extends GetView { + const InvoicesListView({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + // App Bar replacement + Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), + color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Center( + child: Text( + 'الفواتير', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + IconButton( + icon: const Icon(Icons.search_rounded, color: Colors.white), + onPressed: () => controller.toggleSearch(), + ), + IconButton( + icon: const Icon(Icons.refresh_rounded, color: Colors.white), + onPressed: () => controller.loadInvoices(), + ), + ], + ), + ), + + // Search Bar + Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: controller.isSearching.value ? 64 : 0, + child: controller.isSearching.value + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + onChanged: (v) => controller.searchQuery.value = v, + textDirection: TextDirection.rtl, + decoration: InputDecoration( + hintText: 'بحث بالرقم أو اسم المورد...', + prefixIcon: const Icon(Icons.search, size: 20), + filled: true, + fillColor: isDark ? Colors.white10 : Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ), + ) + : const SizedBox(), + )), + + // Filter Tabs + Container( + height: 44, + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _buildFilterChip('الكل', 'all', controller, isDark), + _buildFilterChip('قيد المعالجة', 'uploaded', controller, isDark), + _buildFilterChip('جاهزة', 'extracted', controller, isDark), + _buildFilterChip('معتمدة', 'approved', controller, isDark), + ], + ), + ), + + // Invoice List + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return _buildShimmerList(); + } + + final invoices = controller.filteredInvoices; + + if (invoices.isEmpty) { + return _buildEmptyState(isDark); + } + + return RefreshIndicator( + onRefresh: () async => controller.loadInvoices(), + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: invoices.length, + itemBuilder: (context, index) => _buildInvoiceCard(invoices[index], isDark), + ), + ); + }), + ), + ], + ); + } + + Widget _buildFilterChip(String label, String value, InvoicesController ctrl, bool isDark) { + return Obx(() { + final isSelected = ctrl.filterStatus.value == value; + return Padding( + padding: const EdgeInsets.only(left: 8), + child: ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => ctrl.filterStatus.value = value, + selectedColor: const Color(0xFF0F4C81), + backgroundColor: isDark ? Colors.white10 : Colors.white, + labelStyle: TextStyle( + color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87), + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, + fontSize: 13, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + side: BorderSide(color: isSelected ? Colors.transparent : Colors.grey.shade300), + ), + ); + }); + } + + Widget _buildInvoiceCard(Map inv, bool isDark) { + final status = inv['status'] ?? ''; + Color statusColor; + String statusText; + IconData statusIcon; + + switch (status) { + case 'approved': + statusColor = const Color(0xFF10B981); + statusText = '✓ معتمدة'; + statusIcon = Icons.check_circle; + break; + case 'extracted': + statusColor = const Color(0xFF3B82F6); + statusText = 'جاهزة للتدقيق'; + statusIcon = Icons.pending_actions; + break; + default: + statusColor = const Color(0xFFF59E0B); + statusText = 'قيد المعالجة'; + statusIcon = Icons.hourglass_empty; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 0, + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () { + Get.toNamed('/invoice-detail', arguments: {'id': inv['id'].toString()}); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(statusIcon, color: statusColor, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + inv['supplier_name'] ?? inv['company_name'] ?? 'بدون اسم', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: isDark ? Colors.white : const Color(0xFF0F172A), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '# ${inv['invoice_number'] ?? '—'} • ${inv['invoice_date'] ?? '—'}', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white38 : const Color(0xFF94A3B8), + fontFamily: 'monospace', + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 14, + color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF008080), + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + statusText, + style: TextStyle(color: statusColor, fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long_rounded, size: 80, color: isDark ? Colors.white12 : Colors.grey.shade300), + const SizedBox(height: 16), + Text( + 'لا توجد فواتير بعد', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white38 : Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + 'ابدأ بتصوير فواتيرك من زر الماسح الضوئي', + style: TextStyle(fontSize: 13, color: isDark ? Colors.white24 : Colors.grey.shade400), + ), + ], + ), + ); + } + + Widget _buildShimmerList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (context, index) => Container( + height: 80, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(14), + ), + ), + ); + } +} diff --git a/musadaq-app/lib/features/main_shell/views/main_shell_view.dart b/musadaq-app/lib/features/main_shell/views/main_shell_view.dart new file mode 100644 index 0000000..0897e1c --- /dev/null +++ b/musadaq-app/lib/features/main_shell/views/main_shell_view.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../dashboard/views/dashboard_view.dart'; +import '../../invoices/views/invoices_list_view.dart'; +import '../../notifications/views/notifications_view.dart'; +import '../../settings/views/settings_view.dart'; +import '../../../app/routes/app_pages.dart'; +import '../../../core/services/upload_progress_service.dart'; +import '../../../core/utils/app_snackbar.dart'; + +class MainShellView extends StatefulWidget { + const MainShellView({super.key}); + + @override + State createState() => _MainShellViewState(); +} + +class _MainShellViewState extends State { + int _currentIndex = 0; + final UploadProgressService _progressService = Get.put(UploadProgressService()); + + // 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4) + final List _pages = const [ + DashboardView(), // 0 + InvoicesListView(), // 1 + SizedBox(), // 2 - Scanner (FAB placeholder) + NotificationsView(), // 3 + SettingsView(), // 4 + ]; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final navBg = isDark ? const Color(0xFF1A1A2E) : Colors.white; + final activeColor = const Color(0xFF0F4C81); + final inactiveColor = isDark ? Colors.white38 : const Color(0xFF94A3B8); + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + body: Stack( + children: [ + IndexedStack( + index: _getPageIndex(_currentIndex), + children: [ + _pages[0], // Dashboard + _pages[1], // Invoices + _pages[3], // Notifications + _pages[4], // Settings + ], + ), + + // Global Upload Progress Overlay + Obx(() => _progressService.isUploading.value + ? Positioned( + bottom: 80, + left: 16, + right: 16, + child: _buildUploadOverlay(isDark), + ) + : const SizedBox.shrink()), + ], + ), + floatingActionButton: _buildScannerFAB(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: BottomAppBar( + shape: const CircularNotchedRectangle(), + notchMargin: 8, + color: navBg, + elevation: 16, + child: SizedBox( + height: 60, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // Left side (2 items) + _buildNavItem(0, Icons.home_rounded, 'الرئيسية', activeColor, inactiveColor), + _buildNavItem(1, Icons.receipt_long_rounded, 'الفواتير', activeColor, inactiveColor), + // Center gap for FAB + const SizedBox(width: 48), + // Right side (2 items) + _buildNavItem(3, Icons.notifications_rounded, 'الإشعارات', activeColor, inactiveColor), + _buildNavItem(4, Icons.settings_rounded, 'الإعدادات', activeColor, inactiveColor), + ], + ), + ), + ), + ); + } + + int _getPageIndex(int navIndex) { + // Map nav index to page index (skip scanner placeholder at 2) + if (navIndex <= 1) return navIndex; + if (navIndex == 3) return 2; // Notifications + if (navIndex == 4) return 3; // Settings + return 0; + } + + Widget _buildNavItem(int index, IconData icon, String label, Color active, Color inactive) { + final isSelected = _currentIndex == index; + return Expanded( + child: InkWell( + onTap: () => setState(() => _currentIndex = index), + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + decoration: isSelected ? BoxDecoration( + color: active.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ) : null, + child: Icon(icon, color: isSelected ? active : inactive, size: 22), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, + color: isSelected ? active : inactive, + ), + ), + ], + ), + ), + ); + } + + Widget _buildScannerFAB() { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFFD4AF37), Color(0xFFF0D060)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD4AF37).withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: FloatingActionButton( + onPressed: () => Get.toNamed(AppRoutes.SCANNER), + backgroundColor: Colors.transparent, + elevation: 0, + heroTag: 'scanner_fab', + child: const Icon(Icons.document_scanner_rounded, color: Colors.white, size: 26), + ), + ); + } + + Widget _buildUploadOverlay(bool isDark) { + final status = _progressService.status.value; + final progress = _progressService.progress.value; + + Color accentColor = status == 'done' ? const Color(0xFF10B981) : const Color(0xFF0F4C81); + String statusText = status == 'uploading' + ? 'جاري رفع الصور...' + : (status == 'processing' ? 'جاري استخراج البيانات...' : 'اكتملت المعالجة ✓'); + + return Card( + elevation: 8, + shadowColor: Colors.black26, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + status == 'done' + ? const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 24) + : const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0F4C81))), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(statusText, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text( + '${_progressService.companyName.value} • ${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}', + style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.grey), + ), + ], + ), + ), + Text( + '${(progress * 100).toInt()}%', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: accentColor), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: isDark ? Colors.white10 : const Color(0xFFE2E8F0), + color: accentColor, + minHeight: 6, + ), + ), + ], + ), + ), + ); + } +} diff --git a/musadaq-app/lib/features/notifications/views/notifications_view.dart b/musadaq-app/lib/features/notifications/views/notifications_view.dart new file mode 100644 index 0000000..ec26d8a --- /dev/null +++ b/musadaq-app/lib/features/notifications/views/notifications_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class NotificationsView extends StatelessWidget { + const NotificationsView({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + // Top Bar + Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), + color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Center( + child: Text( + 'الإشعارات', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + IconButton( + icon: const Icon(Icons.done_all_rounded, color: Colors.white), + onPressed: () {}, + tooltip: 'قراءة الكل', + ), + ], + ), + ), + + // Notifications List + Expanded( + child: _buildEmptyState(isDark), + ), + ], + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: isDark ? Colors.white.withOpacity(0.05) : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: Icon( + Icons.notifications_off_rounded, + size: 48, + color: isDark ? Colors.white12 : Colors.grey.shade300, + ), + ), + const SizedBox(height: 20), + Text( + 'لا توجد إشعارات', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white38 : Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + 'ستظهر هنا إشعارات معالجة الفواتير\nوتحديثات الاشتراك', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white24 : Colors.grey.shade400, + height: 1.5, + ), + ), + ], + ), + ); + } +} diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart index cca2b6f..8b62b41 100644 --- a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; +import '../../../core/services/upload_progress_service.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/app_snackbar.dart'; import '../../../core/services/image_processing_service.dart'; @@ -22,6 +23,8 @@ class ScannerController extends GetxController { var isBatchDone = false.obs; final InvoiceUploadService _uploadService = InvoiceUploadService(); + final UploadProgressService _progressService = + Get.find(); @override void onInit() { @@ -33,12 +36,19 @@ class ScannerController extends GetxController { void _initFcmListener() { FirebaseMessaging.onMessage.listen((RemoteMessage message) { final data = message.data; - if (data['type'] == 'batch_progress' && data['batch_id'] == currentBatchId.value) { - processedImagesCount.value = int.tryParse(data['processed'].toString()) ?? 0; + if (data['type'] == 'batch_progress' && + data['batch_id'] == currentBatchId.value) { + processedImagesCount.value = + int.tryParse(data['processed'].toString()) ?? 0; totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0; - + + // Update global progress service + _progressService.updateProcessingProgress( + processedImagesCount.value, totalImagesCount.value); + if (processedImagesCount.value >= totalImagesCount.value) { - isBatchDone.value = true; + isBatchDone.value = true; + _progressService.complete(); } } }); @@ -60,18 +70,17 @@ class ScannerController extends GetxController { Future addImage(String imagePath) async { File originalFile = File(imagePath); - // Add to UI immediately so the user doesn't wait capturedImages.add(originalFile); int index = capturedImages.length - 1; - // Process in background without showing full-screen loader - ImageProcessingService.processInvoiceImage(originalFile).then((processedFile) { + ImageProcessingService.processInvoiceImage(originalFile) + .then((processedFile) { if (processedFile != null && index < capturedImages.length) { capturedImages[index] = processedFile; - AppLogger.print('Finished processing image in background. Replaced in batch.'); + AppLogger.print('Finished processing image in background.'); } }).catchError((e) { - AppLogger.error('Failed to process image in background', e); + AppLogger.error('Failed to process image in background', e); }); } @@ -90,89 +99,67 @@ class ScannerController extends GetxController { try { isProcessing.value = true; uploadProgress.value = 0.0; - - // Fetch a valid company ID dynamically to prevent 403 Forbidden + String companyId = fallbackCompanyId; + String companyName = 'شركة غير محددة'; + if (companyId == 'mock_company_id_123' || companyId.isEmpty) { - final res = await DioClient().client.get('companies'); - if (res.data['success'] == true && res.data['data'] != null && res.data['data'].isNotEmpty) { - companyId = res.data['data'][0]['id']; - AppLogger.print('Dynamically fetched company: $companyId'); + if (companies.isNotEmpty) { + companyId = companies[0]['id']; + companyName = companies[0]['name'] ?? 'شركتي'; } else { - AppSnackbar.showError('خطأ', 'لا توجد شركات مسجلة في حسابك'); - isProcessing.value = false; - return; + final res = await DioClient().client.get('companies'); + if (res.data['success'] == true && + res.data['data'] != null && + res.data['data'].isNotEmpty) { + companyId = res.data['data'][0]['id']; + companyName = res.data['data'][0]['name'] ?? 'شركتي'; + } else { + AppSnackbar.showError('خطأ', 'لا توجد شركات مسجلة في حسابك'); + isProcessing.value = false; + return; + } } + } else { + final comp = companies.firstWhereOrNull((c) => c['id'] == companyId); + if (comp != null) companyName = comp['name'] ?? 'شركتي'; } - AppLogger.print('Uploading batch of ${capturedImages.length} images to company $companyId...'); - + AppLogger.print( + 'Uploading batch of ${capturedImages.length} images to company $companyId...'); + + // Start global progress + _progressService.startUpload(companyName, capturedImages.length); + final batchId = await _uploadService.uploadBatch( companyId: companyId, images: capturedImages, onProgress: (current, total) { uploadProgress.value = current / total; + _progressService.updateProgress(uploadProgress.value, current); }, ); - + if (batchId != null) { currentBatchId.value = batchId; totalImagesCount.value = capturedImages.length; processedImagesCount.value = 0; + + // Clear scanner state and go back to dashboard capturedImages.clear(); uploadProgress.value = 0.0; - - Get.dialog( - AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: const Center( - child: Text('جاري المعالجة ⏳', - style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18) - ) - ), - content: Obx(() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('يتم الآن تدقيق الفواتير عبر الذكاء الاصطناعي...', - textAlign: TextAlign.center, - style: TextStyle(fontFamily: 'El Messiri', fontSize: 14) - ), - const SizedBox(height: 20), - if (!isBatchDone.value) ...[ - LinearProgressIndicator( - value: totalImagesCount.value > 0 ? processedImagesCount.value / totalImagesCount.value : 0, - backgroundColor: Colors.grey[200], - valueColor: const AlwaysStoppedAnimation(Color(0xFF0F4C81)), - ), - const SizedBox(height: 10), - Text('${processedImagesCount.value} من ${totalImagesCount.value}', - style: const TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold) - ), - ] else ...[ - const Icon(Icons.check_circle, color: Colors.green, size: 50), - const SizedBox(height: 10), - const Text('اكتملت المعالجة بنجاح!', - style: TextStyle(fontFamily: 'El Messiri', color: Colors.green, fontWeight: FontWeight.bold) - ), - ], - ], - )), - actions: [ - TextButton( - onPressed: () { - Get.back(); // close dialog - Get.back(); // go back to dashboard - }, - child: const Text('إغلاق', style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold)), - ) - ], - ), - barrierDismissible: false, - ); + isProcessing.value = false; + + _progressService.startProcessing(); + Get.back(); // Go back to dashboard, progress will show in overlay + AppSnackbar.showSuccess( + 'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية'); } else { + _progressService.fail(); AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'); } } catch (e) { + _progressService.fail(); AppLogger.error('Failed to upload batch', e); AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع'); } finally { diff --git a/musadaq-app/lib/features/settings/controllers/settings_controller.dart b/musadaq-app/lib/features/settings/controllers/settings_controller.dart new file mode 100644 index 0000000..b33f1de --- /dev/null +++ b/musadaq-app/lib/features/settings/controllers/settings_controller.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../core/storage/secure_storage.dart'; +import '../../../app/routes/app_pages.dart'; + +class SettingsController extends GetxController { + final SecureStorage _storage = SecureStorage(); + + var isDarkMode = false.obs; + var pushEnabled = true.obs; + var userName = ''.obs; + var userPhone = ''.obs; + var userRole = ''.obs; + + String get roleName { + switch (userRole.value) { + case 'super_admin': return 'مدير النظام'; + case 'admin': return 'مدير مكتب'; + case 'accountant': return 'محاسب'; + case 'viewer': return 'مشاهد'; + default: return 'مستخدم'; + } + } + + @override + void onInit() { + super.onInit(); + _loadUserData(); + isDarkMode.value = Get.isDarkMode; + } + + Future _loadUserData() async { + userName.value = await _storage.read('user_name') ?? ''; + userPhone.value = await _storage.read('user_phone') ?? ''; + userRole.value = await _storage.read('user_role') ?? ''; + } + + void toggleTheme() { + isDarkMode.value = !isDarkMode.value; + Get.changeThemeMode(isDarkMode.value ? ThemeMode.dark : ThemeMode.light); + } + + void togglePush() { + pushEnabled.value = !pushEnabled.value; + } + + Future logout() async { + await _storage.clearAll(); + Get.offAllNamed(AppRoutes.PHONE_INPUT); + } +} diff --git a/musadaq-app/lib/features/settings/views/settings_view.dart b/musadaq-app/lib/features/settings/views/settings_view.dart new file mode 100644 index 0000000..2e95390 --- /dev/null +++ b/musadaq-app/lib/features/settings/views/settings_view.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/settings_controller.dart'; +import '../../../app/routes/app_pages.dart'; + +class SettingsView extends GetView { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Column( + children: [ + // Custom Top Bar + Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), + color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Center( + child: Text( + 'الإعدادات', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ), + + Expanded( + child: Obx(() => ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildProfileCard(isDark), + const SizedBox(height: 24), + _buildSectionTitle('المظهر', Icons.palette_rounded, isDark), + const SizedBox(height: 8), + _buildSettingsCard(isDark, [ + _buildSwitchTile( + icon: Icons.dark_mode_rounded, + title: 'الوضع الداكن', + subtitle: 'تفعيل المظهر الداكن للتطبيق', + value: controller.isDarkMode.value, + onChanged: (v) => controller.toggleTheme(), + isDark: isDark, + ), + ]), + const SizedBox(height: 20), + _buildSectionTitle('الإشعارات', Icons.notifications_rounded, isDark), + const SizedBox(height: 8), + _buildSettingsCard(isDark, [ + _buildSwitchTile( + icon: Icons.notifications_active_rounded, + title: 'إشعارات الدفع', + subtitle: 'استلام إشعارات عند اكتمال المعالجة', + value: controller.pushEnabled.value, + onChanged: (v) => controller.togglePush(), + isDark: isDark, + ), + ]), + const SizedBox(height: 20), + _buildSectionTitle('حول التطبيق', Icons.info_rounded, isDark), + const SizedBox(height: 8), + _buildSettingsCard(isDark, [ + _buildInfoTile( + icon: Icons.verified_rounded, + title: 'الإصدار', + trailing: '1.0.0', + isDark: isDark, + ), + const Divider(height: 1), + _buildInfoTile( + icon: Icons.diamond_rounded, + title: 'الاشتراكات والباقات', + trailing: 'ترقية →', + isDark: isDark, + onTap: () => Get.toNamed(AppRoutes.SUBSCRIPTION), + ), + const Divider(height: 1), + _buildInfoTile( + icon: Icons.support_agent_rounded, + title: 'الدعم الفني', + trailing: 'support@musadaq.jo', + isDark: isDark, + ), + const Divider(height: 1), + _buildInfoTile( + icon: Icons.description_rounded, + title: 'سياسة الخصوصية', + trailing: '→', + isDark: isDark, + onTap: () {}, + ), + ]), + const SizedBox(height: 32), + _buildLogoutButton(), + const SizedBox(height: 16), + Center( + child: TextButton( + onPressed: () => _confirmDeleteAccount(context), + child: const Text( + 'حذف الحساب', + style: TextStyle(color: Colors.red, fontSize: 13, decoration: TextDecoration.underline), + ), + ), + ), + const SizedBox(height: 40), + ], + )), + ), + ], + ); + } + + Widget _buildProfileCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0F4C81), Color(0xFF1A6BB5)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Obx(() => Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Center( + child: Text( + (controller.userName.value.isNotEmpty ? controller.userName.value[0] : 'م').toUpperCase(), + style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.userName.value.isNotEmpty ? controller.userName.value : 'مستخدم مُصادَق', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + controller.userPhone.value, + style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 13, fontFamily: 'monospace'), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFD4AF37).withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD4AF37).withOpacity(0.5)), + ), + child: Text( + controller.roleName, + style: const TextStyle(color: Color(0xFFF0D060), fontSize: 11, fontWeight: FontWeight.w600), + ), + ), + ], + )), + ); + } + + Widget _buildSectionTitle(String title, IconData icon, bool isDark) { + return Row( + children: [ + Icon(icon, size: 18, color: const Color(0xFF0F4C81)), + const SizedBox(width: 8), + Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: isDark ? Colors.white70 : const Color(0xFF0F4C81))), + ], + ); + } + + Widget _buildSettingsCard(bool isDark, List children) { + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column(children: children), + ); + } + + Widget _buildSwitchTile({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + required bool isDark, + }) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF0F4C81).withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: const Color(0xFF0F4C81), size: 20), + ), + title: Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87)), + subtitle: Text(subtitle, style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)), + trailing: Switch.adaptive( + value: value, + onChanged: onChanged, + activeColor: const Color(0xFF0F4C81), + ), + ); + } + + Widget _buildInfoTile({ + required IconData icon, + required String title, + required String trailing, + required bool isDark, + VoidCallback? onTap, + }) { + return ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF0F4C81).withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: const Color(0xFF0F4C81), size: 20), + ), + title: Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87)), + trailing: Text(trailing, style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey)), + ); + } + + Widget _buildLogoutButton() { + return SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton.icon( + onPressed: () => _confirmLogout(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFEE2E2), + foregroundColor: const Color(0xFFDC2626), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.logout_rounded), + label: const Text('تسجيل الخروج', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)), + ), + ); + } + + void _confirmLogout() { + Get.defaultDialog( + title: 'تسجيل الخروج', + middleText: 'هل أنت متأكد من رغبتك في تسجيل الخروج؟', + textConfirm: 'خروج', + textCancel: 'إلغاء', + confirmTextColor: Colors.white, + buttonColor: const Color(0xFFDC2626), + onConfirm: () => controller.logout(), + titleStyle: const TextStyle(fontWeight: FontWeight.bold), + radius: 14, + ); + } + + void _confirmDeleteAccount(BuildContext context) { + Get.defaultDialog( + title: '⚠️ حذف الحساب', + middleText: 'سيتم حذف جميع بياناتك نهائياً. هذا الإجراء لا يمكن التراجع عنه.', + textConfirm: 'حذف نهائي', + textCancel: 'إلغاء', + confirmTextColor: Colors.white, + buttonColor: const Color(0xFFDC2626), + onConfirm: () { + Get.back(); + Get.snackbar('قريباً', 'سيتم تفعيل هذه الميزة قريباً'); + }, + titleStyle: const TextStyle(fontWeight: FontWeight.bold), + radius: 14, + ); + } +} diff --git a/musadaq-app/lib/features/subscription/controllers/payment_receipt_controller.dart b/musadaq-app/lib/features/subscription/controllers/payment_receipt_controller.dart new file mode 100644 index 0000000..6e751d5 --- /dev/null +++ b/musadaq-app/lib/features/subscription/controllers/payment_receipt_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; +import 'subscription_controller.dart'; + +class PaymentReceiptController extends GetxController { + var payment = {}.obs; + var isUploading = false.obs; + final referenceController = TextEditingController(); + + @override + void onInit() { + super.onInit(); + if (Get.arguments != null) { + payment.value = Get.arguments; + } + } + + @override + void onClose() { + referenceController.dispose(); + super.onClose(); + } + + Future submitReference() async { + final ref = referenceController.text.trim(); + if (ref.isEmpty) { + AppSnackbar.showWarning('تنبيه', 'الرجاء إدخال رقم المرجع أولاً'); + return; + } + + try { + isUploading.value = true; + String paymentId = payment['id']; + + final res = await DioClient().client.post( + 'payments/verify-reference', + data: { + 'payment_id': paymentId, + 'bank_reference': ref, + }, + ); + + if (res.data['success'] == true) { + final data = res.data['data']; + + // Refresh subscription info + if (Get.isRegistered()) { + Get.find().loadAll(); + } + + Get.back(); // close the screen + + if (data['status'] == 'approved') { + AppSnackbar.showSuccess('مبروك!', data['message']); + } else { + AppSnackbar.showInfo('تم الحفظ', data['message']); + } + } + } catch (e) { + AppLogger.error('Failed to submit reference', e); + AppSnackbar.showError('خطأ', 'فشل التحقق من رقم المرجع. تأكد من صحته أو حاول لاحقاً.'); + } finally { + isUploading.value = false; + } + } +} diff --git a/musadaq-app/lib/features/subscription/controllers/subscription_controller.dart b/musadaq-app/lib/features/subscription/controllers/subscription_controller.dart new file mode 100644 index 0000000..26c43d0 --- /dev/null +++ b/musadaq-app/lib/features/subscription/controllers/subscription_controller.dart @@ -0,0 +1,82 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/logger.dart'; + +class SubscriptionController extends GetxController { + var plans = >[].obs; + var currentSubscription = Rxn>(); + var myPayments = >[].obs; + var isLoading = true.obs; + var isCreatingPayment = false.obs; + var activePaymentRequest = Rxn>(); + + @override + void onInit() { + super.onInit(); + loadAll(); + } + + Future loadAll() async { + isLoading.value = true; + await Future.wait([ + loadPlans(), + loadCurrentSubscription(), + loadMyPayments(), + ]); + isLoading.value = false; + } + + Future loadPlans() async { + try { + final res = await DioClient().client.get('subscriptions/plans'); + if (res.data['success'] == true && res.data['data'] != null) { + plans.value = List>.from(res.data['data']); + } + } catch (e) { + AppLogger.error('Failed to load plans', e); + } + } + + Future loadCurrentSubscription() async { + try { + final res = await DioClient().client.get('subscriptions/current'); + if (res.data['success'] == true && res.data['data'] != null) { + currentSubscription.value = Map.from(res.data['data']); + } + } catch (e) { + AppLogger.error('Failed to load subscription', e); + } + } + + Future loadMyPayments() async { + try { + final res = await DioClient().client.get('payments/my-requests'); + if (res.data['success'] == true && res.data['data'] != null) { + myPayments.value = List>.from(res.data['data']); + // Check for active pending payment + final pending = myPayments.firstWhereOrNull((p) => p['status'] == 'pending'); + activePaymentRequest.value = pending; + } + } catch (e) { + AppLogger.error('Failed to load my payments', e); + } + } + + Future?> createPaymentRequest(String planId) async { + try { + isCreatingPayment.value = true; + final res = await DioClient().client.post('payments/create', data: {'plan_id': planId}); + if (res.data['success'] == true && res.data['data'] != null) { + final result = Map.from(res.data['data']); + activePaymentRequest.value = result; + await loadMyPayments(); + return result; + } + } catch (e) { + AppLogger.error('Failed to create payment', e); + } finally { + isCreatingPayment.value = false; + } + return null; + } +} diff --git a/musadaq-app/lib/features/subscription/views/payment_receipt_view.dart b/musadaq-app/lib/features/subscription/views/payment_receipt_view.dart new file mode 100644 index 0000000..61aedf0 --- /dev/null +++ b/musadaq-app/lib/features/subscription/views/payment_receipt_view.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/payment_receipt_controller.dart'; + +class PaymentReceiptView extends StatelessWidget { + const PaymentReceiptView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(PaymentReceiptController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('إتمام الدفع', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Payment Info Card + Obx(() => Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + children: [ + const Text('تفاصيل التحويل المطلوب', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 16), + _buildInfoRow('الاسم المستعار (CliQ)', controller.payment['cliq_alias'] ?? '', isDark, isHighlight: true), + const Divider(height: 24), + _buildInfoRow('المبلغ المطلوب', '${controller.payment['amount_jod'] ?? 0} JOD', isDark), + ], + ), + )), + + const SizedBox(height: 24), + Text('الخطوة التالية:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: isDark ? Colors.white : Colors.black87)), + const SizedBox(height: 8), + Text( + 'قم بتحويل المبلغ عبر تطبيق البنك الخاص بك إلى الاسم المستعار المذكور أعلاه (CliQ). بعد إتمام الحوالة بنجاح، ستصلك رسالة أو إشعار من البنك يحتوي على "رقم المرجع" للعملية. انسخه والصقه هنا.', + style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.grey.shade700, height: 1.5), + ), + const SizedBox(height: 24), + + // Reference Number Input Area + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('رقم المرجع (Reference Number)', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + TextField( + controller: controller.referenceController, + decoration: InputDecoration( + hintText: 'مثال: 1234567890', + hintStyle: TextStyle(color: isDark ? Colors.white38 : Colors.grey), + filled: true, + fillColor: isDark ? const Color(0xFF1A1A2E) : const Color(0xFFF1F5F9), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + keyboardType: TextInputType.text, + style: TextStyle( + fontFamily: 'monospace', + color: isDark ? Colors.white : Colors.black87, + ), + ), + ], + ), + ), + + const SizedBox(height: 32), + + // Submit Button + SizedBox( + height: 52, + child: Obx(() => ElevatedButton.icon( + onPressed: controller.isUploading.value + ? null + : () => controller.submitReference(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + disabledBackgroundColor: isDark ? Colors.white10 : Colors.grey.shade300, + ), + icon: controller.isUploading.value + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.check_circle_outline), + label: Text( + controller.isUploading.value ? 'جاري التحقق...' : 'تأكيد الدفع', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + )), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value, bool isDark, {bool isHighlight = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.grey.shade600)), + Container( + padding: EdgeInsets.symmetric(horizontal: isHighlight ? 12 : 0, vertical: isHighlight ? 6 : 0), + decoration: isHighlight ? BoxDecoration( + color: const Color(0xFF0F4C81).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ) : null, + child: Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: isHighlight ? const Color(0xFF0F4C81) : (isDark ? Colors.white : Colors.black87), + fontFamily: 'monospace', + ), + ), + ), + ], + ); + } +} diff --git a/musadaq-app/lib/features/subscription/views/subscription_view.dart b/musadaq-app/lib/features/subscription/views/subscription_view.dart new file mode 100644 index 0000000..22de6eb --- /dev/null +++ b/musadaq-app/lib/features/subscription/views/subscription_view.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/subscription_controller.dart'; +import '../../../core/utils/app_snackbar.dart'; + +class SubscriptionView extends StatelessWidget { + const SubscriptionView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(SubscriptionController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('الاشتراكات', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } + + return RefreshIndicator( + onRefresh: () async => controller.loadAll(), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current Subscription Status + if (controller.currentSubscription.value != null) + _buildCurrentPlan(controller.currentSubscription.value!, isDark), + + const SizedBox(height: 24), + + // Active Payment Request Banner + if (controller.activePaymentRequest.value != null) + _buildActivePaymentBanner(controller.activePaymentRequest.value!, isDark), + + // Plans Header + Row( + children: [ + const Icon(Icons.diamond_rounded, color: Color(0xFFD4AF37), size: 22), + const SizedBox(width: 8), + Text( + 'اختر باقتك', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF0F172A), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'ادفع عبر CliQ — بدون عمولة!', + style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey), + ), + const SizedBox(height: 16), + + // Plans Grid + ...controller.plans.map((plan) => _buildPlanCard(plan, controller, isDark)), + + const SizedBox(height: 24), + + // Payment History + if (controller.myPayments.isNotEmpty) ...[ + const Text('سجل المدفوعات', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + ...controller.myPayments.map((p) => _buildPaymentHistoryItem(p, isDark)), + ], + + const SizedBox(height: 40), + ], + ), + ), + ); + }), + ); + } + + Widget _buildCurrentPlan(Map sub, bool isDark) { + final planName = sub['plan_name'] ?? sub['plan_id'] ?? 'مجانية'; + final daysLeft = sub['days_remaining'] ?? 0; + final used = sub['invoices_used'] ?? 0; + final limit = sub['invoices_limit'] ?? 0; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0F4C81), Color(0xFF1A6BB5)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.verified, color: Color(0xFFD4AF37), size: 24), + const SizedBox(width: 8), + Text( + 'باقتك الحالية: $planName', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildSubInfoChip(Icons.timer_outlined, '$daysLeft يوم متبقي'), + const SizedBox(width: 12), + _buildSubInfoChip(Icons.receipt_long, '$used/$limit فاتورة'), + ], + ), + ], + ), + ); + } + + Widget _buildSubInfoChip(IconData icon, String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white70, size: 16), + const SizedBox(width: 6), + Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ); + } + + Widget _buildActivePaymentBanner(Map payment, bool isDark) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFED7AA)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.pending_actions, color: Color(0xFFF59E0B), size: 20), + SizedBox(width: 8), + Text('لديك طلب دفع قائم', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF92400E))), + ], + ), + const SizedBox(height: 8), + Text('رقم المرجع: ${payment['reference_number']}', style: const TextStyle(fontFamily: 'monospace', fontSize: 14, fontWeight: FontWeight.bold)), + Text('المبلغ: ${payment['amount_jod']} JOD', style: const TextStyle(fontSize: 13)), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('رفع وصل الدفع'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF59E0B), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () { + // Navigate to receipt upload screen + Get.toNamed('/payment-receipt', arguments: payment); + }, + ), + ), + ], + ), + ); + } + + Widget _buildPlanCard(Map plan, SubscriptionController ctrl, bool isDark) { + final isPopular = plan['is_popular'] == true; + final price = (plan['price_jod'] ?? 0).toString(); + final features = (plan['features'] as List?)?.cast() ?? []; + final nameAr = plan['name_ar'] ?? plan['name_en'] ?? 'باقة'; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isPopular ? const Color(0xFFD4AF37) : (isDark ? Colors.white10 : Colors.grey.shade200), + width: isPopular ? 2 : 1, + ), + ), + child: Column( + children: [ + // Popular Badge + if (isPopular) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: const BoxDecoration( + color: Color(0xFFD4AF37), + borderRadius: BorderRadius.only(topLeft: Radius.circular(14), topRight: Radius.circular(14)), + ), + child: const Center( + child: Text('⭐ الأكثر شعبية', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ), + ), + + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(nameAr, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: isDark ? Colors.white : const Color(0xFF0F172A))), + RichText( + text: TextSpan( + children: [ + TextSpan(text: price, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w900, color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81))), + TextSpan(text: ' JOD', style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + plan['description_ar'] ?? '', + style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey), + ), + const SizedBox(height: 12), + + // Features + ...features.map((f) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 16), + const SizedBox(width: 8), + Expanded(child: Text(f, style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.black87))), + ], + ), + )), + const SizedBox(height: 12), + + // Stats Row + Row( + children: [ + _buildPlanStat(Icons.business, '${plan['max_companies'] ?? 0} شركات'), + const SizedBox(width: 8), + _buildPlanStat(Icons.receipt_long, '${plan['max_invoices_month'] ?? 0} فاتورة/شهر'), + const SizedBox(width: 8), + _buildPlanStat(Icons.people, '${plan['max_users'] ?? 0} مستخدمين'), + ], + ), + const SizedBox(height: 16), + + // Upgrade Button + SizedBox( + width: double.infinity, + height: 48, + child: Obx(() => ElevatedButton( + onPressed: ctrl.isCreatingPayment.value ? null : () async { + final result = await ctrl.createPaymentRequest(plan['id'].toString()); + if (result != null) { + AppSnackbar.showSuccess('تم إنشاء طلب الدفع', 'قم بالتحويل عبر CliQ ثم ارفع وصل الدفع'); + } else { + AppSnackbar.showError('خطأ', 'فشل إنشاء طلب الدفع'); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: isPopular ? const Color(0xFFD4AF37) : const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: ctrl.isCreatingPayment.value + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('ترقية الآن', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + )), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPlanStat(IconData icon, String text) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF0F4C81).withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon(icon, size: 16, color: const Color(0xFF0F4C81)), + const SizedBox(height: 2), + Text(text, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), textAlign: TextAlign.center), + ], + ), + ), + ); + } + + Widget _buildPaymentHistoryItem(Map payment, bool isDark) { + final status = payment['status'] ?? ''; + Color statusColor; + String statusText; + + switch (status) { + case 'approved': statusColor = const Color(0xFF10B981); statusText = 'تم الاعتماد'; break; + case 'pending': statusColor = const Color(0xFFF59E0B); statusText = 'قيد الانتظار'; break; + case 'uploaded': statusColor = const Color(0xFF3B82F6); statusText = 'تحت المراجعة'; break; + case 'rejected': statusColor = const Color(0xFFEF4444); statusText = 'مرفوض'; break; + default: statusColor = Colors.grey; statusText = status; + } + + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: ListTile( + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.payment_rounded, color: statusColor, size: 20), + ), + title: Text(payment['plan_name'] ?? 'باقة', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + subtitle: Text('${payment['amount_jod']} JOD • ${payment['reference_number']}', style: const TextStyle(fontSize: 11, fontFamily: 'monospace')), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text(statusText, style: TextStyle(color: statusColor, fontSize: 11, fontWeight: FontWeight.w600)), + ), + ), + ); + } +} diff --git a/public/index.php b/public/index.php index 3ae96af..dce09c5 100644 --- a/public/index.php +++ b/public/index.php @@ -55,9 +55,19 @@ $routes = [ 'v1/batches/finalize' => ['POST', 'batches/finalize.php'], 'v1/batches/status' => ['GET', 'batches/status.php'], + // Payment System (CliQ-based) + 'v1/payments/create' => ['POST', 'payments/create.php'], + 'v1/payments/verify-reference' => ['POST', 'payments/verify_reference.php'], + 'v1/payments/bot-webhook' => ['POST', 'payments/bot_webhook.php'], + 'v1/payments/list' => ['GET', 'payments/list.php'], + 'v1/payments/my-requests' => ['GET', 'payments/my_requests.php'], + 'v1/payments/review' => ['POST', 'payments/review.php'], + 'v1/payments/stats' => ['GET', 'payments/stats.php'], + // Voice Assistant Proxies - 'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'], - 'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'], + 'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'], + 'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'], + 'v1/voice/parse-intent-grok' => ['POST', 'voice/grok_intent.php'], ]; if (isset($routes[$route])) {