feat: Add TripImageService for automatic trip image management
- Implemented TripImageService to load missing images for trips, reload images, and clean up unused images. - Added functionality to get image statistics and clean up duplicate images. - Created utility scripts for manual image cleanup and diagnostics in Firebase Storage. - Introduced tests for image loading optimization and photo quality algorithms. - Updated dependencies in pubspec.yaml and pubspec.lock for image handling.
This commit is contained in:
553
lib/services/place_image_service.dart
Normal file
553
lib/services/place_image_service.dart
Normal file
@@ -0,0 +1,553 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
|
||||
class PlaceImageService {
|
||||
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
|
||||
final FirebaseStorage _storage = FirebaseStorage.instance;
|
||||
final ErrorService _errorService = ErrorService();
|
||||
|
||||
/// Récupère l'URL de l'image d'un lieu depuis Google Places API
|
||||
Future<String?> getPlaceImageUrl(String location) async {
|
||||
print('PlaceImageService: Tentative de récupération d\'image pour: $location');
|
||||
|
||||
try {
|
||||
// ÉTAPE 1: Vérifier d'abord si une image existe déjà dans le Storage
|
||||
final existingUrl = await _checkExistingImage(location);
|
||||
if (existingUrl != null) {
|
||||
print('PlaceImageService: Image existante trouvée dans le Storage: $existingUrl');
|
||||
return existingUrl;
|
||||
}
|
||||
|
||||
print('PlaceImageService: Aucune image existante, recherche via Google Places API...');
|
||||
|
||||
if (_apiKey.isEmpty) {
|
||||
print('PlaceImageService: Erreur - 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) {
|
||||
print('PlaceImageService: Essai avec terme de recherche: $searchTerm');
|
||||
|
||||
// 1. Rechercher le lieu
|
||||
final placeId = await _getPlaceIdForTerm(searchTerm);
|
||||
if (placeId == null) continue;
|
||||
|
||||
print('PlaceImageService: Place ID trouvé: $placeId');
|
||||
|
||||
// 2. Récupérer les détails du lieu avec les photos
|
||||
final photoReference = await _getPhotoReference(placeId);
|
||||
if (photoReference == null) continue;
|
||||
|
||||
print('PlaceImageService: Photo référence trouvée: $photoReference');
|
||||
|
||||
// 3. Télécharger et sauvegarder l'image (seulement si pas d'image existante)
|
||||
final imageUrl = await _downloadAndSaveImage(photoReference, location);
|
||||
if (imageUrl != null) {
|
||||
print('PlaceImageService: Image URL finale: $imageUrl');
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Aucune image trouvée pour tous les termes de recherche');
|
||||
return null;
|
||||
|
||||
} catch (e) {
|
||||
print('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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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',
|
||||
'$location landmarks',
|
||||
'$location tourist attractions',
|
||||
'$location monuments historiques',
|
||||
'$location points d\'intérêt',
|
||||
'$location centre ville',
|
||||
'$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',
|
||||
'Arc de Triomphe Paris',
|
||||
'Notre-Dame Paris',
|
||||
'Louvre Paris',
|
||||
'Champs-Élysées Paris',
|
||||
]);
|
||||
} else if (location.contains('london') || location.contains('londres')) {
|
||||
specific.addAll([
|
||||
'Big Ben London',
|
||||
'Tower Bridge London',
|
||||
'London Eye',
|
||||
'Buckingham Palace London',
|
||||
'Tower of London',
|
||||
]);
|
||||
} else if (location.contains('rome') || location.contains('roma')) {
|
||||
specific.addAll([
|
||||
'Colosseum Rome',
|
||||
'Trevi Fountain Rome',
|
||||
'Vatican Rome',
|
||||
'Pantheon Rome',
|
||||
]);
|
||||
} else if (location.contains('new york') || location.contains('nyc')) {
|
||||
specific.addAll([
|
||||
'Statue of Liberty New York',
|
||||
'Empire State Building New York',
|
||||
'Times Square New York',
|
||||
'Brooklyn Bridge New York',
|
||||
]);
|
||||
} else if (location.contains('tokyo') || location.contains('japon')) {
|
||||
specific.addAll([
|
||||
'Tokyo Tower',
|
||||
'Senso-ji Temple Tokyo',
|
||||
'Shibuya Crossing Tokyo',
|
||||
'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');
|
||||
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);
|
||||
}
|
||||
|
||||
/// Recherche un lieu avec un type spécifique
|
||||
Future<String?> _searchPlaceWithType(String location, String type) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'https://maps.googleapis.com/maps/api/place/textsearch/json'
|
||||
'?query=${Uri.encodeComponent('$location attractions monuments')}'
|
||||
'&type=$type'
|
||||
'&fields=place_id,name,types,rating'
|
||||
'&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;
|
||||
results.sort((a, b) {
|
||||
final aRating = a['rating'] ?? 0.0;
|
||||
final bRating = b['rating'] ?? 0.0;
|
||||
return bRating.compareTo(aRating);
|
||||
});
|
||||
|
||||
return results.first['place_id'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur recherche avec type $type: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche générale de lieu
|
||||
Future<String?> _searchPlaceGeneral(String location) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'https://maps.googleapis.com/maps/api/place/findplacefromtext/json'
|
||||
'?input=${Uri.encodeComponent(location)}'
|
||||
'&inputtype=textquery'
|
||||
'&fields=place_id'
|
||||
'&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'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur recherche générale: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la référence photo du lieu
|
||||
Future<String?> _getPhotoReference(String placeId) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'https://maps.googleapis.com/maps/api/place/details/json'
|
||||
'?place_id=$placeId'
|
||||
'&fields=photos'
|
||||
'&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 &&
|
||||
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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors de la récupération de la référence photo: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
'&photo_reference=$photoReference'
|
||||
'&key=$_apiKey';
|
||||
|
||||
print('PlaceImageService: Téléchargement de l\'image: $imageUrl');
|
||||
|
||||
// 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';
|
||||
|
||||
// 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,
|
||||
SettableMetadata(
|
||||
contentType: 'image/jpeg',
|
||||
customMetadata: {
|
||||
'location': location,
|
||||
'normalizedLocation': normalizedLocation,
|
||||
'source': 'google_places',
|
||||
'uploadedAt': DateTime.now().toIso8601String(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Récupérer l'URL de téléchargement
|
||||
final downloadUrl = await uploadTask.ref.getDownloadURL();
|
||||
print('PlaceImageService: Image sauvegardée avec succès: $downloadUrl');
|
||||
return downloadUrl;
|
||||
} else {
|
||||
print('PlaceImageService: Erreur HTTP ${response.statusCode} lors du téléchargement');
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors du téléchargement/sauvegarde: $e');
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors du téléchargement/sauvegarde: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une image existe déjà pour cette location
|
||||
Future<String?> _checkExistingImage(String location) async {
|
||||
try {
|
||||
final normalizedLocation = _normalizeLocationName(location);
|
||||
print('PlaceImageService: Recherche d\'image existante pour "$location" (normalisé: "$normalizedLocation")');
|
||||
|
||||
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 storedLocation = metadata.customMetadata?['location'];
|
||||
|
||||
// Méthode 1: Vérifier avec la location normalisée (nouvelles images)
|
||||
if (storedNormalizedLocation != null && storedNormalizedLocation == normalizedLocation) {
|
||||
final url = await item.getDownloadURL();
|
||||
print('PlaceImageService: Image trouvée via normalizedLocation: $url');
|
||||
return url;
|
||||
}
|
||||
|
||||
// Méthode 2: Vérifier avec la location originale normalisée (anciennes images)
|
||||
if (storedLocation != null) {
|
||||
final storedLocationNormalized = _normalizeLocationName(storedLocation);
|
||||
if (storedLocationNormalized == normalizedLocation) {
|
||||
final url = await item.getDownloadURL();
|
||||
print('PlaceImageService: Image trouvée via location originale: $url');
|
||||
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())) {
|
||||
try {
|
||||
final url = await item.getDownloadURL();
|
||||
print('PlaceImageService: Image trouvée via nom de fichier: $url');
|
||||
return url;
|
||||
} catch (urlError) {
|
||||
print('PlaceImageService: Erreur récupération URL pour ${item.name}: $urlError');
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Impossible de lire les métadonnées pour ${item.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Aucune image existante trouvée pour "$location"');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors de la vérification d\'images existantes: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise le nom de location pour la comparaison
|
||||
String _normalizeLocationName(String location) {
|
||||
return location
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9]'), '_')
|
||||
.replaceAll(RegExp(r'_+'), '_')
|
||||
.replaceAll(RegExp(r'^_|_$'), '');
|
||||
}
|
||||
|
||||
/// Nettoie les images inutilisées (à appeler manuellement si nécessaire)
|
||||
Future<void> cleanupUnusedImages(List<String> usedImageUrls) async {
|
||||
try {
|
||||
print('PlaceImageService: Nettoyage des images inutilisées...');
|
||||
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++;
|
||||
print('PlaceImageService: Image supprimée: ${item.name}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors de la suppression de ${item.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Nettoyage terminé. $deletedCount images supprimées.');
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors du nettoyage: $e');
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors du nettoyage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoie spécifiquement les doublons d'images pour la même location
|
||||
Future<void> cleanupDuplicateImages() async {
|
||||
try {
|
||||
print('PlaceImageService: Nettoyage des images en doublon...');
|
||||
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 storedLocation = metadata.customMetadata?['location'];
|
||||
|
||||
if (storedNormalizedLocation != null) {
|
||||
locationKey = storedNormalizedLocation;
|
||||
} else if (storedLocation != null) {
|
||||
locationKey = _normalizeLocationName(storedLocation);
|
||||
} else {
|
||||
// Deviner depuis le nom du fichier
|
||||
final fileName = item.name;
|
||||
final parts = fileName.split('_');
|
||||
if (parts.length >= 2) {
|
||||
locationKey = parts.take(parts.length - 1).join('_');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Utiliser le nom du fichier comme fallback
|
||||
final fileName = item.name;
|
||||
final parts = fileName.split('_');
|
||||
if (parts.length >= 2) {
|
||||
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) {
|
||||
print('PlaceImageService: Doublons trouvés pour "$location": ${images.length} images');
|
||||
|
||||
// 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++;
|
||||
print('PlaceImageService: Doublon supprimé: ${images[i].name}');
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur suppression ${images[i].name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Gardé: ${images[0].name} (plus récent)');
|
||||
}
|
||||
}
|
||||
|
||||
print('PlaceImageService: Nettoyage des doublons terminé. $deletedCount images supprimées.');
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors du nettoyage des doublons: $e');
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors du nettoyage des doublons: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le timestamp du nom de fichier
|
||||
int _extractTimestampFromName(String fileName) {
|
||||
final parts = fileName.split('_');
|
||||
if (parts.isNotEmpty) {
|
||||
final lastPart = parts.last.split('.').first; // Enlever l'extension
|
||||
return int.tryParse(lastPart) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Récupère uniquement l'URL d'une image existante dans le Storage (sans télécharger)
|
||||
Future<String?> getExistingImageUrl(String location) async {
|
||||
try {
|
||||
print('PlaceImageService: Recherche d\'image existante pour: $location');
|
||||
final existingUrl = await _checkExistingImage(location);
|
||||
if (existingUrl != null) {
|
||||
print('PlaceImageService: Image existante trouvée: $existingUrl');
|
||||
return existingUrl;
|
||||
}
|
||||
print('PlaceImageService: Aucune image existante trouvée pour: $location');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('PlaceImageService: Erreur lors de la recherche d\'image existante: $e');
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors de la recherche d\'image existante: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une image du storage (optionnel, pour le nettoyage)
|
||||
Future<void> deleteImage(String imageUrl) async {
|
||||
try {
|
||||
final ref = _storage.refFromURL(imageUrl);
|
||||
await ref.delete();
|
||||
} catch (e) {
|
||||
_errorService.logError('PlaceImageService', 'Erreur lors de la suppression de l\'image: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user