diff --git a/app/Core/AiUsageLogger.php b/app/Core/AiUsageLogger.php new file mode 100644 index 0000000..6c129ce --- /dev/null +++ b/app/Core/AiUsageLogger.php @@ -0,0 +1,102 @@ + [ + 'input' => 0.075, // $0.075 per 1M input tokens + 'output' => 0.30, // $0.30 per 1M output tokens + ], + 'gemini-2.0-flash' => [ + 'input' => 0.10, + 'output' => 0.40, + ], + 'gemini-1.5-pro' => [ + 'input' => 1.25, + 'output' => 5.00, + ], + 'grok-2' => [ + 'input' => 2.00, + 'output' => 10.00, + ], + 'whisper-large-v3' => [ + 'input' => 0.111, // $0.111 per 1M input tokens (Groq) + 'output' => 0.0, + ], + ]; + + /** + * Log an AI usage event. + * + * @param string $tenantId + * @param string $actionType One of: invoice_extraction, voice_transcribe, voice_intent, report_generation, chatbot + * @param string $modelName e.g. gemini-1.5-flash + * @param int $promptTokens + * @param int $completionTokens + * @param string|null $userId + * @param string|null $companyId + * @param array|null $metadata Any extra info (invoice_id, etc.) + */ + public static function log( + string $tenantId, + string $actionType, + string $modelName, + int $promptTokens, + int $completionTokens, + ?string $userId = null, + ?string $companyId = null, + ?array $metadata = null, + ): void { + $totalTokens = $promptTokens + $completionTokens; + $estimatedCost = self::estimateCost($modelName, $promptTokens, $completionTokens); + + try { + $db = Database::getInstance(); + $stmt = $db->prepare( + "INSERT INTO ai_usage_log + (tenant_id, user_id, company_id, action_type, model_name, + prompt_tokens, completion_tokens, total_tokens, estimated_cost, + request_metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())" + ); + $stmt->execute([ + $tenantId, + $userId, + $companyId, + $actionType, + $modelName, + $promptTokens, + $completionTokens, + $totalTokens, + $estimatedCost, + $metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null, + ]); + } catch (\Exception $e) { + // Logging should never break the main flow + error_log('[AiUsageLogger] Failed to log: ' . $e->getMessage()); + } + } + + /** + * Estimate cost in USD based on model pricing. + */ + private static function estimateCost(string $model, int $inputTokens, int $outputTokens): float + { + $pricing = self::MODEL_PRICING[$model] ?? ['input' => 0.10, 'output' => 0.40]; + + $inputCost = ($inputTokens / 1_000_000) * $pricing['input']; + $outputCost = ($outputTokens / 1_000_000) * $pricing['output']; + + return round($inputCost + $outputCost, 6); + } +} diff --git a/app/modules_app/ai-usage/log.php b/app/modules_app/ai-usage/log.php new file mode 100644 index 0000000..a45b2a5 --- /dev/null +++ b/app/modules_app/ai-usage/log.php @@ -0,0 +1,67 @@ +prepare($countSql); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); + +// Fetch +$sql = "SELECT + a.id, a.action_type, a.model_name, + a.prompt_tokens, a.completion_tokens, a.total_tokens, + a.estimated_cost, a.created_at + FROM ai_usage_log a + $tenantCondition + ORDER BY a.created_at DESC + LIMIT $perPage OFFSET $offset"; + +$stmt = $db->prepare($sql); +$stmt->execute($params); +$logs = $stmt->fetchAll(\PDO::FETCH_ASSOC); + +// Translate action types +$actionLabels = [ + 'invoice_extraction' => 'استخراج فاتورة', + 'voice_transcribe' => 'تحويل صوت لنص', + 'voice_intent' => 'تحليل أمر صوتي', + 'report_generation' => 'توليد تقرير', + 'chatbot' => 'محادثة ذكية', +]; + +foreach ($logs as &$log) { + $log['action_label'] = $actionLabels[$log['action_type']] ?? $log['action_type']; + $log['estimated_cost'] = round((float) $log['estimated_cost'], 6); +} + +json_success([ + 'logs' => $logs, + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'pages' => ceil($total / $perPage), + ], +]); diff --git a/app/modules_app/ai-usage/stats.php b/app/modules_app/ai-usage/stats.php new file mode 100644 index 0000000..184eae2 --- /dev/null +++ b/app/modules_app/ai-usage/stats.php @@ -0,0 +1,103 @@ + "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)", + 'week' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)", + 'month' => "AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)", + default => "", +}; + +$tenantCondition = $isSuperAdmin ? "" : "AND a.tenant_id = ?"; +$params = $isSuperAdmin ? [] : [$tenantId]; + +// Totals +$sql = "SELECT + COUNT(*) as total_requests, + COALESCE(SUM(a.prompt_tokens), 0) as total_prompt_tokens, + COALESCE(SUM(a.completion_tokens), 0) as total_completion_tokens, + COALESCE(SUM(a.total_tokens), 0) as total_tokens, + COALESCE(SUM(a.estimated_cost), 0) as total_cost + FROM ai_usage_log a + WHERE 1=1 $tenantCondition $dateCondition"; + +$stmt = $db->prepare($sql); +$stmt->execute($params); +$totals = $stmt->fetch(\PDO::FETCH_ASSOC); + +// Breakdown by action type +$sql2 = "SELECT + a.action_type, + COUNT(*) as requests, + COALESCE(SUM(a.total_tokens), 0) as tokens, + COALESCE(SUM(a.estimated_cost), 0) as cost + FROM ai_usage_log a + WHERE 1=1 $tenantCondition $dateCondition + GROUP BY a.action_type + ORDER BY tokens DESC"; + +$stmt2 = $db->prepare($sql2); +$stmt2->execute($params); +$breakdown = $stmt2->fetchAll(\PDO::FETCH_ASSOC); + +// Breakdown by model +$sql3 = "SELECT + a.model_name, + COUNT(*) as requests, + COALESCE(SUM(a.total_tokens), 0) as tokens, + COALESCE(SUM(a.estimated_cost), 0) as cost + FROM ai_usage_log a + WHERE 1=1 $tenantCondition $dateCondition + GROUP BY a.model_name + ORDER BY tokens DESC"; + +$stmt3 = $db->prepare($sql3); +$stmt3->execute($params); +$modelBreakdown = $stmt3->fetchAll(\PDO::FETCH_ASSOC); + +// Daily trend (last 30 days) +$sql4 = "SELECT + DATE(a.created_at) as date, + COALESCE(SUM(a.total_tokens), 0) as tokens, + COALESCE(SUM(a.estimated_cost), 0) as cost, + COUNT(*) as requests + FROM ai_usage_log a + WHERE 1=1 $tenantCondition AND a.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY DATE(a.created_at) + ORDER BY date ASC"; + +$stmt4 = $db->prepare($sql4); +$stmt4->execute($params); +$dailyTrend = $stmt4->fetchAll(\PDO::FETCH_ASSOC); + +json_success([ + 'period' => $period, + 'totals' => [ + 'requests' => (int) $totals['total_requests'], + 'prompt_tokens' => (int) $totals['total_prompt_tokens'], + 'completion_tokens' => (int) $totals['total_completion_tokens'], + 'total_tokens' => (int) $totals['total_tokens'], + 'estimated_cost_usd' => round((float) $totals['total_cost'], 4), + ], + 'by_action' => $breakdown, + 'by_model' => $modelBreakdown, + 'daily_trend' => $dailyTrend, +]); diff --git a/app/modules_app/invoices/submit_jofotara.php b/app/modules_app/invoices/submit_jofotara.php new file mode 100644 index 0000000..0e936db --- /dev/null +++ b/app/modules_app/invoices/submit_jofotara.php @@ -0,0 +1,162 @@ +prepare($query); +$stmt->execute($params); +$invoice = $stmt->fetch(); + +if (!$invoice) { + json_error('الفاتورة غير موجودة أو ليس لديك صلاحية', 404); +} + +if ($invoice['status'] !== 'approved') { + json_error('يجب اعتماد الفاتورة أولاً قبل إرسالها لجوفتورة', 400); +} + +// 2. Check if already submitted +$stmtCheck = $db->prepare("SELECT id FROM jofotara_submissions WHERE invoice_id = ? AND status = 'accepted'"); +$stmtCheck->execute([$invoiceId]); +if ($stmtCheck->fetch()) { + json_error('تم إرسال هذه الفاتورة لجوفتورة مسبقاً', 400); +} + +// 3. Verify JoFotara credentials +$clientId = $invoice['jofotara_client_id'] ?? ''; +$secretKey = $invoice['jofotara_secret_key'] ?? ''; + +if (empty($clientId) || empty($secretKey)) { + json_error('يجب ربط الشركة بمنظومة جوفتورة أولاً (Client ID + Secret Key)', 422); +} + +// 4. Decrypt sensitive fields for XML generation +$dec = function($val) { + if (empty($val)) return ''; + $result = Encryption::decrypt((string)$val); + return ($result !== false && $result !== null) ? $result : (string)$val; +}; + +// Prepare invoice data for XML +$invoiceData = [ + 'invoice_number' => $invoice['invoice_number'], + 'invoice_date' => $invoice['invoice_date'], + 'invoice_type' => $invoice['invoice_type'], + 'invoice_category' => $invoice['invoice_category'] ?? 'simplified', + 'ubl_type_code' => $invoice['ubl_type_code'] ?? '388', + 'payment_method_code' => $invoice['payment_method_code'] ?? '013', + 'buyer_name' => $dec($invoice['buyer_name']), + 'buyer_tin' => $dec($invoice['buyer_tin']), + 'buyer_national_id' => $dec($invoice['buyer_national_id']), + 'subtotal' => (float)$invoice['subtotal'], + 'tax_amount' => (float)$invoice['tax_amount'], + 'discount_total' => (float)$invoice['discount_total'], + 'grand_total' => (float)$invoice['grand_total'], +]; + +// Fetch line items +$stmtLines = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC"); +$stmtLines->execute([$invoiceId]); +$invoiceData['items'] = $stmtLines->fetchAll(); + +$companyData = [ + 'name' => $invoice['company_name'], + 'tax_identification_number' => $invoice['tax_identification_number'], + 'address' => $invoice['company_address'] ?? '', +]; + +// 5. Generate XML +$jofotara = new JoFotara(); +$xml = $jofotara->generateXML($invoiceData, $companyData); + +// 6. Submit to JoFotara API +$result = $jofotara->submitInvoice($xml, $clientId, $secretKey); + +// 7. Record submission +$submissionId = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); +$stmt = $db->prepare(" + INSERT INTO jofotara_submissions (id, invoice_id, tenant_id, company_id, jofotara_uuid, xml_content, status, qr_code_raw, response_body, submitted_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) +"); + +$stmt->execute([ + $submissionId, + $invoiceId, + $tenantId, + $invoice['company_id'], + $result['uuid'] ?? null, + $xml, + $result['success'] ? 'accepted' : 'rejected', + $result['qrCode'] ?? null, + json_encode($result['raw'] ?? [], JSON_UNESCAPED_UNICODE), +]); + +// 8. Update invoice status if accepted +if ($result['success']) { + $db->prepare("UPDATE invoices SET status = 'submitted', jofotara_uuid = ? WHERE id = ?") + ->execute([$result['uuid'], $invoiceId]); + + // Generate local QR code + $qrBase64 = $jofotara->generateQRCode([ + 'supplier_name' => $companyData['name'], + 'supplier_tin' => $companyData['tax_identification_number'], + 'invoice_date' => $invoiceData['invoice_date'], + 'grand_total' => $invoiceData['grand_total'], + 'tax_amount' => $invoiceData['tax_amount'], + ]); + + $db->prepare("UPDATE invoices SET qr_code = ? WHERE id = ?")->execute([$qrBase64, $invoiceId]); + + AuditLogger::log('invoice.submitted_jofotara', 'invoice', $invoiceId, null, [ + 'jofotara_uuid' => $result['uuid'], + ], $decoded); + + json_success([ + 'uuid' => $result['uuid'], + 'qr_code' => $qrBase64, + 'status' => 'accepted', + ], 'تم إرسال الفاتورة لجوفتورة بنجاح'); +} else { + AuditLogger::log('invoice.jofotara_rejected', 'invoice', $invoiceId, null, [ + 'error' => $result['error'] ?? 'Unknown', + ], $decoded); + + json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422); +} diff --git a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift index 18c4a09..5ac729a 100644 --- a/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift +++ b/musadaq-app/ios/MusadaqLiveActivity/MusadaqLiveActivityBundle.swift @@ -23,12 +23,12 @@ struct InvoiceBatchAttributes: ActivityAttributes { @main struct MusadaqLiveActivityBundle: WidgetBundle { var body: some Widget { - MusadaqLiveActivityLiveActivity() + InvoiceBatchLiveActivity() } } // ─── 3. Widget ─────────────────────────────────────── -struct MusadaqLiveActivityLiveActivity: Widget { +struct InvoiceBatchLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: InvoiceBatchAttributes.self) { context in // Lock Screen UI diff --git a/musadaq-app/ios/Podfile b/musadaq-app/ios/Podfile index 69011b4..ab0f6d2 100644 --- a/musadaq-app/ios/Podfile +++ b/musadaq-app/ios/Podfile @@ -39,5 +39,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0' + end end end diff --git a/musadaq-app/ios/Podfile.lock b/musadaq-app/ios/Podfile.lock index 6af29b7..5f7cbab 100644 --- a/musadaq-app/ios/Podfile.lock +++ b/musadaq-app/ios/Podfile.lock @@ -117,6 +117,8 @@ PODS: - printing (1.0.0): - Flutter - PromisesObjC (2.4.0) + - record_ios (1.2.0): + - Flutter - SDWebImage (5.21.7): - SDWebImage/Core (= 5.21.7) - SDWebImage/Core (5.21.7) @@ -148,6 +150,7 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - printing (from `.symlinks/plugins/printing/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -203,6 +206,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" printing: :path: ".symlinks/plugins/printing/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" speech_to_text: :path: ".symlinks/plugins/speech_to_text/darwin" sqflite_darwin: @@ -239,11 +244,12 @@ SPEC CHECKSUMS: permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 -PODFILE CHECKSUM: 912e34f102da91123e38965ab4e5b3fb21e195f0 +PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079 COCOAPODS: 1.16.2 diff --git a/musadaq-app/ios/Runner.xcodeproj/project.pbxproj b/musadaq-app/ios/Runner.xcodeproj/project.pbxproj index 7a136c7..15eeef9 100644 --- a/musadaq-app/ios/Runner.xcodeproj/project.pbxproj +++ b/musadaq-app/ios/Runner.xcodeproj/project.pbxproj @@ -3,20 +3,20 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 06190262057E8ECBFBA83901 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07031741F165E91648B40191 /* Pods_RunnerTests.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 53140AEC3D931B044D6ACFE2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F124508D0E246590A34406 /* Pods_RunnerTests.framework */; }; + 6CEF46C20D00416BACD93D17 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D025DDA48B1D85CD96612EF0 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 84FA7226BA669CC504D14C8E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 01F4F223F169A9E6C26FA35C /* GoogleService-Info.plist */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - BF0A71587203972CC86D9A9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D6221B80BFD02DE32675609 /* Pods_Runner.framework */; }; C68ADD2A2FACB4B8000DB48F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C68ADD292FACB4B8000DB48F /* WidgetKit.framework */; }; C68ADD2C2FACB4B8000DB48F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C68ADD2B2FACB4B8000DB48F /* SwiftUI.framework */; }; C68ADD3B2FACB4BA000DB48F /* MusadaqLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C68ADD282FACB4B8000DB48F /* MusadaqLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -65,19 +65,18 @@ /* Begin PBXFileReference section */ 01F4F223F169A9E6C26FA35C /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 07031741F165E91648B40191 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0ABD12806655BCEC708686C7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 4CDBE737BB43361B59042085 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 4FA12020E1E597AE113FF074 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 617832E77456CC2A5A40C0CD /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3DD1CA7ECEC28ABEC7C14878 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 6F6A5F2CEFCF737EF3CF46C7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7769E470AB827B63F2DD41A0 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7D6221B80BFD02DE32675609 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -85,18 +84,19 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A516B9E15A13BFDC50FA3878 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - B5F124508D0E246590A34406 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C1D35ECD47C3CDCBD2C89CB0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9BEDA09D5C3C2E33EAFCD635 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B9049223F8847739CB089DF5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + BC02333A746194D6EE07526B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; C68ADD282FACB4B8000DB48F /* MusadaqLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MusadaqLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; C68ADD292FACB4B8000DB48F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; C68ADD2B2FACB4B8000DB48F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; C68ADD422FACB594000DB48F /* MusadaqLiveActivityExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MusadaqLiveActivityExtension.entitlements; sourceTree = ""; }; C68ADD432FACBB13000DB48F /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + D025DDA48B1D85CD96612EF0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - C68ADD402FACB4BA000DB48F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + C68ADD402FACB4BA000DB48F /* Exceptions for "MusadaqLiveActivity" folder in "MusadaqLiveActivityExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -106,7 +106,18 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - C68ADD2D2FACB4B8000DB48F /* MusadaqLiveActivity */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C68ADD402FACB4BA000DB48F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MusadaqLiveActivity; sourceTree = ""; }; + C68ADD2D2FACB4B8000DB48F /* MusadaqLiveActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C68ADD402FACB4BA000DB48F /* Exceptions for "MusadaqLiveActivity" folder in "MusadaqLiveActivityExtension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = MusadaqLiveActivity; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -114,7 +125,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 53140AEC3D931B044D6ACFE2 /* Pods_RunnerTests.framework in Frameworks */, + 06190262057E8ECBFBA83901 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -122,7 +133,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BF0A71587203972CC86D9A9D /* Pods_Runner.framework in Frameworks */, + 6CEF46C20D00416BACD93D17 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -149,12 +160,12 @@ 663DB5D6B423F3C47905DB90 /* Pods */ = { isa = PBXGroup; children = ( - 617832E77456CC2A5A40C0CD /* Pods-Runner.debug.xcconfig */, - 4FA12020E1E597AE113FF074 /* Pods-Runner.release.xcconfig */, - C1D35ECD47C3CDCBD2C89CB0 /* Pods-Runner.profile.xcconfig */, - 7769E470AB827B63F2DD41A0 /* Pods-RunnerTests.debug.xcconfig */, - 4CDBE737BB43361B59042085 /* Pods-RunnerTests.release.xcconfig */, - A516B9E15A13BFDC50FA3878 /* Pods-RunnerTests.profile.xcconfig */, + 9BEDA09D5C3C2E33EAFCD635 /* Pods-Runner.debug.xcconfig */, + 6F6A5F2CEFCF737EF3CF46C7 /* Pods-Runner.release.xcconfig */, + BC02333A746194D6EE07526B /* Pods-Runner.profile.xcconfig */, + 3DD1CA7ECEC28ABEC7C14878 /* Pods-RunnerTests.debug.xcconfig */, + B9049223F8847739CB089DF5 /* Pods-RunnerTests.release.xcconfig */, + 0ABD12806655BCEC708686C7 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -214,10 +225,10 @@ A0A15A5CB3A32757C542E6EB /* Frameworks */ = { isa = PBXGroup; children = ( - 7D6221B80BFD02DE32675609 /* Pods_Runner.framework */, - B5F124508D0E246590A34406 /* Pods_RunnerTests.framework */, C68ADD292FACB4B8000DB48F /* WidgetKit.framework */, C68ADD2B2FACB4B8000DB48F /* SwiftUI.framework */, + D025DDA48B1D85CD96612EF0 /* Pods_Runner.framework */, + 07031741F165E91648B40191 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -229,7 +240,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 13D7BEA4BA840E67CFF7A502 /* [CP] Check Pods Manifest.lock */, + C859431485B2ED96DB37958C /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 61D890350FB4633E292F3803 /* Frameworks */, @@ -248,7 +259,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - B2E217D08F3FC41C4F11F08D /* [CP] Check Pods Manifest.lock */, + E002362A9716695E99F74570 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -256,8 +267,8 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, C68ADD3C2FACB4BA000DB48F /* Embed Foundation Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - E56C32186B9FB7A499F5B38F /* [CP] Embed Pods Frameworks */, - B23DF4822D0EB618AED457C9 /* [CP] Copy Pods Resources */, + DBDA0220C1C000C3D25A56DA /* [CP] Embed Pods Frameworks */, + 9B6FB566A29A41A7B4CFDE93 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -285,8 +296,6 @@ C68ADD2D2FACB4B8000DB48F /* MusadaqLiveActivity */, ); name = MusadaqLiveActivityExtension; - packageProductDependencies = ( - ); productName = MusadaqLiveActivityExtension; productReference = C68ADD282FACB4B8000DB48F /* MusadaqLiveActivityExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -365,28 +374,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 13D7BEA4BA840E67CFF7A502 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -418,7 +405,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - B23DF4822D0EB618AED457C9 /* [CP] Copy Pods Resources */ = { + 9B6FB566A29A41A7B4CFDE93 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -426,20 +413,55 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - B2E217D08F3FC41C4F11F08D /* [CP] Check Pods Manifest.lock */ = { + C859431485B2ED96DB37958C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DBDA0220C1C000C3D25A56DA /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + E002362A9716695E99F74570 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -461,27 +483,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - E56C32186B9FB7A499F5B38F /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -587,7 +588,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -614,6 +615,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.musadaq.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; @@ -621,7 +623,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7769E470AB827B63F2DD41A0 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 3DD1CA7ECEC28ABEC7C14878 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -639,7 +641,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4CDBE737BB43361B59042085 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = B9049223F8847739CB089DF5 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -655,7 +657,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A516B9E15A13BFDC50FA3878 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 0ABD12806655BCEC708686C7 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -718,7 +720,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -769,7 +771,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -799,6 +801,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; @@ -822,6 +825,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.musadaq.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; diff --git a/musadaq-app/ios/Runner/AppDelegate.swift b/musadaq-app/ios/Runner/AppDelegate.swift index c30b367..6266644 100644 --- a/musadaq-app/ios/Runner/AppDelegate.swift +++ b/musadaq-app/ios/Runner/AppDelegate.swift @@ -2,15 +2,12 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } } diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index f62517c..4c747c5 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -18,6 +18,7 @@ import '../../features/onboarding/views/onboarding_view.dart'; import '../../features/onboarding/controllers/onboarding_controller.dart'; import '../../core/storage/secure_storage.dart'; import '../../features/companies/views/companies_management_view.dart'; + import '../../features/companies/views/company_stats_view.dart'; import '../../features/users/views/users_management_view.dart'; import '../../features/tenants/views/tenants_management_view.dart'; @@ -138,6 +139,10 @@ class AppPages { name: AppRoutes.COMPANIES_MANAGEMENT, page: () => const CompaniesManagementView(), ), + GetPage( + name: AppRoutes.COMPANY_STATS, + page: () => const CompanyStatsView(), + ), GetPage( name: AppRoutes.TENANTS_MANAGEMENT, page: () => const TenantsManagementView(), diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index e134396..b5baa18 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -18,6 +18,7 @@ abstract class AppRoutes { static const INVOICE_DETAIL = '/invoice-detail'; static const ONBOARDING = '/onboarding'; static const COMPANIES_MANAGEMENT = '/companies-management'; + static const COMPANY_STATS = '/company-stats'; static const TENANTS_MANAGEMENT = '/tenants-management'; static const USERS_MANAGEMENT = '/users-management'; } diff --git a/musadaq-app/lib/features/companies/controllers/company_stats_controller.dart b/musadaq-app/lib/features/companies/controllers/company_stats_controller.dart new file mode 100644 index 0000000..eb5b0c2 --- /dev/null +++ b/musadaq-app/lib/features/companies/controllers/company_stats_controller.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/logger.dart'; +import '../../../core/utils/app_snackbar.dart'; + +class CompanyStatsController extends GetxController { + final Dio _dio = DioClient().client; + + var isLoading = false.obs; + var company = {}.obs; + var totals = {}.obs; + var monthly = >[].obs; + + final String companyId; + final String companyName; + + CompanyStatsController({required this.companyId, required this.companyName}); + + @override + void onInit() { + super.onInit(); + fetchStats(); + } + + Future fetchStats() async { + try { + isLoading.value = true; + final response = await _dio.get('companies/stats?company_id=$companyId'); + if (response.data['success'] == true) { + final data = response.data['data']; + company.value = Map.from(data['company'] ?? {}); + totals.value = Map.from(data['totals'] ?? {}); + monthly.value = List>.from(data['monthly'] ?? []); + } + } catch (e) { + AppLogger.error('Company stats error', e); + AppSnackbar.showError('خطأ', 'فشل تحميل الإحصائيات'); + } finally { + isLoading.value = false; + } + } +} diff --git a/musadaq-app/lib/features/companies/views/company_stats_view.dart b/musadaq-app/lib/features/companies/views/company_stats_view.dart new file mode 100644 index 0000000..fc65add --- /dev/null +++ b/musadaq-app/lib/features/companies/views/company_stats_view.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/company_stats_controller.dart'; + +class CompanyStatsView extends StatelessWidget { + const CompanyStatsView({super.key}); + + @override + Widget build(BuildContext context) { + final args = Get.arguments as Map; + final controller = Get.put( + CompanyStatsController( + companyId: args['company_id'], + companyName: args['company_name'], + ), + tag: args['company_id'], + ); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: Text( + 'إحصائيات ${args['company_name']}', + style: const TextStyle(fontFamily: 'El Messiri', fontSize: 16), + overflow: TextOverflow.ellipsis, + ), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + actions: [ + Obx(() => controller.isLoading.value + ? const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.refresh), + onPressed: controller.fetchStats, + )), + ], + ), + body: Obx(() { + if (controller.isLoading.value && controller.totals.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.totals.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bar_chart_outlined, + size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text('لا توجد بيانات بعد', + style: TextStyle(fontSize: 18, color: Colors.grey)), + ], + ), + ); + } + + final totals = controller.totals; + final monthly = controller.monthly; + + return RefreshIndicator( + onRefresh: controller.fetchStats, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Lifetime Totals ────────────────────────────── + _sectionTitle('الإحصائيات الإجمالية', isDark), + const SizedBox(height: 12), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.6, + children: [ + _statCard( + icon: Icons.receipt_long_rounded, + label: 'إجمالي الفواتير', + value: _fmt(totals['total_invoices']), + color: const Color(0xFF0F4C81), + isDark: isDark, + ), + _statCard( + icon: Icons.check_circle_rounded, + label: 'فواتير معتمدة', + value: _fmt(totals['approved_count']), + color: const Color(0xFF10B981), + isDark: isDark, + ), + _statCard( + icon: Icons.attach_money_rounded, + label: 'إجمالي المبيعات', + value: '${_fmtAmount(totals['total_amount'])} د.أ', + color: const Color(0xFFD4AF37), + isDark: isDark, + ), + _statCard( + icon: Icons.percent_rounded, + label: 'إجمالي الضريبة', + value: '${_fmtAmount(totals['total_tax'])} د.أ', + color: const Color(0xFF8B5CF6), + isDark: isDark, + ), + ], + ), + + // Approval Rate + const SizedBox(height: 20), + _buildApprovalRateCard(totals, isDark), + + // ── Monthly Breakdown ──────────────────────────── + if (monthly.isNotEmpty) ...[ + const SizedBox(height: 24), + _sectionTitle('التفاصيل الشهرية (آخر 12 شهر)', isDark), + const SizedBox(height: 12), + ...monthly.map((m) => _buildMonthRow(m, isDark)), + ], + ], + ), + ); + }), + ); + } + + Widget _sectionTitle(String title, bool isDark) { + return Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: const Color(0xFF0F4C81), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ); + } + + Widget _statCard({ + required IconData icon, + required String label, + required String value, + required Color color, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: + Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + boxShadow: isDark + ? [] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2)) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(width: 8), + Expanded( + child: Text(label, + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.white54 : Colors.grey.shade600), + overflow: TextOverflow.ellipsis), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildApprovalRateCard(Map totals, bool isDark) { + final total = + int.tryParse(totals['total_invoices']?.toString() ?? '0') ?? 0; + final approved = + int.tryParse(totals['approved_count']?.toString() ?? '0') ?? 0; + final rate = total > 0 ? (approved / total) : 0.0; + + return Container( + padding: const EdgeInsets.all(16), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('نسبة القبول', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text( + '${(rate * 100).toStringAsFixed(1)}%', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: rate > 0.8 + ? const Color(0xFF10B981) + : rate > 0.5 + ? const Color(0xFFD4AF37) + : const Color(0xFFDC2626), + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: rate, + minHeight: 8, + color: rate > 0.8 + ? const Color(0xFF10B981) + : rate > 0.5 + ? const Color(0xFFD4AF37) + : const Color(0xFFDC2626), + backgroundColor: + isDark ? Colors.white10 : const Color(0xFFE2E8F0), + ), + ), + const SizedBox(height: 8), + Text( + '$approved معتمدة من أصل $total فاتورة', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildMonthRow(Map m, bool isDark) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Row( + children: [ + // Month label + Container( + width: 56, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + decoration: BoxDecoration( + color: const Color(0xFF0F4C81).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _formatMonth(m['month']?.toString() ?? ''), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFF0F4C81), + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // Stats + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long_rounded, + size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text('${_fmt(m['total_invoices'])} فاتورة', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + const Icon(Icons.check_circle_outline, + size: 14, color: Color(0xFF10B981)), + const SizedBox(width: 4), + Text('${_fmt(m['approved_count'])} معتمدة', + style: const TextStyle( + fontSize: 12, color: Color(0xFF10B981))), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '${_fmtAmount(m['total_amount'])} د.أ مبيعات', + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white70 : Colors.black87), + ), + const SizedBox(width: 12), + Text( + '${_fmtAmount(m['total_tax'])} د.أ ضريبة', + style: const TextStyle( + fontSize: 12, color: Color(0xFF8B5CF6)), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + String _fmt(dynamic v) { + if (v == null) return '0'; + return int.tryParse(v.toString())?.toString() ?? v.toString(); + } + + String _fmtAmount(dynamic v) { + if (v == null) return '0.000'; + final d = double.tryParse(v.toString()) ?? 0; + return d.toStringAsFixed(3); + } + + String _formatMonth(String m) { + if (m.isEmpty) return m; + final parts = m.split('-'); + if (parts.length < 2) return m; + final months = [ + '', + 'يناير', + 'فبراير', + 'مارس', + 'أبريل', + 'مايو', + 'يونيو', + 'يوليو', + 'أغسطس', + 'سبتمبر', + 'أكتوبر', + 'نوفمبر', + 'ديسمبر' + ]; + final monthIndex = int.tryParse(parts[1]) ?? 0; + final monthName = monthIndex > 0 && monthIndex < months.length + ? months[monthIndex] + : parts[1]; + return '$monthName\n${parts[0]}'; + } +} diff --git a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart index d4f984f..6bf9ca3 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -89,4 +89,48 @@ class InvoiceDetailController extends GetxController { AppSnackbar.showWarning('عذراً', 'لا توجد صورة مرتبطة بهذه الفاتورة'); } } + + Future submitToJoFotara() async { + // Confirmation dialog + final confirmed = await Get.dialog( + AlertDialog( + title: const Text('تأكيد الإرسال'), + content: const Text( + 'هل أنت متأكد من إرسال هذه الفاتورة لمنظومة جوفتورة؟\nلا يمكن التراجع عن هذا الإجراء.'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('إلغاء'), + ), + ElevatedButton( + onPressed: () => Get.back(result: true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + ), + child: const Text('إرسال', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + AppSnackbar.showInfo('جاري الإرسال', 'يتم إرسال الفاتورة لمنظومة جوفتورة...'); + final res = await DioClient().client.post( + 'invoices/submit-jofotara', + data: {'invoice_id': invoiceId}, + ); + + if (res.data['success'] == true) { + AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح'); + fetchInvoiceDetails(); // Refresh to show JoFotara status + } else { + AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال'); + } + } catch (e) { + AppLogger.error('Failed to submit to JoFotara', e); + AppSnackbar.showError('خطأ', 'فشل إرسال الفاتورة لجوفتورة'); + } + } } diff --git a/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart index 996bc0e..1fcceac 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart @@ -80,6 +80,7 @@ class InvoicesController extends GetxController { 'approved', 'extracted', 'uploaded', + 'submitted', 'rejected', }; diff --git a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart index 85c72eb..fdbd7b7 100644 --- a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart @@ -17,6 +17,12 @@ class InvoiceDetailView extends StatelessWidget { backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), foregroundColor: Colors.white, elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.fetchInvoiceDetails(), + ), + ], ), body: Obx(() { if (controller.isLoading.value) { @@ -29,103 +35,49 @@ class InvoiceDetailView extends StatelessWidget { final inv = controller.invoice; final status = inv['status'] ?? 'pending'; + final items = (inv['items'] as List?) ?? []; + final warnings = (inv['validation_warnings'] as List?) ?? []; + final jofotara = inv['jofotara']; 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), - ], - ), - ), + // ─── Header Card ─── + _buildHeaderCard(inv, status, isDark), 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), - ], - ), - ), + // ─── AI Warnings ─── + if (warnings.isNotEmpty) ...[ + _buildWarningsCard(warnings, isDark), + const SizedBox(height: 16), + ], + + // ─── JoFotara Status ─── + if (jofotara != null) ...[ + _buildJoFotaraCard(jofotara, isDark), + const SizedBox(height: 16), + ], + + // ─── Supplier & Buyer ─── + _buildPartiesCard(inv, 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), - ], - ), - ), + // ─── Invoice Lines ─── + if (items.isNotEmpty) ...[ + _buildLinesCard(items, isDark), + const SizedBox(height: 16), + ], + + // ─── Amounts Card ─── + _buildAmountsCard(inv, isDark), const SizedBox(height: 32), - // Action Buttons + // ─── Action Buttons ─── if (status == 'extracted') ...[ SizedBox( height: 52, @@ -156,6 +108,24 @@ class InvoiceDetailView extends StatelessWidget { label: const Text('عرض صورة الفاتورة الأصلية', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ), ), + + if (jofotara == null && status == 'approved') ...[ + const SizedBox(height: 12), + SizedBox( + height: 52, + child: ElevatedButton.icon( + onPressed: () => controller.submitToJoFotara(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.send_rounded), + label: const Text('إرسال لجوفتورة', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ), + ], + const SizedBox(height: 40), ], ), @@ -164,24 +134,391 @@ class InvoiceDetailView extends StatelessWidget { ); } + // ───────────────────────────────────────────── + // Header Card + // ───────────────────────────────────────────── + Widget _buildHeaderCard(Map inv, String status, bool isDark) { + return 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: 4), + Text( + 'فاتورة ضريبية ${inv['invoice_type'] == 'credit' ? '(آجل)' : '(نقدية)'}', + style: TextStyle(color: isDark ? Colors.white70 : Colors.grey.shade600, fontSize: 13), + ), + const SizedBox(height: 16), + Text( + '${_formatAmount(inv['grand_total'])} JOD', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w900, + color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81), + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStatusChip(status), + if (inv['ai_confidence'] != null) ...[ + const SizedBox(width: 8), + _buildConfidenceChip(inv['ai_confidence'], isDark), + ], + ], + ), + ], + ), + ); + } + + // ───────────────────────────────────────────── + // AI Validation Warnings + // ───────────────────────────────────────────── + Widget _buildWarningsCard(List warnings, bool isDark) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2D1F00) : const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFF59E0B).withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFF59E0B), size: 20), + const SizedBox(width: 8), + Text( + 'تحذيرات التدقيق الآلي (${warnings.length})', + style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFF59E0B)), + ), + ], + ), + const SizedBox(height: 12), + ...warnings.map((w) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('⚠️ ', style: TextStyle(fontSize: 12)), + Expanded( + child: Text( + w.toString(), + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ), + )), + ], + ), + ); + } + + // ───────────────────────────────────────────── + // JoFotara Status Card + // ───────────────────────────────────────────── + Widget _buildJoFotaraCard(Map jofotara, bool isDark) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF0A2010) : const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.verified, color: Color(0xFF10B981), size: 20), + const SizedBox(width: 8), + const Text( + 'مُقدَّمة لجوفتورة ✓', + style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF10B981)), + ), + ], + ), + const SizedBox(height: 12), + _buildMiniRow('UUID', jofotara['uuid'] ?? '—', isDark), + const SizedBox(height: 6), + _buildMiniRow('تاريخ التقديم', jofotara['submitted_at'] ?? '—', isDark), + if (jofotara['qr_image_uri'] != null) ...[ + const SizedBox(height: 12), + Center( + child: Image.network( + jofotara['qr_image_uri'], + width: 140, + height: 140, + errorBuilder: (_, __, ___) => const SizedBox(), + ), + ), + ], + ], + ), + ); + } + + // ───────────────────────────────────────────── + // Supplier & Buyer Card + // ───────────────────────────────────────────── + Widget _buildPartiesCard(Map inv, bool isDark) { + return 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['supplier_name'] ?? '—', isDark), + const Divider(height: 24), + _buildInfoRow('ض.م. المورّد', inv['supplier_tin'] ?? '—', isDark), + if ((inv['buyer_name'] ?? '').toString().isNotEmpty) ...[ + const Divider(height: 24), + _buildInfoRow('العميل', inv['buyer_name'] ?? '—', isDark), + ], + if ((inv['buyer_tin'] ?? '').toString().isNotEmpty) ...[ + const Divider(height: 24), + _buildInfoRow('ض.م. العميل', inv['buyer_tin'] ?? '—', isDark), + ], + ], + ), + ); + } + + // ───────────────────────────────────────────── + // Invoice Lines Card + // ───────────────────────────────────────────── + Widget _buildLinesCard(List items, bool isDark) { + return 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: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('بنود الفاتورة', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text( + '${items.length} بنود', + style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 13), + ), + ], + ), + const SizedBox(height: 16), + // Table header + Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.05) : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const SizedBox(width: 30, child: Text('#', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))), + const Expanded(child: Text('الوصف', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))), + const SizedBox(width: 40, child: Text('كمية', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), textAlign: TextAlign.center)), + const SizedBox(width: 60, child: Text('سعر', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), textAlign: TextAlign.center)), + const SizedBox(width: 35, child: Text('ض%', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), textAlign: TextAlign.center)), + const SizedBox(width: 65, child: Text('إجمالي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), textAlign: TextAlign.end)), + ], + ), + ), + const SizedBox(height: 4), + // Table rows + ...items.asMap().entries.map((entry) { + final item = entry.value; + final isLast = entry.key == items.length - 1; + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + border: isLast ? null : Border(bottom: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 30, + child: Text( + (item['line_number'] ?? entry.key + 1).toString(), + style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey), + ), + ), + Expanded( + child: Text( + item['description'] ?? '', + style: const TextStyle(fontSize: 13), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 40, + child: Text( + _fmtNum(item['quantity']), + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: 60, + child: Text( + _fmtNum(item['unit_price']), + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: 35, + child: Text( + '${((double.tryParse(item['tax_rate']?.toString() ?? '0') ?? 0) * 100).toStringAsFixed(0)}%', + style: TextStyle(fontSize: 11, color: isDark ? Colors.white54 : Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: 65, + child: Text( + _fmtNum(item['line_total']), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, fontFamily: 'monospace'), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } + + // ───────────────────────────────────────────── + // Amounts Card + // ───────────────────────────────────────────── + Widget _buildAmountsCard(Map inv, bool isDark) { + return 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('المبلغ الخاضع', '${_formatAmount(inv['subtotal'])} JOD', isDark), + if ((double.tryParse(inv['discount_total']?.toString() ?? '0') ?? 0) > 0) ...[ + const Divider(height: 24), + _buildInfoRow('الخصم', '${_formatAmount(inv['discount_total'])} JOD', isDark), + ], + const Divider(height: 24), + _buildInfoRow('قيمة الضريبة', '${_formatAmount(inv['tax_amount'])} JOD', isDark), + const Divider(height: 24), + _buildInfoRow('الإجمالي النهائي', '${_formatAmount(inv['grand_total'])} JOD', isDark, isBold: true), + ], + ), + ); + } + + // ───────────────────────────────────────────── + // Helper Widgets + // ───────────────────────────────────────────── 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 = 'قيد المعالجة'; + case 'approved': + color = const Color(0xFF10B981); + text = '✓ معتمدة'; + break; + case 'extracted': + color = const Color(0xFF3B82F6); + text = 'جاهزة للتدقيق'; + break; + case 'submitted': + color = const Color(0xFF6366F1); + text = 'مُقدَّمة لجوفتورة'; + break; + default: + color = const Color(0xFFF59E0B); + text = 'قيد المعالجة'; } return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), - border: Border.all(color: color.withOpacity(0.2)), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12)), + ); + } + + Widget _buildConfidenceChip(dynamic confidence, bool isDark) { + final score = (double.tryParse(confidence.toString()) ?? 0) * 100; + final color = score >= 90 + ? const Color(0xFF10B981) + : score >= 70 + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_awesome, size: 12, color: color), + const SizedBox(width: 4), + Text( + 'AI ${score.toStringAsFixed(0)}%', + style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 11, fontFamily: 'monospace'), + ), + ], ), - child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)), ); } @@ -190,16 +527,45 @@ class InvoiceDetailView extends StatelessWidget { 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, + Flexible( + child: 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, + ), + textAlign: TextAlign.end, ), ), ], ); } + + Widget _buildMiniRow(String label, String value, bool isDark) { + return Row( + children: [ + Text('$label: ', style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.grey)), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + String _formatAmount(dynamic value) { + final num = double.tryParse(value?.toString() ?? '0') ?? 0; + return num.toStringAsFixed(3); + } + + String _fmtNum(dynamic value) { + final num = double.tryParse(value?.toString() ?? '0') ?? 0; + if (num == num.truncateToDouble()) return num.toStringAsFixed(0); + return num.toStringAsFixed(3); + } } diff --git a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart index 4e7b176..d2e24b9 100644 --- a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart @@ -76,6 +76,7 @@ class InvoicesListView extends GetView { _buildFilterChip('قيد المعالجة', 'uploaded', controller, isDark), _buildFilterChip('جاهزة', 'extracted', controller, isDark), _buildFilterChip('معتمدة', 'approved', controller, isDark), + _buildFilterChip('مُقدَّمة', 'submitted', controller, isDark), ], ), ), @@ -147,6 +148,11 @@ class InvoicesListView extends GetView { statusText = 'جاهزة للتدقيق'; statusIcon = Icons.pending_actions; break; + case 'submitted': + statusColor = const Color(0xFF6366F1); + statusText = 'مُقدَّمة لجوفتورة'; + statusIcon = Icons.verified; + break; default: statusColor = const Color(0xFFF59E0B); statusText = 'قيد المعالجة'; @@ -174,7 +180,7 @@ class InvoicesListView extends GetView { width: 48, height: 48, decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), + color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(statusIcon, color: statusColor, size: 24), @@ -222,7 +228,7 @@ class InvoicesListView extends GetView { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), + color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -272,7 +278,7 @@ class InvoicesListView extends GetView { height: 80, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), + color: Colors.grey.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14), ), ), diff --git a/musadaq-app/lib/main.dart b/musadaq-app/lib/main.dart index e43a37f..0f594fa 100644 --- a/musadaq-app/lib/main.dart +++ b/musadaq-app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:firebase_core/firebase_core.dart'; import 'app/routes/app_pages.dart'; import 'core/services/push_notification_service.dart'; +import 'core/services/upload_progress_service.dart'; import 'app/theme/app_theme.dart'; @pragma('vm:entry-point') @@ -22,6 +23,9 @@ void main() async { // 2. Register background handler FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + // 3. Register global services + Get.put(UploadProgressService(), permanent: true); + runApp(const MusadaqApp()); } diff --git a/musadaq-app/pubspec.lock b/musadaq-app/pubspec.lock index fcefea5..a51dec6 100644 --- a/musadaq-app/pubspec.lock +++ b/musadaq-app/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -748,26 +748,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -836,18 +836,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" matrix2d: dependency: transitive description: @@ -860,10 +860,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1385,10 +1385,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.9" timing: dependency: transitive description: @@ -1417,10 +1417,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1502,5 +1502,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/musadaq-app/pubspec.yaml b/musadaq-app/pubspec.yaml index 1974c32..f5b06a2 100644 --- a/musadaq-app/pubspec.yaml +++ b/musadaq-app/pubspec.yaml @@ -83,6 +83,7 @@ flutter_launcher_icons: flutter: uses-material-design: true assets: + - assets/images/logo.jpg - assets/images/onboarding_1.png - assets/images/onboarding_2.png - assets/images/onboarding_3.png diff --git a/public/index.php b/public/index.php index dce09c5..7580e99 100644 --- a/public/index.php +++ b/public/index.php @@ -31,6 +31,7 @@ $routes = [ 'v1/invoices/approve' => ['POST', 'invoices/approve.php'], 'v1/invoices/upload' => ['POST', 'invoices/upload.php'], 'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'], + 'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.php'], 'v1/companies/stats' => ['GET', 'companies/stats.php'], 'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], diff --git a/scripts/complete_migration.sql b/scripts/complete_migration.sql new file mode 100644 index 0000000..3e09264 --- /dev/null +++ b/scripts/complete_migration.sql @@ -0,0 +1,87 @@ +-- ════════════════════════════════════════════════════════════ +-- مُصادَق — Comprehensive Phase 1 Migration +-- Tables for AI Extraction, JoFotara Integration, and Usage Logs +-- ════════════════════════════════════════════════════════════ + +-- 1. Invoice Line Items (For AI extracted data) +CREATE TABLE IF NOT EXISTS invoice_lines ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + invoice_id CHAR(36) NOT NULL, + line_number INT NOT NULL, + description VARCHAR(255) NOT NULL, + quantity DECIMAL(10,3) DEFAULT 1, + unit_price DECIMAL(15,4) NOT NULL, + tax_rate DECIMAL(5,2) DEFAULT 16.00, + tax_amount DECIMAL(15,4) DEFAULT 0, + discount DECIMAL(15,4) DEFAULT 0, + total_amount DECIMAL(15,4) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_invoice (invoice_id), + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. JoFotara Submissions Log +CREATE TABLE IF NOT EXISTS jofotara_submissions ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + invoice_id CHAR(36) NOT NULL, + tenant_id CHAR(36) NOT NULL, + company_id CHAR(36) NOT NULL, + jofotara_uuid VARCHAR(100) NULL, + xml_content LONGTEXT NULL, + status ENUM('accepted', 'rejected', 'pending') DEFAULT 'pending', + qr_code_raw TEXT NULL, -- Base64 QR from JoFotara + response_body JSON NULL, + submitted_at DATETIME NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_invoice (invoice_id), + INDEX idx_tenant (tenant_id), + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. Update Companies for JoFotara Credentials +ALTER TABLE companies +ADD COLUMN IF NOT EXISTS jofotara_client_id VARCHAR(255) NULL AFTER tax_identification_number, +ADD COLUMN IF NOT EXISTS jofotara_secret_key VARCHAR(255) NULL AFTER jofotara_client_id, +ADD COLUMN IF NOT EXISTS jofotara_status ENUM('active', 'inactive', 'pending') DEFAULT 'inactive' AFTER jofotara_secret_key; + +-- 4. Update Invoices for AI and JoFotara metadata +ALTER TABLE invoices +ADD COLUMN IF NOT EXISTS invoice_category ENUM('simplified', 'standard') DEFAULT 'simplified' AFTER invoice_type, +ADD COLUMN IF NOT EXISTS ubl_type_code VARCHAR(10) DEFAULT '388' AFTER invoice_category, +ADD COLUMN IF NOT EXISTS payment_method_code VARCHAR(10) DEFAULT '013' AFTER ubl_type_code, +ADD COLUMN IF NOT EXISTS validation_warnings JSON NULL AFTER qr_code, +ADD COLUMN IF NOT EXISTS ai_confidence DECIMAL(5,2) DEFAULT 0 AFTER validation_warnings, +ADD COLUMN IF NOT EXISTS jofotara_uuid VARCHAR(100) NULL AFTER status; + +-- 5. AI Usage Log (Cost & Token tracking) +CREATE TABLE IF NOT EXISTS ai_usage_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id CHAR(36) NOT NULL, + user_id CHAR(36) NULL, + company_id CHAR(36) NULL, + action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL, + model_name VARCHAR(50) NOT NULL, + prompt_tokens INT DEFAULT 0, + completion_tokens INT DEFAULT 0, + total_tokens INT DEFAULT 0, + estimated_cost DECIMAL(10,6) DEFAULT 0, + request_metadata JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_tenant_date (tenant_id, created_at), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6. Notifications Table +CREATE TABLE IF NOT EXISTS notifications ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + user_id CHAR(36) NULL, + type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + metadata JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_read (user_id, is_read), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/deploy_production.sh b/scripts/deploy_production.sh new file mode 100644 index 0000000..7f853c5 --- /dev/null +++ b/scripts/deploy_production.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ───────────────────────────────────────────────────── +# Musadaq Production Deployment Script +# Run this on the production server after syncing files +# ───────────────────────────────────────────────────── + +set -e + +echo "═══════════════════════════════════════════════" +echo " مُصادَق — Production Deployment Script" +echo "═══════════════════════════════════════════════" + +# 1. Install PHP dependencies +echo "" +echo "▶ Step 1: Installing Composer dependencies..." +cd /home/musadaq/htdocs/musadaq.intaleqapp.com +composer install --no-dev --optimize-autoloader + +# 2. Ensure storage directories exist +echo "" +echo "▶ Step 2: Creating storage directories..." +mkdir -p storage/invoices +mkdir -p storage/logs +mkdir -p storage/exports +mkdir -p storage/temp +chmod -R 775 storage/ + +# 3. Set up the Cron Job for AI Queue Worker +echo "" +echo "▶ Step 3: Setting up Cron Job for AI Worker..." +echo "" +echo " Run: crontab -e" +echo " Add this line:" +echo "" +echo " * * * * * /usr/bin/php /home/musadaq/htdocs/musadaq.intaleqapp.com/app/cron/process_batches.php >> /home/musadaq/htdocs/musadaq.intaleqapp.com/storage/logs/cron.log 2>&1" +echo "" +echo " This runs the AI Queue Worker every minute." +echo " The worker has its own lock file to prevent duplicates." +echo "" + +# 4. Verify environment variables +echo "▶ Step 4: Checking .env configuration..." +if [ -f .env ]; then + echo " ✅ .env file found" + + # Check critical keys + grep -q "GEMINI_API_KEY" .env && echo " ✅ GEMINI_API_KEY set" || echo " ❌ GEMINI_API_KEY missing!" + grep -q "DB_HOST" .env && echo " ✅ DB_HOST set" || echo " ❌ DB_HOST missing!" + grep -q "ENCRYPTION_KEY" .env && echo " ✅ ENCRYPTION_KEY set" || echo " ❌ ENCRYPTION_KEY missing!" + grep -q "JWT_SECRET" .env && echo " ✅ JWT_SECRET set" || echo " ❌ JWT_SECRET missing!" + grep -q "FCM_SERVER_KEY\|FIREBASE" .env && echo " ✅ Firebase key set" || echo " ⚠️ Firebase key missing (push notifications won't work)" +else + echo " ❌ .env file not found! Copy .env.example and configure it." +fi + +echo "" +echo "═══════════════════════════════════════════════" +echo " ✅ Deployment Complete!" +echo "" +echo " Next steps:" +echo " 1. Add the Cron Job (shown above)" +echo " 2. Test the API: curl https://musadaq.intaleqapp.com/api/v1/auth/login" +echo " 3. Monitor logs: tail -f storage/logs/cron.log" +echo "═══════════════════════════════════════════════" diff --git a/scripts/phase1_migration.sql b/scripts/phase1_migration.sql new file mode 100644 index 0000000..8974476 --- /dev/null +++ b/scripts/phase1_migration.sql @@ -0,0 +1,72 @@ +-- ════════════════════════════════════════════════════════════ +-- مُصادَق — Phase 1: AI Usage Tracking + Notifications +-- ════════════════════════════════════════════════════════════ + +-- AI Usage Log (tracks every AI request) +CREATE TABLE IF NOT EXISTS ai_usage_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id CHAR(36) NOT NULL, + user_id CHAR(36) NULL, + company_id CHAR(36) NULL, + action_type ENUM('invoice_extraction','voice_transcribe','voice_intent','report_generation','chatbot') NOT NULL, + model_name VARCHAR(50) NOT NULL, + prompt_tokens INT DEFAULT 0, + completion_tokens INT DEFAULT 0, + total_tokens INT DEFAULT 0, + estimated_cost DECIMAL(10,6) DEFAULT 0, + request_metadata JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_tenant_date (tenant_id, created_at), + INDEX idx_action (action_type), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Notifications +CREATE TABLE IF NOT EXISTS notifications ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + user_id CHAR(36) NULL, + type ENUM('invoice_processed','invoice_rejected','quota_warning','month_end','system','achievement') NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + metadata JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_read (user_id, is_read), + INDEX idx_tenant (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Referral Codes (Phase 2 prep) +CREATE TABLE IF NOT EXISTS referral_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + code VARCHAR(20) NOT NULL UNIQUE, + uses_count INT DEFAULT 0, + max_uses INT DEFAULT 50, + reward_months INT DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Referral Uses (Phase 2 prep) +CREATE TABLE IF NOT EXISTS referral_uses ( + id INT AUTO_INCREMENT PRIMARY KEY, + code_id INT NOT NULL, + referred_tenant_id CHAR(36) NOT NULL, + reward_applied BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (code_id) REFERENCES referral_codes(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- User Achievements (Phase 2 prep) +CREATE TABLE IF NOT EXISTS user_achievements ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id CHAR(36) NOT NULL, + achievement_code VARCHAR(50) NOT NULL, + points INT NOT NULL DEFAULT 0, + earned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_user_achievement (user_id, achievement_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;