Update: 2026-05-08 05:24:38
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#16325C" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F5A623" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
139
musadaq-app/android/app/src/main/res/layout/widget_musadaq.xml
Normal file
139
musadaq-app/android/app/src/main/res/layout/widget_musadaq.xml
Normal file
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Musadaq Home Screen Widget Layout (Android) -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp"
|
||||
android:background="@drawable/widget_background">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="مُصادَق"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_last_update"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--:--"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#80FFFFFF" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<!-- Total Invoices -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_total_invoices"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#F5A623" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="الفواتير"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#B0FFFFFF" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Pending -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_pending"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FF6B6B" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="معلقة"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#B0FFFFFF" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Quota -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_quota"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0/0"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10B981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="الحصة"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#B0FFFFFF" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scan Button -->
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_scan_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/widget_button_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📸 مسح فاتورة"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#16325C" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:updatePeriodMillis="3600000"
|
||||
android:initialLayout="@layout/widget_musadaq"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:previewImage="@mipmap/launcher_icon"
|
||||
android:description="@string/app_name" />
|
||||
@@ -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());
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
106
musadaq-app/lib/core/services/home_widget_service.dart
Normal file
106
musadaq-app/lib/core/services/home_widget_service.dart
Normal file
@@ -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<void> 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<void> _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<dynamic> _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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
489
musadaq-app/lib/features/ar_scanner/views/ar_scanner_view.dart
Normal file
489
musadaq-app/lib/features/ar_scanner/views/ar_scanner_view.dart
Normal file
@@ -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<WifiScannerController> {
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,20 @@ class SettingsView extends GetView<SettingsController> {
|
||||
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, [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user