From 8ee3557109efd6014a119948d3f38cc497487a21 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 7 May 2026 18:16:10 +0300 Subject: [PATCH] Update: 2026-05-07 18:16:10 --- app/modules_app/voice/transcribe.php | 596 ++++++++++++++++-- .../services/voice_assistant_service.dart | 43 ++ .../controllers/dashboard_controller.dart | 389 +++++++++--- musadaq-app/pubspec.lock | 102 ++- musadaq-app/pubspec.yaml | 1 + 5 files changed, 976 insertions(+), 155 deletions(-) create mode 100644 musadaq-app/lib/core/services/voice_assistant_service.dart diff --git a/app/modules_app/voice/transcribe.php b/app/modules_app/voice/transcribe.php index 373bb10..d18f476 100644 --- a/app/modules_app/voice/transcribe.php +++ b/app/modules_app/voice/transcribe.php @@ -1,18 +1,26 @@ $maxBytes) { + json_error('حجم ملف الصوت أكبر من الحد المسموح (10MB)', 413); } -// Ensure it's a valid audio file (basic check) -$tmpPath = $_FILES['audio']['tmp_name']; - -$cfile = curl_file_create($tmpPath, $_FILES['audio']['type'], $_FILES['audio']['name']); - -$postData = [ - 'file' => $cfile, - 'model' => 'whisper-large-v3', - 'language' => 'ar', - 'response_format' => 'json' -]; - -$ch = curl_init('https://api.groq.com/openai/v1/audio/transcriptions'); -curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - '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("Groq Error: $response | $error"); - json_error('فشل في تحويل الصوت إلى نص', 500); +$geminiApiKey = env('GEMINI_API_KEY'); +if (!$geminiApiKey) { + json_error('Gemini API Key غير متوفر', 500); } -$data = json_decode($response, true); -json_success(['text' => $data['text'] ?? ''], 'تم التحويل بنجاح'); +$tmpPath = $audio['tmp_name']; +$rawAudio = @file_get_contents($tmpPath); +if ($rawAudio === false) { + json_error('فشل في قراءة ملف الصوت', 500); +} +$base64Audio = base64_encode($rawAudio); + +$mimeType = detectAudioMimeType($tmpPath, (string)($audio['type'] ?? 'audio/m4a')); +$intent = extractIntentFromAudio($base64Audio, $mimeType, $geminiApiKey); +$execution = executeVoiceAction($decoded, $intent); + +json_success([ + 'intent' => $intent, + 'execution' => $execution, +], 'تم تحليل الأمر الصوتي وتنفيذه'); + +function detectAudioMimeType(string $path, string $fallback): string +{ + $allowed = [ + 'audio/mpeg', + 'audio/mp3', + 'audio/mp4', + 'audio/m4a', + 'audio/wav', + 'audio/x-wav', + 'audio/webm', + 'audio/ogg', + 'audio/aac', + 'audio/x-m4a', + ]; + + $detected = $fallback; + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo !== false) { + $probe = finfo_file($finfo, $path); + if (is_string($probe) && $probe !== '') { + $detected = $probe; + } + finfo_close($finfo); + } + } + + // Keep a safe supported fallback for Gemini audio understanding. + return in_array($detected, $allowed, true) ? $detected : 'audio/m4a'; +} + +function extractIntentFromAudio(string $base64Audio, string $mimeType, string $apiKey): array +{ + $model = env('GEMINI_VOICE_MODEL', env('GEMINI_MODEL', 'gemini-2.5-flash')); + + $systemPrompt = << [ + [ + 'parts' => [ + ['text' => 'حلّل هذا التسجيل الصوتي واستخرج أمر النظام بصيغة JSON فقط.'], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => $base64Audio, + ], + ], + ], + ], + ], + 'systemInstruction' => [ + 'parts' => [ + ['text' => $systemPrompt], + ], + ], + 'generationConfig' => [ + 'responseMimeType' => 'application/json', + 'temperature' => 0.1, + ], + ]; + + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 45, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($httpCode !== 200 || !$response) { + error_log("Voice Gemini Error: HTTP {$httpCode} | {$error} | {$response}"); + json_error('فشل تحليل الصوت بواسطة Gemini', 500); + } + + $respData = json_decode($response, true); + if (!is_array($respData)) { + json_error('تعذر قراءة رد Gemini', 500); + } + + $rawText = $respData['candidates'][0]['content']['parts'][0]['text'] ?? ''; + if (!is_string($rawText) || trim($rawText) === '') { + json_error('رد غير متوقع من Gemini', 500); + } + + $parsed = decodeModelJson($rawText); + if (!is_array($parsed)) { + error_log("Voice Gemini JSON parse failed. Raw: " . $rawText); + json_error('فشل تفسير الأمر الصوتي', 500); + } + + $action = isset($parsed['action']) && is_string($parsed['action']) + ? strtolower(trim($parsed['action'])) + : 'navigate'; + $params = isset($parsed['params']) && is_array($parsed['params']) + ? $parsed['params'] + : []; + $confirmation = isset($parsed['confirmation']) && is_string($parsed['confirmation']) + ? trim($parsed['confirmation']) + : 'تم فهم الأمر'; + $transcript = isset($parsed['transcript']) && is_string($parsed['transcript']) + ? trim($parsed['transcript']) + : ''; + + return [ + 'action' => $action, + 'params' => $params, + 'confirmation' => $confirmation, + 'transcript' => $transcript, + ]; +} + +function decodeModelJson(string $rawText): ?array +{ + $text = trim($rawText); + + // Remove fenced blocks if model wrapped JSON in ```json ... ``` + if (str_starts_with($text, '```')) { + $text = preg_replace('/^```(?:json)?/i', '', $text) ?? $text; + $text = preg_replace('/```$/', '', $text) ?? $text; + $text = trim($text); + } + + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + // Fallback: try to extract the first JSON object + if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $m) === 1) { + $decoded = json_decode($m[0], true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; +} + +function executeVoiceAction(array $decoded, array $intent): array +{ + $action = (string)($intent['action'] ?? ''); + $params = is_array($intent['params'] ?? null) ? $intent['params'] : []; + + $tenantId = (string)($decoded['tenant_id'] ?? ''); + $userId = (string)($decoded['user_id'] ?? ''); + $role = (string)($decoded['role'] ?? ''); + + try { + switch ($action) { + case 'list_invoices': + return executeListInvoices($tenantId, $userId, $role, $params); + + case 'search_invoice': + return executeSearchInvoices($tenantId, $userId, $role, $params); + + case 'check_quota': + if ($tenantId === '') { + return [ + 'status' => 'failed', + 'action' => $action, + 'message' => 'لا يمكن جلب تفاصيل الباقة بدون tenant_id', + 'data' => null, + ]; + } + return [ + 'status' => 'executed', + 'action' => $action, + 'message' => 'تم جلب استهلاك الباقة', + 'data' => QuotaMiddleware::getUsageSummary($tenantId), + ]; + + case 'check_status': + return executeCheckStatus($tenantId, $role, $params); + + case 'get_report': + return executeGetReport($tenantId, $role, $params); + + case 'open_scanner': + case 'navigate': + case 'export_pdf': + return [ + 'status' => 'client_action', + 'action' => $action, + 'message' => 'يتطلب تنفيذ هذا الإجراء من واجهة التطبيق', + 'data' => $params, + ]; + + default: + return [ + 'status' => 'not_supported', + 'action' => $action, + 'message' => 'الأمر مفهوم لكن غير مدعوم حالياً في التنفيذ المباشر', + 'data' => $params, + ]; + } + } catch (\Throwable $e) { + error_log("Voice Action Execution Error ({$action}): " . $e->getMessage()); + return [ + 'status' => 'failed', + 'action' => $action, + 'message' => 'حدث خطأ أثناء تنفيذ الأمر داخلياً', + 'data' => null, + ]; + } +} + +function executeListInvoices(string $tenantId, string $userId, string $role, array $params): array +{ + $db = Database::getInstance(); + + $where = []; + $bind = []; + + if ($role !== 'super_admin') { + $where[] = 'i.tenant_id = ?'; + $bind[] = $tenantId; + } + + // Role scoping for accountant/viewer (assigned companies only) + if (in_array($role, ['accountant', 'viewer'], true)) { + $stmtAssigned = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1"); + $stmtAssigned->execute([$userId]); + $assigned = $stmtAssigned->fetchAll(PDO::FETCH_COLUMN); + + if (empty($assigned)) { + return [ + 'status' => 'executed', + 'action' => 'list_invoices', + 'message' => 'لا توجد شركات مخصصة لك حالياً', + 'data' => ['items' => [], 'count' => 0], + ]; + } + + $placeholders = implode(',', array_fill(0, count($assigned), '?')); + $where[] = "i.company_id IN ({$placeholders})"; + foreach ($assigned as $companyId) { + $bind[] = $companyId; + } + } + + if (!empty($params['status']) && is_string($params['status'])) { + $where[] = 'i.status = ?'; + $bind[] = trim($params['status']); + } + + if (!empty($params['from']) && is_string($params['from'])) { + $where[] = 'i.invoice_date >= ?'; + $bind[] = trim($params['from']); + } + + if (!empty($params['to']) && is_string($params['to'])) { + $where[] = 'i.invoice_date <= ?'; + $bind[] = trim($params['to']); + } + + if (!empty($params['company']) && is_string($params['company'])) { + $where[] = '(c.name LIKE ? OR c.name_en LIKE ?)'; + $needle = '%' . trim($params['company']) . '%'; + $bind[] = $needle; + $bind[] = $needle; + } + + $limit = isset($params['limit']) ? (int)$params['limit'] : 20; + if ($limit < 1) $limit = 20; + if ($limit > 50) $limit = 50; + + $sql = " + SELECT i.id, i.invoice_number, i.invoice_date, i.status, i.grand_total, i.tax_amount, c.name AS company_name + FROM invoices i + LEFT JOIN companies c ON c.id = i.company_id + "; + + if (!empty($where)) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + + $sql .= ' ORDER BY i.created_at DESC LIMIT ' . $limit; + + $stmt = $db->prepare($sql); + $stmt->execute($bind); + $items = $stmt->fetchAll(); + + foreach ($items as &$row) { + $row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? '')); + } + + return [ + 'status' => 'executed', + 'action' => 'list_invoices', + 'message' => 'تم جلب قائمة الفواتير', + 'data' => [ + 'items' => $items, + 'count' => count($items), + ], + ]; +} + +function executeSearchInvoices(string $tenantId, string $userId, string $role, array $params): array +{ + // Reuse list logic with extra flexible filters. + $filters = [ + 'status' => $params['status'] ?? null, + 'company' => $params['company'] ?? null, + 'from' => $params['from'] ?? null, + 'to' => $params['to'] ?? null, + 'limit' => $params['limit'] ?? 20, + ]; + + $result = executeListInvoices($tenantId, $userId, $role, $filters); + + if (($result['status'] ?? '') !== 'executed') { + return $result; + } + + $items = $result['data']['items'] ?? []; + + if (!empty($params['number']) && is_string($params['number'])) { + $needle = strtolower(trim($params['number'])); + $items = array_values(array_filter($items, static function (array $row) use ($needle): bool { + return str_contains(strtolower((string)($row['invoice_number'] ?? '')), $needle); + })); + } + + if (isset($params['amount']) && is_numeric($params['amount'])) { + $target = (float)$params['amount']; + $items = array_values(array_filter($items, static function (array $row) use ($target): bool { + $value = (float)($row['grand_total'] ?? 0); + return abs($value - $target) <= 0.01; + })); + } + + return [ + 'status' => 'executed', + 'action' => 'search_invoice', + 'message' => 'تم تنفيذ البحث عن الفاتورة', + 'data' => [ + 'items' => $items, + 'count' => count($items), + ], + ]; +} + +function executeCheckStatus(string $tenantId, string $role, array $params): array +{ + $db = Database::getInstance(); + + $invoiceId = isset($params['invoice_id']) ? trim((string)$params['invoice_id']) : ''; + $invoiceNumber = isset($params['invoice_number']) ? trim((string)$params['invoice_number']) : ''; + + if ($invoiceId === '' && $invoiceNumber === '') { + return [ + 'status' => 'failed', + 'action' => 'check_status', + 'message' => 'يرجى تحديد رقم الفاتورة أو معرفها', + 'data' => null, + ]; + } + + $where = []; + $bind = []; + if ($invoiceId !== '') { + $where[] = 'i.id = ?'; + $bind[] = $invoiceId; + } + if ($invoiceNumber !== '') { + $where[] = 'i.invoice_number = ?'; + $bind[] = $invoiceNumber; + } + if ($role !== 'super_admin') { + $where[] = 'i.tenant_id = ?'; + $bind[] = $tenantId; + } + + $sql = " + SELECT i.id, i.invoice_number, i.status, i.invoice_date, i.grand_total, i.jofotara_uuid, c.name AS company_name + FROM invoices i + LEFT JOIN companies c ON c.id = i.company_id + WHERE " . implode(' AND ', $where) . " + ORDER BY i.created_at DESC + LIMIT 1 + "; + + $stmt = $db->prepare($sql); + $stmt->execute($bind); + $row = $stmt->fetch(); + + if (!$row) { + return [ + 'status' => 'executed', + 'action' => 'check_status', + 'message' => 'لم يتم العثور على الفاتورة المطلوبة', + 'data' => null, + ]; + } + + $row['company_name'] = decryptIfNeeded((string)($row['company_name'] ?? '')); + + return [ + 'status' => 'executed', + 'action' => 'check_status', + 'message' => 'تم جلب حالة الفاتورة', + 'data' => $row, + ]; +} + +function executeGetReport(string $tenantId, string $role, array $params): array +{ + $db = Database::getInstance(); + $type = strtolower(trim((string)($params['type'] ?? 'monthly'))); + $period = trim((string)($params['period'] ?? date('Y-m'))); + + $periodRegex = '/^\d{4}-\d{2}$/'; + if (!preg_match($periodRegex, $period)) { + $period = date('Y-m'); + } + + $where = "DATE_FORMAT(i.invoice_date, '%Y-%m') = ?"; + $bind = [$period]; + + if ($role !== 'super_admin') { + $where .= " AND i.tenant_id = ?"; + $bind[] = $tenantId; + } + + if ($type === 'tax') { + $sql = " + SELECT + COUNT(i.id) AS invoices_count, + ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax, + ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_with_tax + FROM invoices i + WHERE {$where} + "; + } else { + $sql = " + SELECT + COUNT(i.id) AS invoices_count, + ROUND(COALESCE(SUM(i.grand_total), 0), 3) AS total_amount, + ROUND(COALESCE(SUM(i.tax_amount), 0), 3) AS total_tax, + SUM(CASE WHEN i.status = 'approved' THEN 1 ELSE 0 END) AS approved_count + FROM invoices i + WHERE {$where} + "; + } + + $stmt = $db->prepare($sql); + $stmt->execute($bind); + $summary = $stmt->fetch() ?: []; + + return [ + 'status' => 'executed', + 'action' => 'get_report', + 'message' => 'تم إنشاء التقرير المختصر', + 'data' => [ + 'type' => $type, + 'period' => $period, + 'summary' => $summary, + ], + ]; +} + +function decryptIfNeeded(string $value): string +{ + if ($value === '') { + return ''; + } + try { + $dec = Encryption::decrypt($value); + if ($dec !== false && $dec !== null) { + return (string)$dec; + } + } catch (\Throwable $e) { + // Keep original value + } + return $value; +} diff --git a/musadaq-app/lib/core/services/voice_assistant_service.dart b/musadaq-app/lib/core/services/voice_assistant_service.dart new file mode 100644 index 0000000..31cee7c --- /dev/null +++ b/musadaq-app/lib/core/services/voice_assistant_service.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import '../network/dio_client.dart'; +import '../utils/logger.dart'; + +class VoiceAssistantService { + final Dio _dio = DioClient().client; + + Future> processVoiceCommand(File audioFile) async { + final fileName = audioFile.uri.pathSegments.isNotEmpty + ? audioFile.uri.pathSegments.last + : 'voice_command.m4a'; + + final formData = FormData.fromMap({ + 'audio': await MultipartFile.fromFile(audioFile.path, filename: fileName), + }); + + final response = await _dio.post( + 'voice/transcribe', + data: formData, + options: Options(contentType: 'multipart/form-data'), + ); + + if (response.data is! Map) { + throw Exception('استجابة غير متوقعة من الخادم'); + } + + final body = Map.from(response.data as Map); + if (body['success'] != true) { + throw Exception((body['message'] ?? 'فشل تنفيذ الأمر الصوتي').toString()); + } + + final payload = body['data']; + if (payload is! Map) { + throw Exception('بيانات المساعد الصوتي ناقصة'); + } + + AppLogger.print('Voice API success: ${body['message']}'); + return Map.from(payload); + } +} diff --git a/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart index a6f213f..62cd59a 100644 --- a/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart +++ b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart @@ -1,31 +1,49 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + import '../../../core/storage/secure_storage.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/services/voice_assistant_service.dart'; import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/logger.dart'; import '../../../app/routes/app_pages.dart'; class DashboardController extends GetxController { final SecureStorage _storage = SecureStorage(); - final Dio _dio = Dio(BaseOptions( - baseUrl: 'https://musadaq.intaleqapp.com/api/v1', - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - responseType: ResponseType.json, - )); + final Dio _dio = DioClient().client; + final VoiceAssistantService _voiceAssistantService = VoiceAssistantService(); + final AudioRecorder _audioRecorder = AudioRecorder(); + Timer? _recordTimer; var isLoading = true.obs; var stats = {}.obs; var recentActivities = [].obs; var userRole = ''.obs; + final isVoiceRecording = false.obs; + final isVoiceUploading = false.obs; + final voiceRemainingSeconds = 15.obs; + final voiceStatusText = 'اضغط "ابدأ التسجيل" ثم تحدّث'.obs; + @override void onInit() { super.onInit(); _loadDashboardData(); } + @override + void onClose() { + _recordTimer?.cancel(); + _audioRecorder.dispose(); + super.onClose(); + } + Future _loadDashboardData() async { try { isLoading.value = true; @@ -36,17 +54,15 @@ class DashboardController extends GetxController { return; } - _dio.options.headers['Authorization'] = 'Bearer $token'; - // Fetch Stats - final statsResponse = await _dio.get('/dashboard/stats'); + final statsResponse = await _dio.get('dashboard/stats'); if (statsResponse.data['success'] == true) { stats.value = statsResponse.data['data']; userRole.value = statsResponse.data['data']['role'] ?? ''; } // Fetch Recent Activity - final activityResponse = await _dio.get('/dashboard/recent-activity'); + final activityResponse = await _dio.get('dashboard/recent-activity'); if (activityResponse.data['success'] == true) { recentActivities.value = activityResponse.data['data']; } @@ -71,133 +87,306 @@ class DashboardController extends GetxController { } void startVoiceAssistant() { - final textController = TextEditingController(); + _resetVoiceState(); Get.bottomSheet( - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Get.isDarkMode ? const Color(0xFF1E1E2E) : Colors.white, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(30), topRight: Radius.circular(30)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), - borderRadius: BorderRadius.circular(2)), + Obx( + () => Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Get.isDarkMode ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), ), - Row( - children: [ - const Icon(Icons.auto_awesome, color: Color(0xFF5EEAD4)), - const SizedBox(width: 12), - Text( - 'المساعد الذكي (Grok-Beta)', - style: TextStyle( + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 18), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + const Icon(Icons.auto_awesome, color: Color(0xFF5EEAD4)), + const SizedBox(width: 12), + Text( + 'المساعد الصوتي (Gemini)', + style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Get.isDarkMode ? Colors.white - : const Color(0xFF0F4C81)), - ), - ], - ), - const SizedBox(height: 20), - TextField( - autofocus: true, - controller: textController, - style: TextStyle( - color: Get.isDarkMode ? Colors.white : Colors.black), - decoration: InputDecoration( - hintText: 'اكتب أمرك هنا (مثلاً: أريد رؤية الفواتير)...', - hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), - filled: true, - fillColor: Get.isDarkMode - ? const Color(0xFF1A1A2E) - : const Color(0xFFF1F5F9), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none), - prefixIcon: const Icon(Icons.mic_none_rounded, color: Colors.grey), - suffixIcon: IconButton( - icon: - const Icon(Icons.send_rounded, color: Color(0xFF0F4C81)), - onPressed: () => _handleVoiceCommand(textController.text), + : const Color(0xFF0F4C81), + ), + ), + ], + ), + const SizedBox(height: 18), + Text( + voiceStatusText.value, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Get.isDarkMode ? Colors.white70 : Colors.black87, ), ), - onSubmitted: (val) => _handleVoiceCommand(val), - ), - const SizedBox(height: 12), - Text( - 'ملاحظة: جاري تفعيل ميزة التعرف الصوتي المباشر...', - style: - TextStyle(fontSize: 11, color: Colors.grey.withOpacity(0.7)), - ), - const SizedBox(height: 20), - ], + const SizedBox(height: 14), + Text( + '${voiceRemainingSeconds.value}s', + style: TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + fontSize: 18, + color: isVoiceRecording.value + ? const Color(0xFFDC2626) + : const Color(0xFF0F4C81), + ), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: (15 - voiceRemainingSeconds.value) / 15, + minHeight: 8, + color: isVoiceRecording.value + ? const Color(0xFFDC2626) + : const Color(0xFF0F4C81), + backgroundColor: + Get.isDarkMode ? Colors.white10 : const Color(0xFFE2E8F0), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isVoiceUploading.value + ? null + : () async { + if (isVoiceRecording.value) { + await _stopAndUploadVoice(autoStopped: false); + } else { + await _startVoiceRecording(); + } + }, + icon: Icon( + isVoiceRecording.value + ? Icons.stop_circle_outlined + : Icons.mic_none_rounded, + ), + label: Text( + isVoiceRecording.value + ? 'إيقاف وإرسال' + : 'ابدأ التسجيل', + ), + style: OutlinedButton.styleFrom( + foregroundColor: isVoiceRecording.value + ? const Color(0xFFDC2626) + : const Color(0xFF0F4C81), + side: BorderSide( + color: isVoiceRecording.value + ? const Color(0xFFDC2626) + : const Color(0xFF0F4C81), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: + isVoiceRecording.value || isVoiceUploading.value + ? null + : () => Get.back(), + icon: const Icon(Icons.close), + label: const Text('إغلاق'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + if (isVoiceUploading.value) ...[ + const SizedBox(height: 14), + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ], + ), ), ), isScrollControlled: true, + isDismissible: false, ); } - Future _handleVoiceCommand(String text) async { - if (text.trim().isEmpty) return; + Future _startVoiceRecording() async { + if (isVoiceUploading.value) return; - Get.back(); // Close bottom sheet - AppSnackbar.showWarning('جاري التحليل...', 'يتم تحليل طلبك بواسطة Grok AI'); + final hasPermission = await _audioRecorder.hasPermission(); + if (!hasPermission) { + AppSnackbar.showError('صلاحيات', 'الرجاء السماح بالوصول للميكروفون'); + return; + } + + final tempDir = await getTemporaryDirectory(); + final path = + '${tempDir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; + + await _audioRecorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + sampleRate: 16000, + numChannels: 1, + bitRate: 64000, + ), + path: path, + ); + + isVoiceRecording.value = true; + voiceRemainingSeconds.value = 15; + voiceStatusText.value = 'جاري التسجيل... تكلم الآن'; + _startVoiceCountdown(); + } + + void _startVoiceCountdown() { + _recordTimer?.cancel(); + _recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final next = voiceRemainingSeconds.value - 1; + voiceRemainingSeconds.value = next; + + if (next <= 0) { + timer.cancel(); + _stopAndUploadVoice(autoStopped: true); + } + }); + } + + Future _stopAndUploadVoice({required bool autoStopped}) async { + if (!isVoiceRecording.value) return; + + _recordTimer?.cancel(); + isVoiceRecording.value = false; + voiceStatusText.value = autoStopped + ? 'تم الإيقاف التلقائي، جاري الإرسال...' + : 'جاري الإرسال...'; + + final recordPath = await _audioRecorder.stop(); + if (recordPath == null || recordPath.isEmpty) { + voiceStatusText.value = 'فشل حفظ التسجيل الصوتي'; + AppSnackbar.showError('خطأ', 'فشل حفظ التسجيل الصوتي'); + return; + } + + await _uploadVoiceCommand(File(recordPath)); + } + + Future _uploadVoiceCommand(File audioFile) async { + isVoiceUploading.value = true; try { - final token = await _storage.getToken(); - final response = await _dio.post( - '/voice/parse-intent-grok', - data: {'text': text}, - options: Options(headers: {'Authorization': 'Bearer $token'}), + final result = + await _voiceAssistantService.processVoiceCommand(audioFile); + final intent = Map.from( + (result['intent'] as Map?) ?? {}, + ); + final execution = Map.from( + (result['execution'] as Map?) ?? {}, ); - if (response.data['success'] == true) { - final action = response.data['data']['action']; - final params = response.data['data']['params']; - final confirmation = response.data['data']['confirmation'] ?? 'تم!'; + final action = (intent['action'] ?? '').toString(); + final params = Map.from( + (intent['params'] as Map?) ?? {}, + ); + final confirmation = (intent['confirmation'] ?? + execution['message'] ?? + 'تم تنفيذ الأمر الصوتي') + .toString(); + final executionStatus = (execution['status'] ?? 'unknown').toString(); - AppSnackbar.showSuccess('تم!', confirmation); - _executeAction(action, params); - } else { + AppSnackbar.showSuccess('المساعد الصوتي', confirmation); + + if (executionStatus == 'failed') { AppSnackbar.showError( - 'خطأ', response.data['message'] ?? 'فشل تحليل الطلب'); + 'فشل التنفيذ', + (execution['message'] ?? 'تعذر تنفيذ الأمر داخليًا').toString(), + ); + } else { + _executeAction(action, params); } } on DioException catch (e) { - AppLogger.error('Voice Assistant Error', e); - String errorMsg = 'حدث خطأ أثناء التواصل مع خادم الذكاء الاصطناعي'; + AppLogger.error('Voice upload error', e.response?.data ?? e.message); + String errorMsg = 'تعذر إرسال التسجيل الصوتي'; if (e.response != null && e.response?.data != null) { if (e.response?.data is Map && e.response?.data['message'] != null) { errorMsg = e.response?.data['message']; } } AppSnackbar.showError('خطأ', errorMsg); - } catch (e) { - AppLogger.error('Voice Assistant Error', e); + } catch (e, stack) { + AppLogger.error('Voice Assistant Error', e, stack); AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع'); + } finally { + isVoiceUploading.value = false; + voiceStatusText.value = 'جاهز لأمر صوتي جديد'; + + // Close voice sheet if still open. + if (Get.isBottomSheetOpen == true) { + Get.back(); + } } } void _executeAction(String action, dynamic params) { switch (action) { case 'list_invoices': - Get.toNamed(AppRoutes.INVOICES); + case 'search_invoice': + Get.toNamed(AppRoutes.MAIN); + AppSnackbar.showWarning( + 'معلومة', 'تم فتح الواجهة الرئيسية؛ عرض نتائج تفصيلي قادم'); break; case 'open_scanner': Get.toNamed(AppRoutes.SCANNER); break; case 'navigate': - final screen = params['screen']?.toString().toLowerCase(); - if (screen == 'settings') Get.toNamed(AppRoutes.SETTINGS); - if (screen == 'dashboard') Get.back(); + final screen = + (params is Map ? params['screen'] : null)?.toString().toLowerCase(); + if (screen == 'settings') { + Get.toNamed(AppRoutes.MAIN); + } else if (screen == 'scanner') { + Get.toNamed(AppRoutes.SCANNER); + } else { + Get.toNamed(AppRoutes.MAIN); + } + break; + case 'check_quota': + case 'check_status': + case 'get_report': + case 'export_pdf': + AppSnackbar.showWarning( + 'تم التنفيذ', + 'تم تنفيذ الأمر على الخادم وسيتم تحسين عرض النتائج داخل التطبيق قريباً', + ); break; default: AppSnackbar.showWarning( @@ -205,6 +394,14 @@ class DashboardController extends GetxController { } } + void _resetVoiceState() { + _recordTimer?.cancel(); + isVoiceRecording.value = false; + isVoiceUploading.value = false; + voiceRemainingSeconds.value = 15; + voiceStatusText.value = 'اضغط "ابدأ التسجيل" ثم تحدّث'; + } + void refreshData() { _loadDashboardData(); } diff --git a/musadaq-app/pubspec.lock b/musadaq-app/pubspec.lock index 4ec259e..fcefea5 100644 --- a/musadaq-app/pubspec.lock +++ b/musadaq-app/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -748,26 +748,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -836,18 +836,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" matrix2d: dependency: transitive description: @@ -860,10 +860,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1152,6 +1152,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: @@ -1321,10 +1385,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.4" timing: dependency: transitive description: @@ -1353,10 +1417,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -1438,5 +1502,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.32.0" diff --git a/musadaq-app/pubspec.yaml b/musadaq-app/pubspec.yaml index 79ebc41..1974c32 100644 --- a/musadaq-app/pubspec.yaml +++ b/musadaq-app/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: # ─── Voice & Audio ────────────────────────────────── speech_to_text: ^7.3.0 + record: ^6.2.0 permission_handler: ^11.3.0 # ─── Connectivity & Background ──────────────────────