Files
musadaq-saas/musadaq-app/lib/features/ar_scanner/controllers/ar_scanner_controller.dart
2026-05-08 05:24:38 +03:00

464 lines
15 KiB
Dart

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 = <Map<String, dynamic>>[].obs;
final isScanning = false.obs;
final connectedScanner = Rxn<Map<String, dynamic>>();
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 = <Map<String, dynamic>>[].obs;
final isLoadingCompanies = false.obs;
// Scan queue
final scannedImages = <File>[].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<void> _loadCompanies() async {
isLoadingCompanies.value = true;
try {
final res = await _dio.get('companies');
if (res.data['success'] == true) {
companies.value = List<Map<String, dynamic>>.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<void> 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 = <Future>[];
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<void> _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<void> 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<String, dynamic> scanner) {
connectedScanner.value = scanner;
isConnected.value = true;
connectionStatus.value = 'متصل بـ ${scanner['name']}';
statusMessage.value = '✅ تم الاتصال! اختر شركة ثم ابدأ المسح.';
}
/// Trigger a scan on the connected scanner
Future<void> 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<Uint8List?> _esclScan(String ip, int port) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 30);
// 1. Create scan job
final scanSettings = '''<?xml version="1.0" encoding="UTF-8"?>
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03"
xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
<pwg:Version>2.0</pwg:Version>
<scan:Intent>TextAndGraphic</scan:Intent>
<pwg:ScanRegions>
<pwg:ScanRegion>
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
<pwg:Height>3507</pwg:Height>
<pwg:Width>2481</pwg:Width>
<pwg:XOffset>0</pwg:XOffset>
<pwg:YOffset>0</pwg:YOffset>
</pwg:ScanRegion>
</pwg:ScanRegions>
<pwg:InputSource>Platen</pwg:InputSource>
<scan:ColorMode>${scanColorMode.value == 'Color' ? 'RGB24' : 'Grayscale8'}</scan:ColorMode>
<scan:XResolution>${scanResolution.value}</scan:XResolution>
<scan:YResolution>${scanResolution.value}</scan:YResolution>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
</scan:ScanSettings>''';
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<int>>(
<int>[],
(list, chunk) => list..addAll(chunk),
);
client.close();
return Uint8List.fromList(bytes);
}
client.close();
return null;
}
/// Generic HTTP scan (for custom scanner endpoints)
Future<Uint8List?> _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<int>>(
<int>[],
(list, chunk) => list..addAll(chunk),
);
client.close();
return Uint8List.fromList(bytes);
}
}
} catch (_) {
continue;
}
}
client.close();
return null;
}
/// Upload all scanned images to Musadaq
Future<void> 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<String?> _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'<pwg:MakeAndModel>(.*?)</pwg:MakeAndModel>').firstMatch(xml);
return match?.group(1);
}
}