import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:get/get.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'; import '../../invoices/controllers/invoices_controller.dart'; import '../../main_shell/controllers/main_shell_controller.dart'; class DashboardController extends GetxController { final SecureStorage _storage = SecureStorage(); final Dio _dio = DioClient().client; final VoiceAssistantService _voiceAssistantService = VoiceAssistantService(); final AudioRecorder _audioRecorder = AudioRecorder(); Timer? _recordTimer; var isLoading = true.obs; var stats = {}.obs; var gamification = {}.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; final token = await _storage.getToken(); if (token == null || token.isEmpty) { Get.offAllNamed(AppRoutes.PHONE_INPUT); return; } // Fetch Stats final statsResponse = await _dio.get('dashboard/stats'); if (statsResponse.data['success'] == true) { stats.value = statsResponse.data['data']; userRole.value = statsResponse.data['data']['role'] ?? ''; // Save role to storage for other controllers to use if (userRole.value.isNotEmpty) { await _storage.write('user_role', userRole.value); } } // Fetch Gamification final gamificationResponse = await _dio.get('gamification/profile'); if (gamificationResponse.data['success'] == true) { gamification.value = gamificationResponse.data['data']; } // Fetch Recent Activity final activityResponse = await _dio.get('dashboard/recent-activity'); if (activityResponse.data['success'] == true) { recentActivities.value = activityResponse.data['data']; } } on DioException catch (e) { AppLogger.error('Dashboard Data Fetch Error', e); if (e.response?.statusCode == 401 || e.response?.statusCode == 403) { await logout(); } else { AppSnackbar.showError( 'خطأ', 'فشل في جلب البيانات. الرجاء التحقق من اتصالك بالإنترنت.'); } } catch (e) { AppLogger.error('Unexpected error fetching dashboard', e); } finally { isLoading.value = false; } } Future logout() async { await _storage.clearAll(); Get.offAllNamed(AppRoutes.PHONE_INPUT); } void startVoiceAssistant() { _resetVoiceState(); Get.bottomSheet( 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), ), ), 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: 18), Text( voiceStatusText.value, textAlign: TextAlign.center, style: TextStyle( fontSize: 13, color: Get.isDarkMode ? Colors.white70 : Colors.black87, ), ), 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 _startVoiceRecording() async { if (isVoiceUploading.value) return; final hasPermission = await _audioRecorder.hasPermission(); if (!hasPermission) { AppSnackbar.showError('صلاحيات', 'الرجاء السماح بالوصول للميكروفون'); return; } final tempDir = await getTemporaryDirectory(); final wavSupported = await _audioRecorder.isEncoderSupported(AudioEncoder.wav); final flacSupported = await _audioRecorder.isEncoderSupported(AudioEncoder.flac); final AudioEncoder encoder; final String extension; if (wavSupported) { encoder = AudioEncoder.wav; extension = 'wav'; } else if (flacSupported) { encoder = AudioEncoder.flac; extension = 'flac'; } else { AppSnackbar.showError( 'الصوت', 'هذا الجهاز لا يدعم صيغة صوت مناسبة لـ Gemini حالياً', ); return; } final path = '${tempDir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.$extension'; AppLogger.print( 'Voice recorder selected encoder=$encoder, wavSupported=$wavSupported, flacSupported=$flacSupported, path=$path', ); await _audioRecorder.start( RecordConfig( encoder: encoder, sampleRate: 16000, numChannels: 1, ), 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 result = await _voiceAssistantService.processVoiceCommand(audioFile); final intent = Map.from( (result['intent'] as Map?) ?? {}, ); final execution = Map.from( (result['execution'] as Map?) ?? {}, ); final action = (intent['action'] ?? '').toString(); final params = _asStringMap(intent['params']); final confirmation = (intent['confirmation'] ?? execution['message'] ?? 'تم تنفيذ الأمر الصوتي') .toString(); final executionStatus = (execution['status'] ?? 'unknown').toString(); AppSnackbar.showSuccess('المساعد الصوتي', confirmation); if (executionStatus == 'failed') { AppSnackbar.showError( 'فشل التنفيذ', (execution['message'] ?? 'تعذر تنفيذ الأمر داخليًا').toString(), ); } else { _executeAction(action, params, execution); } } on DioException catch (e) { 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, 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(); } } } Map _asStringMap(dynamic value) { if (value is Map) { return Map.from(value); } return {}; } void _executeAction( String action, Map params, Map execution, ) { switch (action) { case 'list_invoices': case 'search_invoice': _openInvoicesFromVoice(params, execution); break; case 'open_scanner': Get.toNamed(AppRoutes.SCANNER); break; case 'check_quota': _showQuotaResult(execution); break; case 'navigate': final screen = params['screen']?.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_status': case 'get_report': case 'export_pdf': AppSnackbar.showWarning( 'تم التنفيذ', 'تم تنفيذ الأمر على الخادم وسيتم تحسين عرض النتائج داخل التطبيق قريباً', ); break; default: AppSnackbar.showWarning( 'تنبيه', 'الأمر مفهوم ولكن لم يتم ربط الأكشن برمجياً بعد.'); } } void _openInvoicesFromVoice( Map params, Map execution, ) { final invoicesController = Get.isRegistered() ? Get.find() : Get.put(InvoicesController()); invoicesController.applyVoiceFilters(params); if (Get.isRegistered()) { Get.find().selectTab(1); } else { Get.offAllNamed(AppRoutes.MAIN); } final data = execution['data']; final count = data is Map ? data['count'] : null; final status = params['status']?.toString(); final suffix = count == null ? '' : ' ($count)'; final label = status == 'approved' ? 'المعتمدة' : status == 'extracted' ? 'الجاهزة' : 'المطابقة'; AppSnackbar.showSuccess('الفواتير', 'تم عرض الفواتير $label$suffix'); } void _showQuotaResult(Map execution) { final data = _asStringMap(execution['data']); if (data.isEmpty) { AppSnackbar.showWarning('الاشتراك', 'لم تصل تفاصيل الاشتراك من الخادم'); return; } final planName = (data['plan_name'] ?? 'غير معروف').toString(); final status = _subscriptionStatusLabel(data['status']); final days = data['days_remaining']; Get.bottomSheet( Container( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), decoration: BoxDecoration( color: Get.isDarkMode ? const Color(0xFF1E1E2E) : Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 18), Row( children: [ const Icon(Icons.workspace_premium_rounded, color: Color(0xFFD4AF37)), const SizedBox(width: 10), Expanded( child: Text( 'اشتراكك الحالي', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: Get.isDarkMode ? Colors.white : const Color(0xFF0F172A), ), ), ), ], ), const SizedBox(height: 14), Text( '$planName • $status${days == null ? '' : ' • $days يوم متبقي'}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Get.isDarkMode ? Colors.white70 : const Color(0xFF334155), ), ), const SizedBox(height: 18), _buildQuotaRow('الفواتير', data['invoices'], Icons.receipt_long), const SizedBox(height: 12), _buildQuotaRow('الشركات', data['companies'], Icons.business), const SizedBox(height: 12), _buildQuotaRow('المستخدمين', data['users'], Icons.people), ], ), ), ), isScrollControlled: true, ); } Widget _buildQuotaRow(String label, dynamic raw, IconData icon) { final item = _asStringMap(raw); final used = item['used'] ?? 0; final limit = item['limit'] ?? 0; final percent = ((item['percent'] as num?) ?? 0).clamp(0, 100).toDouble(); final warning = item['warning'] == true; final color = warning ? const Color(0xFFDC2626) : const Color(0xFF0F4C81); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Text( label, style: const TextStyle(fontWeight: FontWeight.w700), ), ), Text( '$used / $limit', style: TextStyle( fontWeight: FontWeight.w700, color: color, fontFamily: 'monospace', ), ), ], ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: percent / 100, minHeight: 7, color: color, backgroundColor: Get.isDarkMode ? Colors.white10 : const Color(0xFFE2E8F0), ), ), ], ); } String _subscriptionStatusLabel(dynamic status) { switch (status?.toString()) { case 'trial': return 'تجريبي'; case 'active': return 'فعّال'; case 'past_due': return 'متأخر الدفع'; case 'cancelled': return 'ملغي'; default: return 'غير معروف'; } } void _resetVoiceState() { _recordTimer?.cancel(); isVoiceRecording.value = false; isVoiceUploading.value = false; voiceRemainingSeconds.value = 15; voiceStatusText.value = 'اضغط "ابدأ التسجيل" ثم تحدّث'; } void refreshData() { _loadDashboardData(); } }