Update: 2026-05-07 00:14:28

This commit is contained in:
Hamza-Ayed
2026-05-07 00:14:29 +03:00
parent dd364fc918
commit 2449e44cb0
5 changed files with 276 additions and 13 deletions

View File

@@ -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<File?> 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<int>? _applyFilters(List<int> 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;
}
}
}

View File

@@ -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<String?> uploadBatch({
required String companyId,
required List<File> 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;
}
}
}

View File

@@ -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 = <File>[].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<void> 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<void> uploadBatch() async {
Future<void> 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;
}

View File

@@ -138,7 +138,8 @@ class ScannerView extends GetView<ScannerController> {
: 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<ScannerController> {
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} فواتير',

74
public/migrate_phase2.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/app/bootstrap/init.php';
use App\Core\Database;
try {
$db = Database::getInstance();
echo "Running Phase 2 Migrations...\n";
// 1. user_devices
$db->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";
}