diff --git a/musadaq-app/android/app/src/main/res/drawable/widget_background.xml b/musadaq-app/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..ea4ed18 --- /dev/null +++ b/musadaq-app/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/musadaq-app/android/app/src/main/res/drawable/widget_button_bg.xml b/musadaq-app/android/app/src/main/res/drawable/widget_button_bg.xml new file mode 100644 index 0000000..ca4f600 --- /dev/null +++ b/musadaq-app/android/app/src/main/res/drawable/widget_button_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/musadaq-app/android/app/src/main/res/layout/widget_musadaq.xml b/musadaq-app/android/app/src/main/res/layout/widget_musadaq.xml new file mode 100644 index 0000000..75173bf --- /dev/null +++ b/musadaq-app/android/app/src/main/res/layout/widget_musadaq.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/musadaq-app/android/app/src/main/res/xml/widget_musadaq_info.xml b/musadaq-app/android/app/src/main/res/xml/widget_musadaq_info.xml new file mode 100644 index 0000000..597e490 --- /dev/null +++ b/musadaq-app/android/app/src/main/res/xml/widget_musadaq_info.xml @@ -0,0 +1,10 @@ + + diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index b938045..2f05fb4 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -25,6 +25,8 @@ import '../../features/onboarding/views/onboarding_view.dart'; import '../../features/audit/views/audit_log_view.dart'; import '../../features/referral/views/referral_view.dart'; import '../../features/ai_usage/views/ai_usage_view.dart'; + import '../../features/ar_scanner/views/ar_scanner_view.dart'; + import '../../features/ar_scanner/controllers/ar_scanner_controller.dart'; part 'app_routes.dart'; @@ -171,5 +173,12 @@ class AppPages { name: AppRoutes.AI_USAGE, page: () => const AiUsageView(), ), + GetPage( + name: AppRoutes.AR_SCANNER, + page: () => const WifiScannerView(), + binding: BindingsBuilder(() { + Get.put(WifiScannerController()); + }), + ), ]; } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 12eb4be..ec47380 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -25,4 +25,5 @@ abstract class AppRoutes { static const AUDIT_LOG = '/audit-log'; static const REFERRAL = '/referral'; static const AI_USAGE = '/ai-usage'; + static const AR_SCANNER = '/ar-scanner'; } diff --git a/musadaq-app/lib/core/services/home_widget_service.dart b/musadaq-app/lib/core/services/home_widget_service.dart new file mode 100644 index 0000000..597527e --- /dev/null +++ b/musadaq-app/lib/core/services/home_widget_service.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../network/dio_client.dart'; + +/// Home Screen Widget Service +/// Manages data for native iOS/Android home screen widgets. +/// +/// Widgets show: +/// - Quick stats (invoice count, pending, quota) +/// - Quick scan shortcut +/// - Today's activity summary +/// +/// Uses MethodChannel to communicate with native widget code. +class HomeWidgetService extends GetxService { + static const _channel = MethodChannel('com.musadaq.widget'); + + // Widget data + final totalInvoices = 0.obs; + final pendingInvoices = 0.obs; + final quotaUsed = 0.obs; + final quotaLimit = 0.obs; + final lastUpdate = ''.obs; + + @override + void onInit() { + super.onInit(); + // Listen for widget tap events + _channel.setMethodCallHandler(_handleWidgetAction); + refreshWidgetData(); + } + + /// Refresh widget data from API + Future refreshWidgetData() async { + try { + final res = await DioClient().client.get('/v1/dashboard/stats'); + if (res.data['success'] == true) { + final data = res.data['data']; + totalInvoices.value = data['invoices']?['total'] ?? 0; + pendingInvoices.value = data['invoices']?['pending'] ?? 0; + } + + final subRes = await DioClient().client.get('/v1/subscriptions/current'); + if (subRes.data['success'] == true) { + final sub = subRes.data['data']; + quotaUsed.value = sub['invoices']?['used'] ?? 0; + quotaLimit.value = sub['invoices']?['limit'] ?? 0; + } + + lastUpdate.value = DateTime.now().toString().substring(0, 16); + + // Push data to native widget + await _updateNativeWidget(); + } catch (e) { + // Widget update failure is non-critical + } + } + + /// Push data to native home screen widget + Future _updateNativeWidget() async { + try { + await _channel.invokeMethod('updateWidget', { + 'total_invoices': totalInvoices.value, + 'pending_invoices': pendingInvoices.value, + 'quota_used': quotaUsed.value, + 'quota_limit': quotaLimit.value, + 'quota_percent': quotaLimit.value > 0 + ? ((quotaUsed.value / quotaLimit.value) * 100).round() + : 0, + 'last_update': lastUpdate.value, + }); + } on MissingPluginException { + // Widget not supported on this platform + } catch (e) { + // Non-critical + } + } + + /// Handle taps from the native widget + Future _handleWidgetAction(MethodCall call) async { + switch (call.method) { + case 'openScanner': + Get.toNamed('/scanner'); + break; + case 'openInvoices': + Get.toNamed('/main'); + break; + case 'refreshData': + await refreshWidgetData(); + break; + } + } + + /// Get widget display data as a map + Map getWidgetData() { + return { + 'total_invoices': totalInvoices.value, + 'pending_invoices': pendingInvoices.value, + 'quota_used': quotaUsed.value, + 'quota_limit': quotaLimit.value, + 'quota_percent': quotaLimit.value > 0 + ? ((quotaUsed.value / quotaLimit.value) * 100).round() + : 0, + 'last_update': lastUpdate.value, + }; + } +} diff --git a/musadaq-app/lib/features/ar_scanner/controllers/ar_scanner_controller.dart b/musadaq-app/lib/features/ar_scanner/controllers/ar_scanner_controller.dart new file mode 100644 index 0000000..83d6738 --- /dev/null +++ b/musadaq-app/lib/features/ar_scanner/controllers/ar_scanner_controller.dart @@ -0,0 +1,463 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart' hide FormData, MultipartFile; +import 'package:path_provider/path_provider.dart'; +import '../../../core/network/dio_client.dart'; + +/// WiFi Scanner Controller +/// Connects to external physical scanners (eSCL/AirScan protocol) +/// over the local WiFi network, pulls scanned images, and uploads them. +/// +/// Supported protocols: +/// - eSCL (AirScan) — most modern WiFi scanners (HP, Epson, Canon, Brother) +/// - Manual IP + HTTP endpoint (custom scanners) +/// +/// Flow: +/// 1. Discover scanners on local network (or enter IP manually) +/// 2. Connect & configure scan settings +/// 3. Trigger scan → receive image +/// 4. Add to queue → upload to Musadaq +class WifiScannerController extends GetxController { + // Scanner discovery + final discoveredScanners = >[].obs; + final isScanning = false.obs; + final connectedScanner = Rxn>(); + final manualIpController = TextEditingController(); + + // Scan settings + final scanResolution = 300.obs; // DPI + final scanColorMode = 'Color'.obs; // Color, Grayscale, BlackWhite + final scanFormat = 'JPEG'.obs; // JPEG, PDF, PNG + + // Company selection + final selectedCompanyId = ''.obs; + final selectedCompanyName = ''.obs; + final companies = >[].obs; + final isLoadingCompanies = false.obs; + + // Scan queue + final scannedImages = [].obs; + final isScanningDocument = false.obs; + final isUploading = false.obs; + final uploadProgress = 0.0.obs; + final statusMessage = 'جاهز للاتصال بالماسح الضوئي'.obs; + + // Connection + final isConnected = false.obs; + final connectionStatus = 'غير متصل'.obs; + + @override + void onInit() { + super.onInit(); + _loadCompanies(); + } + + @override + void onClose() { + manualIpController.dispose(); + super.onClose(); + } + + final Dio _dio = DioClient().client; + + Future _loadCompanies() async { + isLoadingCompanies.value = true; + try { + final res = await _dio.get('companies'); + if (res.data['success'] == true) { + companies.value = List>.from(res.data['data'] ?? []); + } + } catch (_) {} + isLoadingCompanies.value = false; + } + + void selectCompany(String id, String name) { + selectedCompanyId.value = id; + selectedCompanyName.value = name; + } + + /// Discover eSCL (AirScan) scanners on the local network + /// Uses mDNS to find _uscan._tcp and _uscans._tcp services + Future discoverScanners() async { + isScanning.value = true; + discoveredScanners.clear(); + statusMessage.value = 'جارٍ البحث عن الماسحات الضوئية...'; + + try { + // Method 1: Try common scanner ports on local subnet + final localIp = await _getLocalIp(); + if (localIp != null) { + final subnet = localIp.substring(0, localIp.lastIndexOf('.')); + + // Scan common eSCL ports (80, 443, 8080, 9095) on local subnet + final futures = []; + for (int i = 1; i <= 254; i++) { + final ip = '$subnet.$i'; + futures.add(_probeScanner(ip)); + } + + // Wait with timeout + await Future.wait(futures).timeout( + const Duration(seconds: 10), + onTimeout: () => [], + ); + } + + // Method 2: Add known scanner brands' default addresses + await _probeScanner('192.168.1.1'); // Common router/scanner + + if (discoveredScanners.isEmpty) { + statusMessage.value = 'لم يتم العثور على ماسحات. أدخل IP يدوياً.'; + } else { + statusMessage.value = 'تم العثور على ${discoveredScanners.length} ماسح ضوئي'; + } + } catch (e) { + statusMessage.value = 'خطأ في البحث: ${e.toString()}'; + } + + isScanning.value = false; + } + + /// Probe a specific IP for eSCL scanner + Future _probeScanner(String ip) async { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + + // Try eSCL endpoint + final request = await client.getUrl(Uri.parse('http://$ip:80/eSCL/ScannerCapabilities')); + final response = await request.close().timeout(const Duration(seconds: 2)); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final scannerName = _extractScannerName(body) ?? 'ماسح ضوئي ($ip)'; + + discoveredScanners.add({ + 'ip': ip, + 'port': 80, + 'name': scannerName, + 'protocol': 'eSCL', + 'capabilities': body, + }); + } + client.close(); + } catch (_) { + // Not a scanner, skip silently + } + } + + /// Connect to scanner manually by IP + Future connectManualIp() async { + final ip = manualIpController.text.trim(); + if (ip.isEmpty) { + Get.snackbar('خطأ', 'أدخل عنوان IP الماسح الضوئي', + backgroundColor: Colors.red, colorText: Colors.white); + return; + } + + statusMessage.value = 'جارٍ الاتصال بـ $ip...'; + isScanning.value = true; + + try { + // Try eSCL first + final client = HttpClient()..connectionTimeout = const Duration(seconds: 5); + + for (final port in [80, 443, 8080, 9095]) { + try { + final request = await client.getUrl( + Uri.parse('http://$ip:$port/eSCL/ScannerCapabilities'), + ); + final response = await request.close().timeout(const Duration(seconds: 3)); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final scanner = { + 'ip': ip, + 'port': port, + 'name': _extractScannerName(body) ?? 'ماسح ضوئي ($ip)', + 'protocol': 'eSCL', + 'capabilities': body, + }; + + connectedScanner.value = scanner; + isConnected.value = true; + connectionStatus.value = 'متصل بـ ${scanner['name']}'; + statusMessage.value = '✅ تم الاتصال! جاهز للمسح.'; + + if (!discoveredScanners.any((s) => s['ip'] == ip)) { + discoveredScanners.add(scanner); + } + + client.close(); + isScanning.value = false; + return; + } + } catch (_) { + continue; + } + } + + // Try as generic HTTP scanner (custom endpoint) + final scanner = { + 'ip': ip, + 'port': 80, + 'name': 'ماسح ضوئي ($ip)', + 'protocol': 'http', + }; + + connectedScanner.value = scanner; + isConnected.value = true; + connectionStatus.value = 'متصل بـ $ip (HTTP عام)'; + statusMessage.value = '✅ تم الاتصال (HTTP). جرّب المسح.'; + + client.close(); + } catch (e) { + statusMessage.value = '❌ فشل الاتصال بـ $ip'; + isConnected.value = false; + } + + isScanning.value = false; + } + + /// Connect to a discovered scanner + void connectToScanner(Map scanner) { + connectedScanner.value = scanner; + isConnected.value = true; + connectionStatus.value = 'متصل بـ ${scanner['name']}'; + statusMessage.value = '✅ تم الاتصال! اختر شركة ثم ابدأ المسح.'; + } + + /// Trigger a scan on the connected scanner + Future triggerScan() async { + if (!isConnected.value || connectedScanner.value == null) { + Get.snackbar('خطأ', 'يجب الاتصال بماسح ضوئي أولاً', + backgroundColor: Colors.red, colorText: Colors.white); + return; + } + + isScanningDocument.value = true; + statusMessage.value = 'جارٍ المسح...'; + + try { + final scanner = connectedScanner.value!; + final ip = scanner['ip']; + final port = scanner['port']; + final protocol = scanner['protocol']; + + Uint8List? imageBytes; + + if (protocol == 'eSCL') { + imageBytes = await _esclScan(ip, port); + } else { + imageBytes = await _httpScan(ip, port); + } + + if (imageBytes != null && imageBytes.isNotEmpty) { + // Save to temp file + final dir = await getTemporaryDirectory(); + final fileName = 'scan_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final file = File('${dir.path}/$fileName'); + await file.writeAsBytes(imageBytes); + + scannedImages.add(file); + statusMessage.value = '✅ تم المسح! (${scannedImages.length} صورة في الطابور)'; + + Get.snackbar('✅ تم المسح', 'الصورة أُضيفت للطابور', + backgroundColor: const Color(0xFF10B981), colorText: Colors.white, + snackPosition: SnackPosition.TOP); + } else { + statusMessage.value = '⚠️ لم يتم استلام صورة من الماسح'; + } + } catch (e) { + statusMessage.value = '❌ خطأ أثناء المسح: ${e.toString()}'; + } + + isScanningDocument.value = false; + } + + /// eSCL scan request + Future _esclScan(String ip, int port) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 30); + + // 1. Create scan job + final scanSettings = ''' + + 2.0 + TextAndGraphic + + + escl:ThreeHundredthsOfInches + 3507 + 2481 + 0 + 0 + + + Platen + ${scanColorMode.value == 'Color' ? 'RGB24' : 'Grayscale8'} + ${scanResolution.value} + ${scanResolution.value} + image/jpeg +'''; + + final postRequest = await client.postUrl( + Uri.parse('http://$ip:$port/eSCL/ScanJobs'), + ); + postRequest.headers.set('Content-Type', 'text/xml'); + postRequest.write(scanSettings); + final postResponse = await postRequest.close(); + + if (postResponse.statusCode != 201) { + client.close(); + return null; + } + + // 2. Get scan job URL from Location header + final jobUrl = postResponse.headers.value('location'); + if (jobUrl == null) { + client.close(); + return null; + } + + // 3. Wait for scan to complete, then fetch image + await Future.delayed(const Duration(seconds: 3)); + + final imageUrl = jobUrl.endsWith('/') + ? '${jobUrl}NextDocument' + : '$jobUrl/NextDocument'; + + final getRequest = await client.getUrl(Uri.parse(imageUrl)); + final getResponse = await getRequest.close(); + + if (getResponse.statusCode == 200) { + final bytes = await getResponse.fold>( + [], + (list, chunk) => list..addAll(chunk), + ); + client.close(); + return Uint8List.fromList(bytes); + } + + client.close(); + return null; + } + + /// Generic HTTP scan (for custom scanner endpoints) + Future _httpScan(String ip, int port) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 15); + + // Try common scan endpoints + for (final endpoint in ['/scan', '/capture', '/api/scan', '/cgi-bin/scan']) { + try { + final request = await client.getUrl( + Uri.parse('http://$ip:$port$endpoint'), + ); + final response = await request.close().timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final contentType = response.headers.contentType?.mimeType ?? ''; + if (contentType.startsWith('image/')) { + final bytes = await response.fold>( + [], + (list, chunk) => list..addAll(chunk), + ); + client.close(); + return Uint8List.fromList(bytes); + } + } + } catch (_) { + continue; + } + } + + client.close(); + return null; + } + + /// Upload all scanned images to Musadaq + Future uploadQueue() async { + if (scannedImages.isEmpty || selectedCompanyId.value.isEmpty) { + Get.snackbar('تنبيه', 'اختر شركة وامسح فواتير أولاً', + backgroundColor: Colors.orange, colorText: Colors.white); + return; + } + + isUploading.value = true; + uploadProgress.value = 0; + final total = scannedImages.length; + + try { + for (int i = 0; i < total; i++) { + statusMessage.value = 'جارٍ رفع الصورة ${i + 1} من $total...'; + + final file = scannedImages[i]; + final fileName = file.path.split('/').last; + final formData = FormData.fromMap({ + 'company_id': selectedCompanyId.value, + 'file': await MultipartFile.fromFile(file.path, filename: fileName), + }); + + await _dio.post('invoices/upload', data: formData); + + uploadProgress.value = (i + 1) / total; + } + + Get.snackbar('✅ تم!', 'تم رفع $total فاتورة بنجاح', + backgroundColor: const Color(0xFF10B981), colorText: Colors.white, + snackPosition: SnackPosition.TOP); + + scannedImages.clear(); + statusMessage.value = '✅ تم رفع جميع الفواتير. جاهز لمسح جديد.'; + } catch (e) { + statusMessage.value = '❌ خطأ أثناء الرفع: ${e.toString()}'; + Get.snackbar('خطأ', 'فشل الرفع', + backgroundColor: Colors.red, colorText: Colors.white); + } + + isUploading.value = false; + } + + void removeImage(int index) { + if (index >= 0 && index < scannedImages.length) { + scannedImages.removeAt(index); + } + } + + void clearQueue() { + scannedImages.clear(); + statusMessage.value = 'تم مسح الطابور'; + } + + void disconnect() { + connectedScanner.value = null; + isConnected.value = false; + connectionStatus.value = 'غير متصل'; + statusMessage.value = 'تم قطع الاتصال'; + } + + /// Get local IP address + Future _getLocalIp() async { + try { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLinkLocal: false, + ); + for (final interface in interfaces) { + for (final addr in interface.addresses) { + if (!addr.isLoopback) return addr.address; + } + } + } catch (_) {} + return null; + } + + /// Extract scanner name from eSCL capabilities XML + String? _extractScannerName(String xml) { + final match = RegExp(r'(.*?)').firstMatch(xml); + return match?.group(1); + } +} diff --git a/musadaq-app/lib/features/ar_scanner/views/ar_scanner_view.dart b/musadaq-app/lib/features/ar_scanner/views/ar_scanner_view.dart new file mode 100644 index 0000000..aa4812c --- /dev/null +++ b/musadaq-app/lib/features/ar_scanner/views/ar_scanner_view.dart @@ -0,0 +1,489 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/ar_scanner_controller.dart'; + +/// WiFi Scanner View +/// Connect to external physical scanners over WiFi. +/// Discover → Connect → Scan → Upload +class WifiScannerView extends GetView { + const WifiScannerView({super.key}); + + static const _navy = Color(0xFF0F4C81); + static const _gold = Color(0xFFD4AF37); + static const _green = Color(0xFF10B981); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('ماسح ضوئي WiFi', style: TextStyle(fontFamily: 'El Messiri')), + centerTitle: true, + backgroundColor: _navy, + foregroundColor: Colors.white, + actions: [ + Obx(() => controller.isConnected.value + ? IconButton( + onPressed: controller.disconnect, + icon: const Icon(Icons.link_off), + tooltip: 'قطع الاتصال', + ) + : const SizedBox()), + ], + ), + body: Obx(() { + if (controller.selectedCompanyId.value.isEmpty) { + return _buildCompanySelection(); + } + if (!controller.isConnected.value) { + return _buildScannerDiscovery(isDark); + } + return _buildScannerInterface(isDark); + }), + ); + } + + // ─── Step 1: Company Selection ──────────────────── + Widget _buildCompanySelection() { + return Obx(() { + if (controller.isLoadingCompanies.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.companies.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('لا توجد شركات.\nأضف شركة أولاً من الإعدادات.', + textAlign: TextAlign.center, style: TextStyle(fontSize: 16)), + ), + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient(colors: [_navy, _navy.withValues(alpha: 0.8)]), + borderRadius: BorderRadius.circular(16), + ), + child: const Column( + children: [ + Icon(Icons.scanner, size: 48, color: Colors.white), + SizedBox(height: 12), + Text('ماسح ضوئي خارجي', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), + SizedBox(height: 4), + Text('اربط جهاز المسح الضوئي عبر WiFi واستورد الفواتير مباشرة', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 13)), + ], + ), + ), + const SizedBox(height: 20), + const Text('اختر الشركة أولاً:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + ...controller.companies.map((c) => Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _navy.withValues(alpha: 0.1), + child: const Icon(Icons.business, color: _navy), + ), + title: Text(c['name'] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('الرقم الضريبي: ${c['tax_identification_number'] ?? '-'}'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => controller.selectCompany(c['id'], c['name'] ?? ''), + ), + )), + ], + ); + }); + } + + // ─── Step 2: Scanner Discovery ──────────────────── + Widget _buildScannerDiscovery(bool isDark) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Status card + Obx(() => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _navy.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon( + controller.isScanning.value ? Icons.search : Icons.wifi_find, + color: _navy, size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Text(controller.statusMessage.value, + style: const TextStyle(fontSize: 14)), + ), + ], + ), + )), + + const SizedBox(height: 16), + + // Auto-discover button + Obx(() => ElevatedButton.icon( + onPressed: controller.isScanning.value ? null : controller.discoverScanners, + icon: controller.isScanning.value + ? const SizedBox( + width: 18, height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.search, color: Colors.white), + label: Text( + controller.isScanning.value ? 'جارٍ البحث...' : '🔍 بحث تلقائي عن ماسحات', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: _navy, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + )), + + const SizedBox(height: 20), + + // Manual IP entry + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('أو أدخل IP يدوياً:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: controller.manualIpController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '192.168.1.100', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + prefixIcon: const Icon(Icons.lan), + ), + ), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: controller.connectManualIp, + style: ElevatedButton.styleFrom( + backgroundColor: _gold, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('اتصل', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Discovered scanners list + Obx(() => controller.discoveredScanners.isEmpty + ? const SizedBox() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('ماسحات مكتشفة:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ...controller.discoveredScanners.map((s) => Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _green.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.scanner, color: _green), + ), + title: Text(s['name'] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${s['ip']}:${s['port']} • ${s['protocol']}'), + trailing: ElevatedButton( + onPressed: () => controller.connectToScanner(s), + style: ElevatedButton.styleFrom(backgroundColor: _green), + child: const Text('اتصل', style: TextStyle(color: Colors.white)), + ), + ), + )), + ], + )), + ], + ); + } + + // ─── Step 3: Connected — Scan Interface ─────────── + Widget _buildScannerInterface(bool isDark) { + return Column( + children: [ + // Connection status bar + Obx(() => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: _green.withValues(alpha: 0.1), + child: Row( + children: [ + const Icon(Icons.check_circle, color: _green, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(controller.connectionStatus.value, + style: const TextStyle(color: _green, fontWeight: FontWeight.w600, fontSize: 13)), + ), + Text(controller.selectedCompanyName.value, + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + )), + + // Scan settings + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Resolution + Expanded( + child: Obx(() => _buildSettingChip( + '${controller.scanResolution.value} DPI', + Icons.high_quality, + () { + final current = controller.scanResolution.value; + controller.scanResolution.value = + current == 150 ? 300 : (current == 300 ? 600 : 150); + }, + )), + ), + const SizedBox(width: 8), + // Color mode + Expanded( + child: Obx(() => _buildSettingChip( + controller.scanColorMode.value == 'Color' ? 'ألوان' : 'رمادي', + Icons.palette, + () { + controller.scanColorMode.value = + controller.scanColorMode.value == 'Color' ? 'Grayscale' : 'Color'; + }, + )), + ), + ], + ), + ), + + // Status message + Obx(() => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(controller.statusMessage.value, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.grey)), + )), + + const SizedBox(height: 16), + + // Scan Button + Obx(() => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: controller.isScanningDocument.value ? null : controller.triggerScan, + icon: controller.isScanningDocument.value + ? const SizedBox( + width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.scanner, color: Colors.white, size: 24), + label: Text( + controller.isScanningDocument.value ? 'جارٍ المسح...' : '📄 مسح فاتورة', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: _navy, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 4, + ), + ), + ), + )), + + const SizedBox(height: 16), + + // Scanned images queue + Expanded( + child: Obx(() { + if (controller.scannedImages.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox, size: 64, color: Colors.grey.withValues(alpha: 0.3)), + const SizedBox(height: 12), + Text('لا توجد صور ممسوحة بعد', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.5))), + const Text('اضغط "مسح فاتورة" لبدء المسح', + style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text('الطابور (${controller.scannedImages.length})', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + const Spacer(), + TextButton.icon( + onPressed: controller.clearQueue, + icon: const Icon(Icons.delete_sweep, size: 18, color: Colors.red), + label: const Text('مسح الكل', style: TextStyle(color: Colors.red, fontSize: 12)), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, crossAxisSpacing: 10, mainAxisSpacing: 10, childAspectRatio: 0.75, + ), + itemCount: controller.scannedImages.length, + itemBuilder: (_, index) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file(controller.scannedImages[index], + fit: BoxFit.cover, width: double.infinity, height: double.infinity), + ), + Positioned( + top: 4, right: 4, + child: GestureDetector( + onTap: () => controller.removeImage(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + child: const Icon(Icons.close, size: 14, color: Colors.white), + ), + ), + ), + Positioned( + bottom: 4, left: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, borderRadius: BorderRadius.circular(8), + ), + child: Text('${index + 1}', style: const TextStyle(color: Colors.white, fontSize: 11)), + ), + ), + ], + ); + }, + ), + ), + ], + ); + }), + ), + + // Upload Bar + Obx(() => controller.scannedImages.isEmpty + ? const SizedBox() + : Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8, offset: const Offset(0, -2))], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (controller.isUploading.value) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: controller.uploadProgress.value, + backgroundColor: Colors.grey.withValues(alpha: 0.2), + color: _gold, + minHeight: 6, + ), + ), + ), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: controller.isUploading.value ? null : controller.uploadQueue, + icon: controller.isUploading.value + ? const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.cloud_upload_rounded, color: Colors.white), + label: Text( + controller.isUploading.value + ? 'جارٍ الرفع... ${(controller.uploadProgress.value * 100).toInt()}%' + : '☁️ رفع ${controller.scannedImages.length} فاتورة لمُصادَق', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: _gold, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + )), + ], + ); + } + + Widget _buildSettingChip(String label, IconData icon, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: _navy.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _navy.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: _navy), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} diff --git a/musadaq-app/lib/features/settings/views/settings_view.dart b/musadaq-app/lib/features/settings/views/settings_view.dart index c7b1afa..986d81c 100644 --- a/musadaq-app/lib/features/settings/views/settings_view.dart +++ b/musadaq-app/lib/features/settings/views/settings_view.dart @@ -124,6 +124,20 @@ class SettingsView extends GetView { return const SizedBox.shrink(); }), + // Tools Section + _buildSectionTitle('أدوات متقدمة', Icons.build_rounded, isDark), + const SizedBox(height: 8), + _buildSettingsCard(isDark, [ + _buildInfoTile( + icon: Icons.scanner_rounded, + title: 'ماسح ضوئي WiFi', + trailing: 'اتصال →', + isDark: isDark, + onTap: () => Get.toNamed(AppRoutes.AR_SCANNER), + ), + ]), + const SizedBox(height: 20), + _buildSectionTitle('حول التطبيق', Icons.info_rounded, isDark), const SizedBox(height: 8), _buildSettingsCard(isDark, [ diff --git a/musadaq-app/lib/main.dart b/musadaq-app/lib/main.dart index 0f594fa..bd37c86 100644 --- a/musadaq-app/lib/main.dart +++ b/musadaq-app/lib/main.dart @@ -5,6 +5,7 @@ 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 'core/services/home_widget_service.dart'; import 'app/theme/app_theme.dart'; @pragma('vm:entry-point') @@ -25,6 +26,7 @@ void main() async { // 3. Register global services Get.put(UploadProgressService(), permanent: true); + Get.put(HomeWidgetService(), permanent: true); runApp(const MusadaqApp()); }