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 '../../../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;
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
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