409 lines
14 KiB
Dart
409 lines
14 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';
|
|
|
|
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 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'] ?? '';
|
|
}
|
|
|
|
// 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 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<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 = Map<String, dynamic>.from(
|
|
(intent['params'] as Map?) ?? <String, dynamic>{},
|
|
);
|
|
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);
|
|
}
|
|
} 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _executeAction(String action, dynamic params) {
|
|
switch (action) {
|
|
case 'list_invoices':
|
|
case 'search_invoice':
|
|
Get.toNamed(AppRoutes.MAIN);
|
|
AppSnackbar.showWarning(
|
|
'معلومة', 'تم فتح الواجهة الرئيسية؛ عرض نتائج تفصيلي قادم');
|
|
break;
|
|
case 'open_scanner':
|
|
Get.toNamed(AppRoutes.SCANNER);
|
|
break;
|
|
case 'navigate':
|
|
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(
|
|
'تنبيه', 'الأمر مفهوم ولكن لم يتم ربط الأكشن برمجياً بعد.');
|
|
}
|
|
}
|
|
|
|
void _resetVoiceState() {
|
|
_recordTimer?.cancel();
|
|
isVoiceRecording.value = false;
|
|
isVoiceUploading.value = false;
|
|
voiceRemainingSeconds.value = 15;
|
|
voiceStatusText.value = 'اضغط "ابدأ التسجيل" ثم تحدّث';
|
|
}
|
|
|
|
void refreshData() {
|
|
_loadDashboardData();
|
|
}
|
|
}
|