622 lines
21 KiB
Dart
622 lines
21 KiB
Dart
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<void> _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<void> 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<void> _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<void> _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<void> _uploadVoiceCommand(File audioFile) async {
|
|
isVoiceUploading.value = true;
|
|
|
|
try {
|
|
final result =
|
|
await _voiceAssistantService.processVoiceCommand(audioFile);
|
|
final intent = Map<String, dynamic>.from(
|
|
(result['intent'] as Map?) ?? <String, dynamic>{},
|
|
);
|
|
final execution = Map<String, dynamic>.from(
|
|
(result['execution'] as Map?) ?? <String, dynamic>{},
|
|
);
|
|
|
|
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<String, dynamic> _asStringMap(dynamic value) {
|
|
if (value is Map) {
|
|
return Map<String, dynamic>.from(value);
|
|
}
|
|
return <String, dynamic>{};
|
|
}
|
|
|
|
void _executeAction(
|
|
String action,
|
|
Map<String, dynamic> params,
|
|
Map<String, dynamic> 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<String, dynamic> params,
|
|
Map<String, dynamic> execution,
|
|
) {
|
|
final invoicesController = Get.isRegistered<InvoicesController>()
|
|
? Get.find<InvoicesController>()
|
|
: Get.put(InvoicesController());
|
|
|
|
invoicesController.applyVoiceFilters(params);
|
|
|
|
if (Get.isRegistered<MainShellController>()) {
|
|
Get.find<MainShellController>().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<String, dynamic> 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();
|
|
}
|
|
}
|