From 2449e44cb0e50873c5da539f32b4ec69690783f9 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 7 May 2026 00:14:29 +0300 Subject: [PATCH] Update: 2026-05-07 00:14:28 --- .../services/image_processing_service.dart | 81 +++++++++++++++++++ .../core/services/invoice_upload_service.dart | 76 +++++++++++++++++ .../controllers/scanner_controller.dart | 47 ++++++++--- .../features/scanner/views/scanner_view.dart | 11 ++- public/migrate_phase2.php | 74 +++++++++++++++++ 5 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 musadaq-app/lib/core/services/image_processing_service.dart create mode 100644 musadaq-app/lib/core/services/invoice_upload_service.dart create mode 100644 public/migrate_phase2.php diff --git a/musadaq-app/lib/core/services/image_processing_service.dart b/musadaq-app/lib/core/services/image_processing_service.dart new file mode 100644 index 0000000..f0552d6 --- /dev/null +++ b/musadaq-app/lib/core/services/image_processing_service.dart @@ -0,0 +1,81 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import '../utils/logger.dart'; + +class ImageProcessingService { + /// Processes an image for OCR/AI extraction: + /// 1. Compresses the original image to reduce memory usage + /// 2. Converts to grayscale + /// 3. Increases contrast + /// 4. Saves as high-quality JPEG + static Future processInvoiceImage(File originalImage) async { + try { + AppLogger.print('Started processing image: ${originalImage.path}'); + + // Step 1: Initial compression using flutter_image_compress (Native, fast) + final dir = await getTemporaryDirectory(); + final compressedPath = path.join( + dir.path, 'compressed_${DateTime.now().millisecondsSinceEpoch}.jpg'); + + final XFile? compressedXFile = + await FlutterImageCompress.compressAndGetFile( + originalImage.path, + compressedPath, + quality: 85, + minWidth: 1920, + minHeight: 1080, + ); + + if (compressedXFile == null) { + AppLogger.error('Failed to compress image initially', null); + return originalImage; // Fallback to original + } + + File compressedFile = File(compressedXFile.path); + + // Step 2: Grayscale and Contrast using `image` package (in Isolate to avoid UI freeze) + final processedBytes = + await compute(_applyFilters, await compressedFile.readAsBytes()); + + if (processedBytes == null) { + AppLogger.error('Failed to apply filters', null); + return compressedFile; // Fallback to just compressed + } + + // Step 3: Save final processed image + final finalPath = path.join( + dir.path, 'processed_${DateTime.now().millisecondsSinceEpoch}.jpg'); + final finalFile = File(finalPath); + await finalFile.writeAsBytes(processedBytes); + + AppLogger.print('Finished processing image: ${finalFile.path}'); + return finalFile; + } catch (e, stack) { + AppLogger.error('Error processing image', e, stack); + return originalImage; // Fallback + } + } + + /// Runs in an isolate to prevent UI freezing + static List? _applyFilters(List bytes) { + try { + img.Image? decodedImage = img.decodeImage(Uint8List.fromList(bytes)); + if (decodedImage == null) return null; + + // Convert to Grayscale + img.grayscale(decodedImage); + + // Increase contrast (adjust as needed, e.g., 1.5) + img.contrast(decodedImage, contrast: 150); + + // Encode back to JPG + return img.encodeJpg(decodedImage, quality: 90); + } catch (e) { + return null; + } + } +} diff --git a/musadaq-app/lib/core/services/invoice_upload_service.dart b/musadaq-app/lib/core/services/invoice_upload_service.dart new file mode 100644 index 0000000..7bd5042 --- /dev/null +++ b/musadaq-app/lib/core/services/invoice_upload_service.dart @@ -0,0 +1,76 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import '../network/dio_client.dart'; +import '../utils/logger.dart'; + +class InvoiceUploadService { + final Dio _dio = DioClient().client; + + /// Uploads a batch of images to the server + /// Returns the batchId if successful, null otherwise. + Future uploadBatch({ + required String companyId, + required List images, + required Function(int current, int total) onProgress, + }) async { + try { + // 1. Create Batch + AppLogger.print('Creating new batch for company: $companyId'); + final createResponse = await _dio.post('batches/create', data: { + 'company_id': companyId, + 'total_images': images.length, + }); + + if (createResponse.statusCode != 200 && createResponse.statusCode != 201) { + throw Exception('Failed to create batch'); + } + + final String batchId = createResponse.data['data']['batch_id']; + AppLogger.print('Batch created successfully: $batchId'); + + // 2. Upload Images sequentially + for (int i = 0; i < images.length; i++) { + final file = images[i]; + final fileName = file.path.split('/').last; + + FormData formData = FormData.fromMap({ + 'batch_id': batchId, + 'image': await MultipartFile.fromFile(file.path, filename: fileName), + 'order_index': i + 1, + }); + + AppLogger.print('Uploading image ${i + 1}/${images.length}: $fileName'); + + await _dio.post( + 'batches/upload-image', + data: formData, + onSendProgress: (int sent, int total) { + // Can be used for detailed progress bar per image if needed + }, + ); + + onProgress(i + 1, images.length); + } + + // 3. Finalize Batch + AppLogger.print('Finalizing batch: $batchId'); + final finalizeResponse = await _dio.post('batches/finalize', data: { + 'batch_id': batchId, + }); + + if (finalizeResponse.statusCode != 200) { + throw Exception('Failed to finalize batch'); + } + + AppLogger.print('Batch finalized successfully!'); + return batchId; + + } on DioException catch (e) { + AppLogger.error('Upload batch failed (DioException)', e.response?.data); + return null; + } catch (e) { + AppLogger.error('Upload batch failed', e); + return null; + } + } +} diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart index d05810e..89f0787 100644 --- a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -4,14 +4,30 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../../../core/utils/logger.dart'; import '../../../core/utils/app_snackbar.dart'; +import '../../../core/services/image_processing_service.dart'; +import '../../../core/services/invoice_upload_service.dart'; class ScannerController extends GetxController { var capturedImages = [].obs; var isProcessing = false.obs; + var uploadProgress = 0.0.obs; - void addImage(String imagePath) { - capturedImages.add(File(imagePath)); - AppLogger.print('Added image to batch: $imagePath. Total: ${capturedImages.length}'); + final InvoiceUploadService _uploadService = InvoiceUploadService(); + + Future addImage(String imagePath) async { + isProcessing.value = true; + try { + File originalFile = File(imagePath); + // Process image (compress, grayscale, contrast) + File? processedFile = await ImageProcessingService.processInvoiceImage(originalFile); + + if (processedFile != null) { + capturedImages.add(processedFile); + AppLogger.print('Added processed image to batch. Total: ${capturedImages.length}'); + } + } finally { + isProcessing.value = false; + } } void removeImage(int index) { @@ -20,7 +36,7 @@ class ScannerController extends GetxController { } } - Future uploadBatch() async { + Future uploadBatch(String companyId) async { if (capturedImages.isEmpty) { AppSnackbar.showWarning('تنبيه', 'الرجاء تصوير فاتورة واحدة على الأقل'); return; @@ -28,17 +44,28 @@ class ScannerController extends GetxController { try { isProcessing.value = true; + uploadProgress.value = 0.0; AppLogger.print('Uploading batch of ${capturedImages.length} images...'); - // TODO: Implement actual upload logic with Dio - await Future.delayed(const Duration(seconds: 2)); // Simulate + final batchId = await _uploadService.uploadBatch( + companyId: companyId, + images: capturedImages, + onProgress: (current, total) { + uploadProgress.value = current / total; + }, + ); - AppSnackbar.showSuccess('نجاح', 'تم رفع ${capturedImages.length} فواتير للمعالجة بنجاح'); - capturedImages.clear(); - Get.back(); // Go back to dashboard or previous screen + if (batchId != null) { + AppSnackbar.showSuccess('نجاح', 'تم رفع ${capturedImages.length} فواتير للمعالجة بنجاح'); + capturedImages.clear(); + uploadProgress.value = 0.0; + Get.back(); // Go back to dashboard or previous screen + } else { + AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'); + } } catch (e) { AppLogger.error('Failed to upload batch', e); - AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'); + AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع'); } finally { isProcessing.value = false; } diff --git a/musadaq-app/lib/features/scanner/views/scanner_view.dart b/musadaq-app/lib/features/scanner/views/scanner_view.dart index 00a75b1..9e5dfd8 100644 --- a/musadaq-app/lib/features/scanner/views/scanner_view.dart +++ b/musadaq-app/lib/features/scanner/views/scanner_view.dart @@ -138,7 +138,8 @@ class ScannerView extends GetView { : ElevatedButton.icon( onPressed: controller.isProcessing.value ? null - : () => controller.uploadBatch(), + // TODO: Get actual company_id from user selection + : () => controller.uploadBatch('mock_company_id_123'), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF0F4C81), padding: const EdgeInsets.symmetric(vertical: 16), @@ -146,11 +147,15 @@ class ScannerView extends GetView { borderRadius: BorderRadius.circular(30)), ), icon: controller.isProcessing.value - ? const SizedBox( + ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( - color: Colors.white, strokeWidth: 2)) + value: controller.uploadProgress.value > 0 + ? controller.uploadProgress.value + : null, + color: Colors.white, + strokeWidth: 2)) : const Icon(Icons.cloud_upload, color: Colors.white), label: Text( 'رفع ${controller.capturedImages.length} فواتير', diff --git a/public/migrate_phase2.php b/public/migrate_phase2.php new file mode 100644 index 0000000..4a2757a --- /dev/null +++ b/public/migrate_phase2.php @@ -0,0 +1,74 @@ +exec(" + CREATE TABLE IF NOT EXISTS user_devices ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + device_fingerprint VARCHAR(64) NOT NULL, + device_name VARCHAR(100), + platform ENUM('android','ios') NOT NULL, + app_version VARCHAR(20), + push_token TEXT NULL, + device_secret VARCHAR(255) NULL, + is_trusted BOOLEAN DEFAULT FALSE, + last_seen_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_user_device (user_id, device_fingerprint) + ) + "); + echo "Created user_devices table.\n"; + + // 2. invoice_batches + $db->exec(" + CREATE TABLE IF NOT EXISTS invoice_batches ( + id CHAR(36) PRIMARY KEY, + tenant_id CHAR(36) NOT NULL, + company_id CHAR(36) NOT NULL, + uploaded_by CHAR(36) NOT NULL, + total_images INT NOT NULL DEFAULT 0, + processed_images INT NOT NULL DEFAULT 0, + status ENUM('uploading','processing','done','partial_fail') DEFAULT 'uploading', + source ENUM('mobile_scan','web_upload','whatsapp') DEFAULT 'mobile_scan', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL + ) + "); + echo "Created invoice_batches table.\n"; + + // 3. invoice_processing_queue + $db->exec(" + CREATE TABLE IF NOT EXISTS invoice_processing_queue ( + id INT AUTO_INCREMENT PRIMARY KEY, + batch_id CHAR(36) NOT NULL, + invoice_id CHAR(36) NULL, + tenant_id CHAR(36) NOT NULL, + image_path VARCHAR(500) NOT NULL, + status ENUM('pending','processing','done','failed') DEFAULT 'pending', + attempts INT DEFAULT 0, + error_message TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + FOREIGN KEY (batch_id) REFERENCES invoice_batches(id) ON DELETE CASCADE + ) + "); + $db->exec("CREATE INDEX IF NOT EXISTS idx_status_tenant ON invoice_processing_queue (status, tenant_id)"); + echo "Created invoice_processing_queue table.\n"; + + echo "Phase 2 Migrations completed successfully.\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +}