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); } }