Update: 2026-05-08 05:24:38
This commit is contained in:
@@ -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 = <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user