311 lines
10 KiB
Dart
311 lines
10 KiB
Dart
/// A service that handles file storage operations using Firebase Storage.
|
|
///
|
|
/// This service provides functionality for:
|
|
/// - Receipt image upload and compression
|
|
/// - Profile image management
|
|
/// - File validation and optimization
|
|
/// - Automatic image compression to reduce storage costs
|
|
/// - Metadata management for uploaded files
|
|
///
|
|
/// The service automatically compresses images to JPEG format with 85% quality
|
|
/// to balance file size and image quality. It also generates unique filenames
|
|
/// and handles error logging through the ErrorService.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final storageService = StorageService();
|
|
///
|
|
/// // Upload a receipt image
|
|
/// final receiptUrl = await storageService.uploadReceiptImage(groupId, imageFile);
|
|
///
|
|
/// // Upload a profile image
|
|
/// final profileUrl = await storageService.uploadProfileImage(userId, imageFile);
|
|
///
|
|
/// // Delete a file
|
|
/// await storageService.deleteFile(fileUrl);
|
|
/// ```
|
|
library;
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:firebase_storage/firebase_storage.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:image/image.dart' as img;
|
|
import 'error_service.dart';
|
|
|
|
/// Service for managing file storage operations with Firebase Storage.
|
|
class StorageService {
|
|
/// Firebase Storage instance for file operations
|
|
final FirebaseStorage _storage;
|
|
|
|
/// Service for error handling and logging
|
|
final ErrorService _errorService;
|
|
|
|
/// Constructor for StorageService.
|
|
///
|
|
/// Args:
|
|
/// [storage]: Optional Firebase Storage instance (auto-created if null)
|
|
/// [errorService]: Optional error service instance (auto-created if null)
|
|
StorageService({
|
|
FirebaseStorage? storage,
|
|
ErrorService? errorService,
|
|
}) : _storage = storage ?? FirebaseStorage.instance,
|
|
_errorService = errorService ?? ErrorService();
|
|
|
|
/// Uploads a receipt image for an expense with automatic compression.
|
|
///
|
|
/// Validates the image file, compresses it to JPEG format with 85% quality,
|
|
/// generates a unique filename, and uploads it with appropriate metadata.
|
|
/// Monitors upload progress and logs it for debugging purposes.
|
|
///
|
|
/// Args:
|
|
/// [groupId]: ID of the group this receipt belongs to
|
|
/// [imageFile]: The image file to upload
|
|
///
|
|
/// Returns:
|
|
/// A Future<String> containing the download URL of the uploaded image
|
|
///
|
|
/// Throws:
|
|
/// Exception if file validation fails or upload encounters an error
|
|
Future<String> uploadReceiptImage(String groupId, File imageFile) async {
|
|
try {
|
|
// File validation
|
|
_validateImageFile(imageFile);
|
|
|
|
// Image compression
|
|
final compressedImage = await _compressImage(imageFile);
|
|
|
|
// Generate unique filename
|
|
final fileName = _generateReceiptFileName(groupId);
|
|
|
|
// Storage reference
|
|
final storageRef = _storage.ref().child('receipts/$groupId/$fileName');
|
|
|
|
// Metadata for cache optimization and compression info
|
|
final metadata = SettableMetadata(
|
|
contentType: 'image/jpeg',
|
|
customMetadata: {
|
|
'groupId': groupId,
|
|
'uploadedAt': DateTime.now().toIso8601String(),
|
|
'compressed': 'true',
|
|
},
|
|
);
|
|
|
|
// File upload
|
|
final uploadTask = storageRef.putData(compressedImage, metadata);
|
|
|
|
// Progress monitoring (optional)
|
|
uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
|
|
final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
|
_errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%');
|
|
});
|
|
|
|
// Wait for completion
|
|
final snapshot = await uploadTask;
|
|
|
|
// Get download URL
|
|
final downloadUrl = await snapshot.ref.getDownloadURL();
|
|
|
|
_errorService.logSuccess('StorageService', 'Image uploaded successfully: $fileName');
|
|
return downloadUrl;
|
|
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Error uploading image: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Deletes a receipt image from storage.
|
|
///
|
|
/// Extracts the storage reference from the provided URL and deletes the file.
|
|
/// Does not throw errors to avoid blocking expense deletion operations.
|
|
///
|
|
/// Args:
|
|
/// [imageUrl]: The download URL of the image to delete
|
|
Future<void> deleteReceiptImage(String imageUrl) async {
|
|
try {
|
|
if (imageUrl.isEmpty) return;
|
|
|
|
// Extract reference from URL
|
|
final ref = _storage.refFromURL(imageUrl);
|
|
await ref.delete();
|
|
|
|
_errorService.logSuccess('StorageService', 'Image deleted successfully');
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Error deleting image: $e');
|
|
// Don't rethrow to avoid blocking expense deletion
|
|
}
|
|
}
|
|
|
|
/// Compresses an image to optimize storage space and upload speed.
|
|
///
|
|
/// Reads the image file, decodes it, resizes it if too large (max 1024x1024),
|
|
/// and encodes it as JPEG with 85% quality for optimal balance between
|
|
/// file size and image quality.
|
|
///
|
|
/// Args:
|
|
/// [imageFile]: The image file to compress
|
|
///
|
|
/// Returns:
|
|
/// A Future<Uint8List> containing the compressed image bytes
|
|
///
|
|
/// Throws:
|
|
/// Exception if the image cannot be decoded or processed
|
|
Future<Uint8List> _compressImage(File imageFile) async {
|
|
try {
|
|
// Read image
|
|
final bytes = await imageFile.readAsBytes();
|
|
img.Image? image = img.decodeImage(bytes);
|
|
|
|
if (image == null) {
|
|
throw Exception('Unable to decode image');
|
|
}
|
|
|
|
// Resize if image is too large
|
|
const maxWidth = 1024;
|
|
const maxHeight = 1024;
|
|
|
|
if (image.width > maxWidth || image.height > maxHeight) {
|
|
image = img.copyResize(
|
|
image,
|
|
width: image.width > image.height ? maxWidth : null,
|
|
height: image.height > image.width ? maxHeight : null,
|
|
interpolation: img.Interpolation.linear,
|
|
);
|
|
}
|
|
|
|
// Encode as JPEG with compression
|
|
final compressedBytes = img.encodeJpg(image, quality: 85);
|
|
|
|
_errorService.logInfo('StorageService',
|
|
'Image compressed: ${bytes.length} → ${compressedBytes.length} bytes');
|
|
|
|
return Uint8List.fromList(compressedBytes);
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Error compressing image: $e');
|
|
// Fallback: return original image if compression fails
|
|
return await imageFile.readAsBytes();
|
|
}
|
|
}
|
|
|
|
/// Validates an image file before upload.
|
|
///
|
|
/// Checks file existence, size constraints (max 10MB), and file extension
|
|
/// to ensure only valid image files are processed for upload.
|
|
///
|
|
/// Args:
|
|
/// [imageFile]: The image file to validate
|
|
///
|
|
/// Throws:
|
|
/// Exception if validation fails (file doesn't exist, too large, or invalid extension)
|
|
void _validateImageFile(File imageFile) {
|
|
// Check if file exists
|
|
if (!imageFile.existsSync()) {
|
|
throw Exception('Image file does not exist');
|
|
}
|
|
|
|
// Check file size (max 10MB)
|
|
const maxSizeBytes = 10 * 1024 * 1024; // 10MB
|
|
final fileSize = imageFile.lengthSync();
|
|
if (fileSize > maxSizeBytes) {
|
|
throw Exception('File size exceeds 10MB limit');
|
|
}
|
|
|
|
// Check file extension
|
|
final extension = path.extension(imageFile.path).toLowerCase();
|
|
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
|
|
if (!allowedExtensions.contains(extension)) {
|
|
throw Exception('Unsupported image format. Use JPG, PNG or WebP');
|
|
}
|
|
}
|
|
|
|
/// Generates a unique filename for a receipt image.
|
|
///
|
|
/// Creates a filename using timestamp, microseconds, and group ID to ensure
|
|
/// uniqueness and prevent naming conflicts when multiple receipts are uploaded.
|
|
///
|
|
/// Args:
|
|
/// [groupId]: ID of the group this receipt belongs to
|
|
///
|
|
/// Returns:
|
|
/// A unique filename string for the receipt image
|
|
String _generateReceiptFileName(String groupId) {
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
final random = DateTime.now().microsecond;
|
|
return 'receipt_${groupId}_${timestamp}_$random.jpg';
|
|
}
|
|
|
|
/// Uploads multiple images simultaneously (for future features).
|
|
///
|
|
/// Processes multiple image files in parallel for batch upload scenarios.
|
|
/// Each image is validated, compressed, and uploaded with unique filenames.
|
|
///
|
|
/// Args:
|
|
/// [groupId]: ID of the group these images belong to
|
|
/// [imageFiles]: List of image files to upload
|
|
///
|
|
/// Returns:
|
|
/// A Future<List<String>> containing download URLs of uploaded images
|
|
Future<List<String>> uploadMultipleImages(
|
|
String groupId,
|
|
List<File> imageFiles,
|
|
) async {
|
|
final uploadTasks = imageFiles.map((file) => uploadReceiptImage(groupId, file));
|
|
return await Future.wait(uploadTasks);
|
|
}
|
|
|
|
/// Récupérer les métadonnées d'une image
|
|
Future<FullMetadata?> getImageMetadata(String imageUrl) async {
|
|
try {
|
|
final ref = _storage.refFromURL(imageUrl);
|
|
return await ref.getMetadata();
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Erreur récupération metadata: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Nettoyer les images orphelines d'un groupe
|
|
Future<void> cleanupGroupImages(String groupId) async {
|
|
try {
|
|
final groupRef = _storage.ref().child('receipts/$groupId');
|
|
final listResult = await groupRef.listAll();
|
|
|
|
for (final ref in listResult.items) {
|
|
// Vérifier l'âge du fichier
|
|
final metadata = await ref.getMetadata();
|
|
final uploadDate = metadata.timeCreated;
|
|
|
|
if (uploadDate != null) {
|
|
final daysSinceUpload = DateTime.now().difference(uploadDate).inDays;
|
|
|
|
// Supprimer les fichiers de plus de 30 jours sans dépense associée
|
|
if (daysSinceUpload > 30) {
|
|
await ref.delete();
|
|
_errorService.logInfo('StorageService', 'Image orpheline supprimée: ${ref.name}');
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Erreur nettoyage images: $e');
|
|
}
|
|
}
|
|
|
|
/// Calculer la taille totale utilisée par un groupe
|
|
Future<int> getGroupStorageSize(String groupId) async {
|
|
try {
|
|
final groupRef = _storage.ref().child('receipts/$groupId');
|
|
final listResult = await groupRef.listAll();
|
|
|
|
int totalSize = 0;
|
|
for (final ref in listResult.items) {
|
|
final metadata = await ref.getMetadata();
|
|
totalSize += metadata.size ?? 0;
|
|
}
|
|
|
|
return totalSize;
|
|
} catch (e) {
|
|
_errorService.logError('StorageService', 'Erreur calcul taille storage: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
} |