Update: 2026-05-07 00:14:28
This commit is contained in:
81
musadaq-app/lib/core/services/image_processing_service.dart
Normal file
81
musadaq-app/lib/core/services/image_processing_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
musadaq-app/lib/core/services/invoice_upload_service.dart
Normal file
76
musadaq-app/lib/core/services/invoice_upload_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,30 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
import '../../../core/utils/app_snackbar.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 {
|
class ScannerController extends GetxController {
|
||||||
var capturedImages = <File>[].obs;
|
var capturedImages = <File>[].obs;
|
||||||
var isProcessing = false.obs;
|
var isProcessing = false.obs;
|
||||||
|
var uploadProgress = 0.0.obs;
|
||||||
|
|
||||||
void addImage(String imagePath) {
|
final InvoiceUploadService _uploadService = InvoiceUploadService();
|
||||||
capturedImages.add(File(imagePath));
|
|
||||||
AppLogger.print('Added image to batch: $imagePath. Total: ${capturedImages.length}');
|
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) {
|
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) {
|
if (capturedImages.isEmpty) {
|
||||||
AppSnackbar.showWarning('تنبيه', 'الرجاء تصوير فاتورة واحدة على الأقل');
|
AppSnackbar.showWarning('تنبيه', 'الرجاء تصوير فاتورة واحدة على الأقل');
|
||||||
return;
|
return;
|
||||||
@@ -28,17 +44,28 @@ class ScannerController extends GetxController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isProcessing.value = true;
|
isProcessing.value = true;
|
||||||
|
uploadProgress.value = 0.0;
|
||||||
AppLogger.print('Uploading batch of ${capturedImages.length} images...');
|
AppLogger.print('Uploading batch of ${capturedImages.length} images...');
|
||||||
|
|
||||||
// TODO: Implement actual upload logic with Dio
|
final batchId = await _uploadService.uploadBatch(
|
||||||
await Future.delayed(const Duration(seconds: 2)); // Simulate
|
companyId: companyId,
|
||||||
|
images: capturedImages,
|
||||||
|
onProgress: (current, total) {
|
||||||
|
uploadProgress.value = current / total;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
AppSnackbar.showSuccess('نجاح', 'تم رفع ${capturedImages.length} فواتير للمعالجة بنجاح');
|
if (batchId != null) {
|
||||||
capturedImages.clear();
|
AppSnackbar.showSuccess('نجاح', 'تم رفع ${capturedImages.length} فواتير للمعالجة بنجاح');
|
||||||
Get.back(); // Go back to dashboard or previous screen
|
capturedImages.clear();
|
||||||
|
uploadProgress.value = 0.0;
|
||||||
|
Get.back(); // Go back to dashboard or previous screen
|
||||||
|
} else {
|
||||||
|
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
AppLogger.error('Failed to upload batch', e);
|
AppLogger.error('Failed to upload batch', e);
|
||||||
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
|
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing.value = false;
|
isProcessing.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ class ScannerView extends GetView<ScannerController> {
|
|||||||
: ElevatedButton.icon(
|
: ElevatedButton.icon(
|
||||||
onPressed: controller.isProcessing.value
|
onPressed: controller.isProcessing.value
|
||||||
? null
|
? null
|
||||||
: () => controller.uploadBatch(),
|
// TODO: Get actual company_id from user selection
|
||||||
|
: () => controller.uploadBatch('mock_company_id_123'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF0F4C81),
|
backgroundColor: const Color(0xFF0F4C81),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
@@ -146,11 +147,15 @@ class ScannerView extends GetView<ScannerController> {
|
|||||||
borderRadius: BorderRadius.circular(30)),
|
borderRadius: BorderRadius.circular(30)),
|
||||||
),
|
),
|
||||||
icon: controller.isProcessing.value
|
icon: controller.isProcessing.value
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
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),
|
: const Icon(Icons.cloud_upload, color: Colors.white),
|
||||||
label: Text(
|
label: Text(
|
||||||
'رفع ${controller.capturedImages.length} فواتير',
|
'رفع ${controller.capturedImages.length} فواتير',
|
||||||
|
|||||||
74
public/migrate_phase2.php
Normal file
74
public/migrate_phase2.php
Normal 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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user