/// 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); /// ``` 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 containing the download URL of the uploaded image /// /// Throws: /// Exception if file validation fails or upload encounters an error Future 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 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 containing the compressed image bytes /// /// Throws: /// Exception if the image cannot be decoded or processed Future _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> containing download URLs of uploaded images Future> uploadMultipleImages( String groupId, List 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 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 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 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; } } }