feat: Add User and UserBalance models with serialization methods

feat: Implement BalanceRepository for group balance calculations

feat: Create ExpenseRepository for managing expenses

feat: Add services for handling expenses and storage operations

fix: Update import paths for models in repositories and services

refactor: Rename CountContent to AccountContent in HomePage

chore: Add StorageService for image upload and management
This commit is contained in:
Dayron
2025-10-21 16:02:58 +02:00
parent 62eb434548
commit 4edbd1cf34
60 changed files with 1973 additions and 342 deletions

View File

@@ -0,0 +1,214 @@
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';
class StorageService {
final FirebaseStorage _storage;
final ErrorService _errorService;
StorageService({
FirebaseStorage? storage,
ErrorService? errorService,
}) : _storage = storage ?? FirebaseStorage.instance,
_errorService = errorService ?? ErrorService();
/// Upload d'une image de reçu pour une dépense
Future<String> uploadReceiptImage(String groupId, File imageFile) async {
try {
// Validation du fichier
_validateImageFile(imageFile);
// Compression de l'image
final compressedImage = await _compressImage(imageFile);
// Génération du nom de fichier unique
final fileName = _generateReceiptFileName(groupId);
// Référence vers le storage
final storageRef = _storage.ref().child('receipts/$groupId/$fileName');
// Métadonnées pour optimiser le cache et la compression
final metadata = SettableMetadata(
contentType: 'image/jpeg',
customMetadata: {
'groupId': groupId,
'uploadedAt': DateTime.now().toIso8601String(),
'compressed': 'true',
},
);
// Upload du fichier
final uploadTask = storageRef.putData(compressedImage, metadata);
// Monitoring du progrès (optionnel)
uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
_errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%');
});
// Attendre la completion
final snapshot = await uploadTask;
// Récupérer l'URL de téléchargement
final downloadUrl = await snapshot.ref.getDownloadURL();
_errorService.logSuccess('StorageService', 'Image uploadée avec succès: $fileName');
return downloadUrl;
} catch (e) {
_errorService.logError('StorageService', 'Erreur upload image: $e');
rethrow;
}
}
/// Supprimer une image de reçu
Future<void> deleteReceiptImage(String imageUrl) async {
try {
if (imageUrl.isEmpty) return;
// Extraire la référence depuis l'URL
final ref = _storage.refFromURL(imageUrl);
await ref.delete();
_errorService.logSuccess('StorageService', 'Image supprimée avec succès');
} catch (e) {
_errorService.logError('StorageService', 'Erreur suppression image: $e');
// Ne pas rethrow pour éviter de bloquer la suppression de la dépense
}
}
/// Compresser une image pour optimiser le stockage
Future<Uint8List> _compressImage(File imageFile) async {
try {
// Lire l'image
final bytes = await imageFile.readAsBytes();
img.Image? image = img.decodeImage(bytes);
if (image == null) {
throw Exception('Impossible de décoder l\'image');
}
// Redimensionner si l'image est trop grande
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,
);
}
// Encoder en JPEG avec compression
final compressedBytes = img.encodeJpg(image, quality: 85);
_errorService.logInfo('StorageService',
'Image compressée: ${bytes.length}${compressedBytes.length} bytes');
return Uint8List.fromList(compressedBytes);
} catch (e) {
_errorService.logError('StorageService', 'Erreur compression image: $e');
// Fallback: retourner l'image originale si la compression échoue
return await imageFile.readAsBytes();
}
}
/// Valider le fichier image
void _validateImageFile(File imageFile) {
// Vérifier que le fichier existe
if (!imageFile.existsSync()) {
throw Exception('Le fichier image n\'existe pas');
}
// Vérifier la taille du fichier (max 10MB)
const maxSizeBytes = 10 * 1024 * 1024; // 10MB
final fileSize = imageFile.lengthSync();
if (fileSize > maxSizeBytes) {
throw Exception('La taille du fichier dépasse 10MB');
}
// Vérifier l'extension
final extension = path.extension(imageFile.path).toLowerCase();
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
if (!allowedExtensions.contains(extension)) {
throw Exception('Format d\'image non supporté. Utilisez JPG, PNG ou WebP');
}
}
/// Générer un nom de fichier unique pour un reçu
String _generateReceiptFileName(String groupId) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecond;
return 'receipt_${groupId}_${timestamp}_$random.jpg';
}
/// Upload multiple d'images (pour futures fonctionnalités)
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;
}
}
}