464 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|