feat: Add logger service and improve expense dialog with enhanced receipt management and calculation logic.

This commit is contained in:
Van Leemput Dayron
2025-11-28 12:54:54 +01:00
parent cad9d42128
commit fd710b8cb8
35 changed files with 2148 additions and 1296 deletions

View File

@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/activity.dart';
import '../services/error_service.dart';
import '../services/logger_service.dart';
/// Service pour rechercher des activités touristiques via Google Places API
class ActivityPlacesService {
@@ -24,7 +25,7 @@ class ActivityPlacesService {
int offset = 0,
}) async {
try {
print(
LoggerService.info(
'ActivityPlacesService: Recherche d\'activités pour: $destination (max: $maxResults, offset: $offset)',
);
@@ -69,7 +70,7 @@ class ActivityPlacesService {
final uniqueActivities = _removeDuplicates(allActivities);
uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0));
print(
LoggerService.info(
'ActivityPlacesService: ${uniqueActivities.length} activités trouvées au total',
);
@@ -81,20 +82,22 @@ class ActivityPlacesService {
);
if (startIndex >= uniqueActivities.length) {
print(
LoggerService.info(
'ActivityPlacesService: Offset $startIndex dépasse le nombre total (${uniqueActivities.length})',
);
return [];
}
final paginatedResults = uniqueActivities.sublist(startIndex, endIndex);
print(
LoggerService.info(
'ActivityPlacesService: Retour de ${paginatedResults.length} activités (offset: $offset, max: $maxResults)',
);
return paginatedResults;
} catch (e) {
print('ActivityPlacesService: Erreur lors de la recherche: $e');
LoggerService.error(
'ActivityPlacesService: Erreur lors de la recherche: $e',
);
_errorService.logError('activity_places_service', e);
return [];
}
@@ -105,7 +108,9 @@ class ActivityPlacesService {
try {
// Vérifier que la clé API est configurée
if (_apiKey.isEmpty) {
print('ActivityPlacesService: Clé API Google Maps manquante');
LoggerService.error(
'ActivityPlacesService: Clé API Google Maps manquante',
);
throw Exception('Clé API Google Maps non configurée');
}
@@ -113,16 +118,20 @@ class ActivityPlacesService {
final url =
'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey';
print('ActivityPlacesService: Géocodage de "$destination"');
print('ActivityPlacesService: URL = $url');
LoggerService.info('ActivityPlacesService: Géocodage de "$destination"');
LoggerService.info('ActivityPlacesService: URL = $url');
final response = await http.get(Uri.parse(url));
print('ActivityPlacesService: Status code = ${response.statusCode}');
LoggerService.info(
'ActivityPlacesService: Status code = ${response.statusCode}',
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
print('ActivityPlacesService: Réponse géocodage = ${data['status']}');
LoggerService.info(
'ActivityPlacesService: Réponse géocodage = ${data['status']}',
);
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
final location = data['results'][0]['geometry']['location'];
@@ -130,10 +139,12 @@ class ActivityPlacesService {
'lat': location['lat'].toDouble(),
'lng': location['lng'].toDouble(),
};
print('ActivityPlacesService: Coordonnées trouvées = $coordinates');
LoggerService.info(
'ActivityPlacesService: Coordonnées trouvées = $coordinates',
);
return coordinates;
} else {
print(
LoggerService.error(
'ActivityPlacesService: Erreur API = ${data['error_message'] ?? data['status']}',
);
if (data['status'] == 'REQUEST_DENIED') {
@@ -154,7 +165,7 @@ class ActivityPlacesService {
throw Exception('Erreur HTTP ${response.statusCode}');
}
} catch (e) {
print('ActivityPlacesService: Erreur géocodage: $e');
LoggerService.error('ActivityPlacesService: Erreur géocodage: $e');
rethrow; // Rethrow pour permettre la gestion d'erreur en amont
}
}
@@ -194,7 +205,9 @@ class ActivityPlacesService {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
LoggerService.error(
'ActivityPlacesService: Erreur conversion place: $e',
);
}
}
@@ -204,7 +217,9 @@ class ActivityPlacesService {
return [];
} catch (e) {
print('ActivityPlacesService: Erreur recherche par catégorie: $e');
LoggerService.error(
'ActivityPlacesService: Erreur recherche par catégorie: $e',
);
return [];
}
}
@@ -260,7 +275,7 @@ class ActivityPlacesService {
updatedAt: DateTime.now(),
);
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
LoggerService.error('ActivityPlacesService: Erreur conversion place: $e');
return null;
}
}
@@ -285,7 +300,9 @@ class ActivityPlacesService {
return null;
} catch (e) {
print('ActivityPlacesService: Erreur récupération détails: $e');
LoggerService.error(
'ActivityPlacesService: Erreur récupération détails: $e',
);
return null;
}
}
@@ -326,7 +343,7 @@ class ActivityPlacesService {
int radius = 5000,
}) async {
try {
print(
LoggerService.info(
'ActivityPlacesService: Recherche textuelle: $query à $destination',
);
@@ -364,7 +381,9 @@ class ActivityPlacesService {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
LoggerService.error(
'ActivityPlacesService: Erreur conversion place: $e',
);
}
}
@@ -374,7 +393,9 @@ class ActivityPlacesService {
return [];
} catch (e) {
print('ActivityPlacesService: Erreur recherche textuelle: $e');
LoggerService.error(
'ActivityPlacesService: Erreur recherche textuelle: $e',
);
return [];
}
}
@@ -423,11 +444,11 @@ class ActivityPlacesService {
if (latitude != null && longitude != null) {
lat = latitude;
lng = longitude;
print(
LoggerService.info(
'ActivityPlacesService: Utilisation des coordonnées pré-géolocalisées: $lat, $lng',
);
} else if (destination != null) {
print(
LoggerService.info(
'ActivityPlacesService: Géolocalisation de la destination: $destination',
);
final coordinates = await _geocodeDestination(destination);
@@ -437,7 +458,7 @@ class ActivityPlacesService {
throw Exception('Destination ou coordonnées requises');
}
print(
LoggerService.info(
'ActivityPlacesService: Recherche paginée aux coordonnées: $lat, $lng (page: ${nextPageToken ?? "première"})',
);
@@ -464,7 +485,9 @@ class ActivityPlacesService {
);
}
} catch (e) {
print('ActivityPlacesService: Erreur recherche paginée: $e');
LoggerService.error(
'ActivityPlacesService: Erreur recherche paginée: $e',
);
_errorService.logError('activity_places_service', e);
return {
'activities': <Activity>[],
@@ -519,7 +542,9 @@ class ActivityPlacesService {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
LoggerService.error(
'ActivityPlacesService: Erreur conversion place: $e',
);
}
}
@@ -537,7 +562,9 @@ class ActivityPlacesService {
'hasMoreData': false,
};
} catch (e) {
print('ActivityPlacesService: Erreur recherche catégorie paginée: $e');
LoggerService.error(
'ActivityPlacesService: Erreur recherche catégorie paginée: $e',
);
return {
'activities': <Activity>[],
'nextPageToken': null,
@@ -595,7 +622,9 @@ class ActivityPlacesService {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
LoggerService.error(
'ActivityPlacesService: Erreur conversion place: $e',
);
}
}
@@ -613,7 +642,7 @@ class ActivityPlacesService {
'hasMoreData': false,
};
} catch (e) {
print(
LoggerService.error(
'ActivityPlacesService: Erreur recherche toutes catégories paginée: $e',
);
return {

View File

@@ -32,6 +32,7 @@
/// - [Settlement] for individual payment recommendations
/// - [UserBalance] for per-user balance information
library;
import '../models/group_balance.dart';
import '../models/expense.dart';
import '../models/group_statistics.dart';
@@ -59,7 +60,10 @@ class BalanceService {
try {
return await _balanceRepository.calculateGroupBalance(groupId);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur calcul balance groupe: $e');
_errorService.logError(
'BalanceService',
'Erreur calcul balance groupe: $e',
);
rethrow;
}
}
@@ -80,7 +84,9 @@ class BalanceService {
/// Stream de la balance en temps réel
Stream<GroupBalance> getGroupBalanceStream(String groupId) {
return _expenseRepository.getExpensesStream(groupId).asyncMap((expenses) async {
return _expenseRepository.getExpensesStream(groupId).asyncMap((
expenses,
) async {
try {
final userBalances = calculateUserBalances(expenses);
final settlements = optimizeSettlements(userBalances);
@@ -145,7 +151,7 @@ class BalanceService {
/// Optimiser les règlements (algorithme avancé)
List<Settlement> optimizeSettlements(List<UserBalance> balances) {
final settlements = <Settlement>[];
// Filtrer les utilisateurs avec une balance significative (> 0.01€)
final creditors = balances
.where((b) => b.shouldReceive && b.balance > 0.01)
@@ -164,10 +170,10 @@ class BalanceService {
// Utiliser des copies mutables pour les calculs
final creditorsRemaining = Map<String, double>.fromEntries(
creditors.map((c) => MapEntry(c.userId, c.balance))
creditors.map((c) => MapEntry(c.userId, c.balance)),
);
final debtorsRemaining = Map<String, double>.fromEntries(
debtors.map((d) => MapEntry(d.userId, -d.balance))
debtors.map((d) => MapEntry(d.userId, -d.balance)),
);
// Algorithme glouton optimisé
@@ -185,16 +191,19 @@ class BalanceService {
);
if (settlementAmount > 0.01) {
settlements.add(Settlement(
fromUserId: debtor.userId,
fromUserName: debtor.userName,
toUserId: creditor.userId,
toUserName: creditor.userName,
amount: settlementAmount,
));
settlements.add(
Settlement(
fromUserId: debtor.userId,
fromUserName: debtor.userName,
toUserId: creditor.userId,
toUserName: creditor.userName,
amount: settlementAmount,
),
);
// Mettre à jour les montants restants
creditorsRemaining[creditor.userId] = creditRemaining - settlementAmount;
creditorsRemaining[creditor.userId] =
creditRemaining - settlementAmount;
debtorsRemaining[debtor.userId] = debtRemaining - settlementAmount;
}
}
@@ -204,7 +213,10 @@ class BalanceService {
}
/// Calculer le montant optimal pour un règlement
double _calculateOptimalSettlementAmount(double creditAmount, double debtAmount) {
double _calculateOptimalSettlementAmount(
double creditAmount,
double debtAmount,
) {
final amount = [creditAmount, debtAmount].reduce((a, b) => a < b ? a : b);
// Arrondir à 2 décimales
return (amount * 100).round() / 100;
@@ -213,36 +225,50 @@ class BalanceService {
/// Valider les règlements calculés
List<Settlement> _validateSettlements(List<Settlement> settlements) {
// Supprimer les règlements trop petits
final validSettlements = settlements
.where((s) => s.amount > 0.01)
.toList();
final validSettlements = settlements.where((s) => s.amount > 0.01).toList();
// Log pour debug en cas de problème
final totalSettlements = validSettlements.fold(0.0, (sum, s) => sum + s.amount);
_errorService.logInfo('BalanceService',
'Règlements calculés: ${validSettlements.length}, Total: ${totalSettlements.toStringAsFixed(2)}');
final totalSettlements = validSettlements.fold(
0.0,
(sum, s) => sum + s.amount,
);
_errorService.logInfo(
'BalanceService',
'Règlements calculés: ${validSettlements.length}, Total: ${totalSettlements.toStringAsFixed(2)}',
);
return validSettlements;
}
/// Calculer la dette entre deux utilisateurs spécifiques
double calculateDebtBetweenUsers(String groupId, String userId1, String userId2) {
double calculateDebtBetweenUsers(
String groupId,
String userId1,
String userId2,
) {
// Cette méthode pourrait être utile pour des fonctionnalités avancées
// comme "Combien me doit X ?" ou "Combien je dois à Y ?"
return 0.0; // TODO: Implémenter si nécessaire
// On peut utiliser optimizeSettlements pour avoir la réponse précise
// Cependant, cela nécessite d'avoir les dépenses.
// Comme cette méthode est synchrone et ne prend pas les dépenses en entrée,
// elle est difficile à implémenter correctement sans changer sa signature.
// Pour l'instant, on retourne 0.0 car elle n'est pas utilisée.
return 0.0;
}
/// Analyser les tendances de dépenses par catégorie
Map<String, double> analyzeCategorySpending(List<Expense> expenses) {
final categoryTotals = <String, double>{};
for (final expense in expenses) {
if (expense.isArchived) continue;
final categoryName = expense.category.displayName;
categoryTotals[categoryName] = (categoryTotals[categoryName] ?? 0) + expense.amountInEur;
categoryTotals[categoryName] =
(categoryTotals[categoryName] ?? 0) + expense.amountInEur;
}
return categoryTotals;
}
@@ -253,9 +279,12 @@ class BalanceService {
}
final nonArchivedExpenses = expenses.where((e) => !e.isArchived).toList();
final totalAmount = nonArchivedExpenses.fold(0.0, (sum, e) => sum + e.amountInEur);
final totalAmount = nonArchivedExpenses.fold(
0.0,
(sum, e) => sum + e.amountInEur,
);
final averageAmount = totalAmount / nonArchivedExpenses.length;
final categorySpending = analyzeCategorySpending(nonArchivedExpenses);
final topCategory = categorySpending.entries
.reduce((a, b) => a.value > b.value ? a : b)
@@ -279,11 +308,14 @@ class BalanceService {
try {
// Ici vous pourriez enregistrer le règlement en base
// ou créer une transaction de règlement
// Pour l'instant, on pourrait juste recalculer
await Future.delayed(const Duration(milliseconds: 100));
_errorService.logSuccess('BalanceService', 'Règlement marqué comme effectué');
_errorService.logSuccess(
'BalanceService',
'Règlement marqué comme effectué',
);
} catch (e) {
_errorService.logError('BalanceService', 'Erreur mark settlement: $e');
rethrow;
@@ -312,4 +344,4 @@ class _UserBalanceCalculator {
balance: _totalPaid - _totalOwed,
);
}
}
}

View File

@@ -1,32 +1,33 @@
import 'package:flutter/material.dart';
import '../components/error/error_content.dart';
import 'logger_service.dart';
/// Service for handling application errors and user notifications.
///
///
/// This singleton service provides centralized error handling capabilities
/// including displaying error dialogs, snackbars, and logging errors for
/// debugging purposes. It uses a global navigator key to show notifications
/// from anywhere in the application.
class ErrorService {
static final ErrorService _instance = ErrorService._internal();
/// Factory constructor that returns the singleton instance.
factory ErrorService() => _instance;
/// Private constructor for singleton pattern.
ErrorService._internal();
/// Global navigator key for accessing context from anywhere in the app.
///
///
/// This key should be assigned to the MaterialApp's navigatorKey property
/// to enable error notifications from any part of the application.
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
/// Displays an error message in a dialog.
///
///
/// Shows a modal dialog with the error message and optional retry functionality.
/// The dialog appearance can be customized with different icons and colors.
///
///
/// [message] - The error message to display
/// [title] - The dialog title (defaults to 'Error')
/// [onRetry] - Optional callback for retry functionality
@@ -53,10 +54,10 @@ class ErrorService {
}
/// Displays an error or success message in a snackbar.
///
///
/// Shows a floating snackbar at the bottom of the screen with the message.
/// The appearance changes based on whether it's an error or success message.
///
///
/// [message] - The message to display
/// [onRetry] - Optional callback for retry functionality
/// [isError] - Whether this is an error (true) or success (false) message
@@ -89,36 +90,35 @@ class ErrorService {
}
/// Logs error messages to the console during development.
///
///
/// Formats and displays error information including source, error message,
/// and optional stack trace in a visually distinct format.
///
///
/// [source] - The source or location where the error occurred
/// [error] - The error object or message
/// [stackTrace] - Optional stack trace for debugging
void logError(String source, dynamic error, [StackTrace? stackTrace]) {
print('═══════════════════════════════════');
print('❌ ERROR in $source');
print('Message: $error');
if (stackTrace != null) {
print('StackTrace: $stackTrace');
}
print('═══════════════════════════════════');
LoggerService.error(
'❌ ERROR in $source\nMessage: $error',
name: source,
error: error,
stackTrace: stackTrace,
);
}
/// Logs informational messages to the console during development.
///
///
/// [source] - The source or location of the information
/// [message] - The informational message
void logInfo(String source, String message) {
print(' [$source] $message');
LoggerService.info(' $message', name: source);
}
/// Logs success messages to the console during development.
///
///
/// [source] - The source or location of the success
/// [message] - The success message
void logSuccess(String source, String message) {
print('[$source] $message');
LoggerService.info('$message', name: source);
}
}
}

View File

@@ -0,0 +1,30 @@
import 'dart:developer' as developer;
class LoggerService {
static void log(String message, {String name = 'App'}) {
developer.log(message, name: name);
}
static void error(
String message, {
String name = 'App',
Object? error,
StackTrace? stackTrace,
}) {
developer.log(
message,
name: name,
error: error,
stackTrace: stackTrace,
level: 1000,
);
}
static void info(String message, {String name = 'App'}) {
developer.log(message, name: name, level: 800);
}
static void warning(String message, {String name = 'App'}) {
developer.log(message, name: name, level: 900);
}
}

View File

@@ -11,7 +11,6 @@ class PlaceImageService {
/// Récupère l'URL de l'image d'un lieu depuis Google Places API
Future<String?> getPlaceImageUrl(String location) async {
try {
// ÉTAPE 1: Vérifier d'abord si une image existe déjà dans le Storage
final existingUrl = await _checkExistingImage(location);
@@ -19,26 +18,25 @@ class PlaceImageService {
return existingUrl;
}
if (_apiKey.isEmpty) {
_errorService.logError('PlaceImageService', 'Google Maps API key manquante');
_errorService.logError(
'PlaceImageService',
'Google Maps API key manquante',
);
return null;
}
// ÉTAPE 2: Recherche via Google Places API seulement si aucune image n'existe
final searchTerms = _generateSearchTerms(location);
for (final searchTerm in searchTerms) {
// 1. Rechercher le lieu
final placeId = await _getPlaceIdForTerm(searchTerm);
if (placeId == null) continue;
// 2. Récupérer les détails du lieu avec les photos
final photoReference = await _getPhotoReference(placeId);
if (photoReference == null) continue;
// 3. Télécharger et sauvegarder l'image (seulement si pas d'image existante)
final imageUrl = await _downloadAndSaveImage(photoReference, location);
@@ -46,11 +44,13 @@ class PlaceImageService {
return imageUrl;
}
}
return null;
return null;
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors de la récupération de l\'image: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la récupération de l\'image: $e',
);
return null;
}
}
@@ -58,11 +58,11 @@ class PlaceImageService {
/// Génère différents termes de recherche pour optimiser les résultats
List<String> _generateSearchTerms(String location) {
final terms = <String>[];
// Ajouter des termes spécifiques pour les villes connues
final citySpecificTerms = _getCitySpecificTerms(location.toLowerCase());
terms.addAll(citySpecificTerms);
// Termes génériques avec attractions
terms.addAll([
'$location attractions touristiques monuments',
@@ -74,14 +74,14 @@ class PlaceImageService {
'$location skyline',
location, // Terme original en dernier
]);
return terms;
}
/// Retourne des termes spécifiques pour des villes connues
List<String> _getCitySpecificTerms(String location) {
final specific = <String>[];
if (location.contains('paris')) {
specific.addAll([
'Tour Eiffel Paris',
@@ -120,20 +120,23 @@ class PlaceImageService {
'Tokyo Skytree',
]);
}
return specific;
}
/// Recherche un place ID pour un terme spécifique
Future<String?> _getPlaceIdForTerm(String searchTerm) async {
// Essayer d'abord avec les attractions touristiques
String? placeId = await _searchPlaceWithType(searchTerm, 'tourist_attraction');
String? placeId = await _searchPlaceWithType(
searchTerm,
'tourist_attraction',
);
if (placeId != null) return placeId;
// Puis avec les points d'intérêt
placeId = await _searchPlaceWithType(searchTerm, 'point_of_interest');
if (placeId != null) return placeId;
// Enfin recherche générale
return await _searchPlaceGeneral(searchTerm);
}
@@ -146,14 +149,14 @@ class PlaceImageService {
'?query=${Uri.encodeComponent('$location attractions monuments')}'
'&type=$type'
'&fields=place_id,name,types,rating'
'&key=$_apiKey'
'&key=$_apiKey',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
// Prioriser les résultats avec des ratings élevés
final results = data['results'] as List;
@@ -162,7 +165,7 @@ class PlaceImageService {
final bRating = b['rating'] ?? 0.0;
return bRating.compareTo(aRating);
});
return results.first['place_id'];
}
}
@@ -180,14 +183,14 @@ class PlaceImageService {
'?input=${Uri.encodeComponent(location)}'
'&inputtype=textquery'
'&fields=place_id'
'&key=$_apiKey'
'&key=$_apiKey',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK' && data['candidates'].isNotEmpty) {
return data['candidates'][0]['place_id'];
}
@@ -205,24 +208,23 @@ class PlaceImageService {
'https://maps.googleapis.com/maps/api/place/details/json'
'?place_id=$placeId'
'&fields=photos'
'&key=$_apiKey'
'&key=$_apiKey',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK' &&
data['result'] != null &&
data['result']['photos'] != null &&
if (data['status'] == 'OK' &&
data['result'] != null &&
data['result']['photos'] != null &&
data['result']['photos'].isNotEmpty) {
final photos = data['result']['photos'] as List;
// Trier les photos pour obtenir les meilleures
final sortedPhotos = _sortPhotosByQuality(photos);
if (sortedPhotos.isNotEmpty) {
return sortedPhotos.first['photo_reference'];
}
@@ -230,7 +232,10 @@ class PlaceImageService {
}
return null;
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors de la récupération de la référence photo: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la récupération de la référence photo: $e',
);
return null;
}
}
@@ -238,64 +243,68 @@ class PlaceImageService {
/// Trie les photos par qualité (largeur/hauteur et popularité)
List<Map<String, dynamic>> _sortPhotosByQuality(List photos) {
final photoList = photos.cast<Map<String, dynamic>>();
photoList.sort((a, b) {
// Priorité 1: Photos horizontales (largeur > hauteur)
final aWidth = a['width'] ?? 0;
final aHeight = a['height'] ?? 0;
final bWidth = b['width'] ?? 0;
final bHeight = b['height'] ?? 0;
final aIsHorizontal = aWidth > aHeight;
final bIsHorizontal = bWidth > bHeight;
if (aIsHorizontal && !bIsHorizontal) return -1;
if (!aIsHorizontal && bIsHorizontal) return 1;
// Priorité 2: Résolution plus élevée
final aResolution = aWidth * aHeight;
final bResolution = bWidth * bHeight;
if (aResolution != bResolution) {
return bResolution.compareTo(aResolution);
}
// Priorité 3: Ratio d'aspect optimal pour paysage (1.5-2.0)
final aRatio = aWidth > 0 ? aWidth / aHeight : 0;
final bRatio = bWidth > 0 ? bWidth / bHeight : 0;
final idealRatio = 1.7; // Ratio 16:9 environ
final aDiff = (aRatio - idealRatio).abs();
final bDiff = (bRatio - idealRatio).abs();
return aDiff.compareTo(bDiff);
});
return photoList;
}
/// Télécharge l'image et la sauvegarde dans Firebase Storage
Future<String?> _downloadAndSaveImage(String photoReference, String location) async {
Future<String?> _downloadAndSaveImage(
String photoReference,
String location,
) async {
try {
// URL pour télécharger l'image en haute qualité et format horizontal
final imageUrl = 'https://maps.googleapis.com/maps/api/place/photo'
'?maxwidth=1200' // Augmenté pour une meilleure qualité
'&maxheight=800' // Ratio horizontal ~1.5:1
final imageUrl =
'https://maps.googleapis.com/maps/api/place/photo'
'?maxwidth=1200' // Augmenté pour une meilleure qualité
'&maxheight=800' // Ratio horizontal ~1.5:1
'&photo_reference=$photoReference'
'&key=$_apiKey';
// Télécharger l'image
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode == 200) {
// Créer un nom de fichier unique basé sur la localisation normalisée
final normalizedLocation = _normalizeLocationName(location);
final fileName = '${normalizedLocation}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final fileName =
'${normalizedLocation}_${DateTime.now().millisecondsSinceEpoch}.jpg';
// Référence vers Firebase Storage
final storageRef = _storage.ref().child('trip_images/$fileName');
// Upload de l'image avec métadonnées
final uploadTask = await storageRef.putData(
response.bodyBytes,
@@ -309,15 +318,17 @@ class PlaceImageService {
},
),
);
// Récupérer l'URL de téléchargement
final downloadUrl = await uploadTask.ref.getDownloadURL();
return downloadUrl;
} else {
}
} else {}
return null;
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors du téléchargement/sauvegarde: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors du téléchargement/sauvegarde: $e',
);
return null;
}
}
@@ -326,45 +337,52 @@ class PlaceImageService {
Future<String?> _checkExistingImage(String location) async {
try {
final normalizedLocation = _normalizeLocationName(location);
final listResult = await _storage.ref('trip_images').listAll();
for (final item in listResult.items) {
try {
final metadata = await item.getMetadata();
final storedNormalizedLocation = metadata.customMetadata?['normalizedLocation'];
final storedNormalizedLocation =
metadata.customMetadata?['normalizedLocation'];
final storedLocation = metadata.customMetadata?['location'];
// Méthode 1: Vérifier avec la location normalisée (nouvelles images)
if (storedNormalizedLocation != null && storedNormalizedLocation == normalizedLocation) {
if (storedNormalizedLocation != null &&
storedNormalizedLocation == normalizedLocation) {
final url = await item.getDownloadURL();
return url;
}
// Méthode 2: Vérifier avec la location originale normalisée (anciennes images)
if (storedLocation != null) {
final storedLocationNormalized = _normalizeLocationName(storedLocation);
final storedLocationNormalized = _normalizeLocationName(
storedLocation,
);
if (storedLocationNormalized == normalizedLocation) {
final url = await item.getDownloadURL();
return url;
}
}
} catch (e) {
// Méthode 3: Essayer de deviner depuis le nom du fichier (fallback)
final fileName = item.name;
if (fileName.toLowerCase().contains(normalizedLocation.toLowerCase())) {
if (fileName.toLowerCase().contains(
normalizedLocation.toLowerCase(),
)) {
try {
final url = await item.getDownloadURL();
return url;
} catch (urlError) {
_errorService.logError('PlaceImageService', 'Erreur lors de la récupération de l\'URL: $urlError');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la récupération de l\'URL: $urlError',
);
}
}
}
}
return null;
} catch (e) {
return null;
@@ -384,23 +402,26 @@ class PlaceImageService {
Future<void> cleanupUnusedImages(List<String> usedImageUrls) async {
try {
final listResult = await _storage.ref('trip_images').listAll();
int deletedCount = 0;
for (final item in listResult.items) {
try {
final url = await item.getDownloadURL();
if (!usedImageUrls.contains(url)) {
await item.delete();
deletedCount++;
}
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors du nettoyage: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors du nettoyage: $e',
);
}
}
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors du nettoyage: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors du nettoyage: $e',
);
}
}
@@ -408,18 +429,19 @@ class PlaceImageService {
Future<void> cleanupDuplicateImages() async {
try {
final listResult = await _storage.ref('trip_images').listAll();
// Grouper les images par location normalisée
final Map<String, List<Reference>> locationGroups = {};
for (final item in listResult.items) {
String locationKey = 'unknown';
try {
final metadata = await item.getMetadata();
final storedNormalizedLocation = metadata.customMetadata?['normalizedLocation'];
final storedNormalizedLocation =
metadata.customMetadata?['normalizedLocation'];
final storedLocation = metadata.customMetadata?['location'];
if (storedNormalizedLocation != null) {
locationKey = storedNormalizedLocation;
} else if (storedLocation != null) {
@@ -440,43 +462,44 @@ class PlaceImageService {
locationKey = parts.take(parts.length - 1).join('_');
}
}
if (!locationGroups.containsKey(locationKey)) {
locationGroups[locationKey] = [];
}
locationGroups[locationKey]!.add(item);
}
// Supprimer les doublons (garder le plus récent)
int deletedCount = 0;
for (final entry in locationGroups.entries) {
final location = entry.key;
final images = entry.value;
if (images.length > 1) {
// Trier par timestamp (garder le plus récent)
images.sort((a, b) {
final aTimestamp = _extractTimestampFromName(a.name);
final bTimestamp = _extractTimestampFromName(b.name);
return bTimestamp.compareTo(aTimestamp); // Plus récent en premier
});
// Supprimer tous sauf le premier (plus récent)
for (int i = 1; i < images.length; i++) {
try {
await images[i].delete();
deletedCount++;
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors de la suppression du doublon: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la suppression du doublon: $e',
);
}
}
}
}
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors du nettoyage des doublons: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors du nettoyage des doublons: $e',
);
}
}
@@ -499,7 +522,10 @@ class PlaceImageService {
}
return null;
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors de la recherche d\'image existante: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la recherche d\'image existante: $e',
);
return null;
}
}
@@ -510,7 +536,10 @@ class PlaceImageService {
final ref = _storage.refFromURL(imageUrl);
await ref.delete();
} catch (e) {
_errorService.logError('PlaceImageService', 'Erreur lors de la suppression de l\'image: $e');
_errorService.logError(
'PlaceImageService',
'Erreur lors de la suppression de l\'image: $e',
);
}
}
}
}

View File

@@ -14,17 +14,18 @@
/// 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';
@@ -36,34 +37,32 @@ import 'error_service.dart';
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();
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
///
/// 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 {
@@ -95,8 +94,12 @@ class StorageService {
// Progress monitoring (optional)
uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
final progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
_errorService.logInfo('StorageService', 'Upload progress: ${progress.toStringAsFixed(1)}%');
final progress =
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
_errorService.logInfo(
'StorageService',
'Upload progress: ${progress.toStringAsFixed(1)}%',
);
});
// Wait for completion
@@ -105,9 +108,11 @@ class StorageService {
// Get download URL
final downloadUrl = await snapshot.ref.getDownloadURL();
_errorService.logSuccess('StorageService', 'Image uploaded successfully: $fileName');
_errorService.logSuccess(
'StorageService',
'Image uploaded successfully: $fileName',
);
return downloadUrl;
} catch (e) {
_errorService.logError('StorageService', 'Error uploading image: $e');
rethrow;
@@ -115,10 +120,10 @@ class StorageService {
}
/// 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 {
@@ -137,17 +142,17 @@ class StorageService {
}
/// 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
///
/// A `Future<Uint8List>` containing the compressed image bytes
///
/// Throws:
/// Exception if the image cannot be decoded or processed
Future<Uint8List> _compressImage(File imageFile) async {
@@ -175,9 +180,11 @@ class StorageService {
// Encode as JPEG with compression
final compressedBytes = img.encodeJpg(image, quality: 85);
_errorService.logInfo('StorageService',
'Image compressed: ${bytes.length}${compressedBytes.length} bytes');
_errorService.logInfo(
'StorageService',
'Image compressed: ${bytes.length}${compressedBytes.length} bytes',
);
return Uint8List.fromList(compressedBytes);
} catch (e) {
@@ -188,13 +195,13 @@ class StorageService {
}
/// 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) {
@@ -219,13 +226,13 @@ class StorageService {
}
/// 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) {
@@ -235,21 +242,23 @@ class StorageService {
}
/// 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
/// A `Future<List<String>>` containing download URLs of uploaded images
Future<List<String>> uploadMultipleImages(
String groupId,
String groupId,
List<File> imageFiles,
) async {
final uploadTasks = imageFiles.map((file) => uploadReceiptImage(groupId, file));
final uploadTasks = imageFiles.map(
(file) => uploadReceiptImage(groupId, file),
);
return await Future.wait(uploadTasks);
}
@@ -259,7 +268,10 @@ class StorageService {
final ref = _storage.refFromURL(imageUrl);
return await ref.getMetadata();
} catch (e) {
_errorService.logError('StorageService', 'Erreur récupération metadata: $e');
_errorService.logError(
'StorageService',
'Erreur récupération metadata: $e',
);
return null;
}
}
@@ -274,14 +286,17 @@ class StorageService {
// 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}');
_errorService.logInfo(
'StorageService',
'Image orpheline supprimée: ${ref.name}',
);
}
}
}
@@ -295,17 +310,20 @@ class StorageService {
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');
_errorService.logError(
'StorageService',
'Erreur calcul taille storage: $e',
);
return 0;
}
}
}
}

View File

@@ -3,10 +3,12 @@ import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/trip.dart';
import 'error_service.dart';
import 'logger_service.dart';
/// Service pour géocoder les destinations des voyages
class TripGeocodingService {
static final TripGeocodingService _instance = TripGeocodingService._internal();
static final TripGeocodingService _instance =
TripGeocodingService._internal();
factory TripGeocodingService() => _instance;
TripGeocodingService._internal();
@@ -16,35 +18,41 @@ class TripGeocodingService {
/// Géocode la destination d'un voyage et retourne un Trip mis à jour
Future<Trip> geocodeTrip(Trip trip) async {
try {
print('🌍 [TripGeocoding] Géocodage de "${trip.location}"');
LoggerService.info('🌍 [TripGeocoding] Géocodage de "${trip.location}"');
// Vérifier si on a déjà des coordonnées récentes
if (trip.hasRecentCoordinates) {
print('✅ [TripGeocoding] Coordonnées récentes trouvées, pas de géocodage nécessaire');
LoggerService.info(
'✅ [TripGeocoding] Coordonnées récentes trouvées, pas de géocodage nécessaire',
);
return trip;
}
if (_apiKey.isEmpty) {
print('❌ [TripGeocoding] Clé API Google Maps manquante');
LoggerService.error('❌ [TripGeocoding] Clé API Google Maps manquante');
throw Exception('Clé API Google Maps non configurée');
}
final coordinates = await _geocodeDestination(trip.location);
if (coordinates != null) {
print('✅ [TripGeocoding] Coordonnées trouvées: ${coordinates['lat']}, ${coordinates['lng']}');
LoggerService.info(
'✅ [TripGeocoding] Coordonnées trouvées: ${coordinates['lat']}, ${coordinates['lng']}',
);
return trip.copyWith(
latitude: coordinates['lat'],
longitude: coordinates['lng'],
lastGeocodingUpdate: DateTime.now(),
);
} else {
print('⚠️ [TripGeocoding] Impossible de géocoder "${trip.location}"');
LoggerService.warning(
'⚠️ [TripGeocoding] Impossible de géocoder "${trip.location}"',
);
return trip;
}
} catch (e) {
print('❌ [TripGeocoding] Erreur lors du géocodage: $e');
LoggerService.error('❌ [TripGeocoding] Erreur lors du géocodage: $e');
_errorService.logError('trip_geocoding_service', e);
return trip; // Retourner le voyage original en cas d'erreur
}
@@ -53,18 +61,23 @@ class TripGeocodingService {
/// Géocode une destination et retourne les coordonnées
Future<Map<String, double>?> _geocodeDestination(String destination) async {
try {
final url = 'https://maps.googleapis.com/maps/api/geocode/json'
final url =
'https://maps.googleapis.com/maps/api/geocode/json'
'?address=${Uri.encodeComponent(destination)}'
'&key=$_apiKey';
print('🌐 [TripGeocoding] URL = $url');
LoggerService.info('🌐 [TripGeocoding] URL = $url');
final response = await http.get(Uri.parse(url));
print('📡 [TripGeocoding] Status code = ${response.statusCode}');
LoggerService.info(
'📡 [TripGeocoding] Status code = ${response.statusCode}',
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
print('📋 [TripGeocoding] Réponse géocodage = ${data['status']}');
LoggerService.info(
'📋 [TripGeocoding] Réponse géocodage = ${data['status']}',
);
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
final location = data['results'][0]['geometry']['location'];
@@ -72,18 +85,24 @@ class TripGeocodingService {
'lat': (location['lat'] as num).toDouble(),
'lng': (location['lng'] as num).toDouble(),
};
print('📍 [TripGeocoding] Coordonnées trouvées = $coordinates');
LoggerService.info(
'📍 [TripGeocoding] Coordonnées trouvées = $coordinates',
);
return coordinates;
} else {
print('⚠️ [TripGeocoding] Erreur API = ${data['error_message'] ?? data['status']}');
LoggerService.warning(
'⚠️ [TripGeocoding] Erreur API = ${data['error_message'] ?? data['status']}',
);
return null;
}
} else {
print('❌ [TripGeocoding] Erreur HTTP ${response.statusCode}');
LoggerService.error(
'❌ [TripGeocoding] Erreur HTTP ${response.statusCode}',
);
return null;
}
} catch (e) {
print('❌ [TripGeocoding] Exception lors du géocodage: $e');
LoggerService.error('❌ [TripGeocoding] Exception lors du géocodage: $e');
_errorService.logError('trip_geocoding_service', e);
return null;
}
@@ -96,23 +115,25 @@ class TripGeocodingService {
/// Géocode plusieurs voyages en batch
Future<List<Trip>> geocodeTrips(List<Trip> trips) async {
print('🔄 [TripGeocoding] Géocodage de ${trips.length} voyages');
LoggerService.info(
'🔄 [TripGeocoding] Géocodage de ${trips.length} voyages',
);
final List<Trip> geocodedTrips = [];
for (final trip in trips) {
if (needsGeocoding(trip)) {
final geocodedTrip = await geocodeTrip(trip);
geocodedTrips.add(geocodedTrip);
// Petit délai pour éviter de saturer l'API Google
await Future.delayed(const Duration(milliseconds: 200));
} else {
geocodedTrips.add(trip);
}
}
print('✅ [TripGeocoding] Géocodage terminé');
LoggerService.info('✅ [TripGeocoding] Géocodage terminé');
return geocodedTrips;
}
}
}