Update: 2026-05-08 05:24:38

This commit is contained in:
Hamza-Ayed
2026-05-08 05:24:38 +03:00
parent d2d345b6a0
commit df92a44878
11 changed files with 1245 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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" />

View File

@@ -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());
}),
),
];
}

View File

@@ -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';
}

View 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,
};
}
}

View File

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

View 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)),
],
),
),
);
}
}

View File

@@ -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, [

View File

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