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:
Dayron
2025-11-03 14:33:58 +01:00
parent 83aed85fea
commit e3dad39c4f
16 changed files with 2415 additions and 190 deletions

View File

@@ -19,6 +19,7 @@ import '../../services/user_service.dart';
import '../../repositories/group_repository.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../services/place_image_service.dart';
/// Create trip content widget for trip creation and editing functionality.
///
@@ -69,6 +70,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
/// Services for user and group operations
final _userService = UserService();
final _groupRepository = GroupRepository();
final _placeImageService = PlaceImageService();
/// Trip date variables
DateTime? _startDate;
@@ -77,6 +79,8 @@ class _CreateTripContentState extends State<CreateTripContent> {
/// Loading and state management variables
bool _isLoading = false;
String? _createdTripId;
String? _selectedImageUrl;
bool _isLoadingImage = false;
/// Google Maps API key for location services
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
@@ -111,6 +115,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
_budgetController.text = trip.budget?.toString() ?? '';
_startDate = trip.startDate;
_endDate = trip.endDate;
_selectedImageUrl = trip.imageUrl; // Charger l'image existante
});
await _loadParticipantEmails(trip.participants);
@@ -250,6 +255,40 @@ class _CreateTripContentState extends State<CreateTripContent> {
setState(() {
_placeSuggestions = [];
});
// Charger l'image du lieu sélectionné
_loadPlaceImage(suggestion.description);
}
/// Charge l'image du lieu depuis Google Places API
Future<void> _loadPlaceImage(String location) async {
print('CreateTripContent: Chargement de l\'image pour: $location');
setState(() {
_isLoadingImage = true;
});
try {
final imageUrl = await _placeImageService.getPlaceImageUrl(location);
print('CreateTripContent: Image URL reçue: $imageUrl');
if (mounted) {
setState(() {
_selectedImageUrl = imageUrl;
_isLoadingImage = false;
});
print('CreateTripContent: État mis à jour avec imageUrl: $_selectedImageUrl');
}
} catch (e) {
print('CreateTripContent: Erreur lors du chargement de l\'image: $e');
if (mounted) {
setState(() {
_isLoadingImage = false;
});
_errorService.logError(
'create_trip_content.dart',
'Erreur lors du chargement de l\'image: $e',
);
}
}
}
Future<void> _loadParticipantEmails(List<String> participantIds) async {
@@ -420,6 +459,64 @@ class _CreateTripContentState extends State<CreateTripContent> {
),
),
const SizedBox(height: 16),
// Aperçu de l'image du lieu
if (_isLoadingImage || _selectedImageUrl != null) ...[
_buildSectionTitle('Aperçu de la destination'),
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: _isLoadingImage
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Chargement de l\'image...'),
],
),
)
: _selectedImageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
_selectedImageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, color: Colors.grey),
Text('Erreur de chargement'),
],
),
),
);
},
),
)
: const SizedBox(),
),
const SizedBox(height: 16),
],
const SizedBox(height: 24),
_buildSectionTitle('Dates du voyage'),
@@ -891,6 +988,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
participants: participantIds,
createdAt: isEditing ? widget.tripToEdit!.createdAt : DateTime.now(),
updatedAt: DateTime.now(),
imageUrl: _selectedImageUrl, // Ajouter l'URL de l'image
);
if (isEditing) {

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/components/home/create_trip_content.dart';
import '../home/show_trip_details_content.dart';
import 'package:travel_mate/components/home/trip_card.dart';
import 'package:travel_mate/services/trip_image_service.dart';
import '../../blocs/user/user_bloc.dart';
import '../../blocs/user/user_state.dart';
import '../../blocs/trip/trip_bloc.dart';
@@ -38,6 +40,9 @@ class _HomeContentState extends State<HomeContent>
/// Flag to prevent duplicate trip loading operations
bool _hasLoadedTrips = false;
/// Service pour charger les images manquantes
final TripImageService _tripImageService = TripImageService();
@override
void initState() {
super.initState();
@@ -274,197 +279,41 @@ class _HomeContentState extends State<HomeContent>
}
Widget _buildTripsList(List<Trip> trips) {
return Column(children: trips.map((trip) => _buildTripCard(trip)).toList());
}
Widget _buildTripCard(Trip trip) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final textColor = isDarkMode ? Colors.white : Colors.black;
final subtextColor = isDarkMode ? Colors.white70 : Colors.grey[600];
return Card(
margin: EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image en haut de la carte
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
color: Colors.grey[300],
),
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: trip.imageUrl != null && trip.imageUrl!.isNotEmpty
? Image.network(
trip.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: Icon(
Icons.image_not_supported,
size: 50,
color: Colors.grey[600],
),
);
},
)
: Container(
color: Colors.grey[300],
child: Icon(
Icons.travel_explore,
size: 50,
color: Colors.grey[600],
),
),
),
),
// Contenu de la carte
Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom du voyage
Text(
trip.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textColor,
),
),
SizedBox(height: 8),
// Section dates, participants et bouton
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Colonne gauche : dates et participants
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dates
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: subtextColor,
),
SizedBox(width: 4),
Text(
'${_formatDate(trip.startDate)} - ${_formatDate(trip.endDate)}',
style: TextStyle(
fontSize: 14,
color: subtextColor,
),
),
],
),
SizedBox(height: 8),
// Nombre de participants
Row(
children: [
Icon(Icons.people, size: 16, color: subtextColor),
SizedBox(width: 4),
Text(
'${trip.participants.length} participant${trip.participants.length > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 14,
color: subtextColor,
),
),
],
),
],
),
),
// Bouton "Voir" à droite
ElevatedButton(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ShowTripDetailsContent(trip: trip),
),
);
if (result == true && mounted) {
final userState = context.read<UserBloc>().state;
if (userState is UserLoaded) {
context.read<TripBloc>().add(
LoadTripsByUserId(userId: userState.user.id),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Text('Voir', style: TextStyle(fontSize: 14)),
),
],
),
],
),
),
],
),
// Charger les images manquantes en arrière-plan
_loadMissingImagesInBackground(trips);
return Column(
children: trips.map((trip) => TripCard(
trip: trip,
onTap: () => _showTripDetails(trip),
)).toList(),
);
}
// Color _getStatusColor(Trip trip) {
// final now = DateTime.now();
// if (now.isBefore(trip.startDate)) {
// return Colors.blue;
// } else if (now.isAfter(trip.endDate)) {
// return Colors.grey;
// } else {
// return Colors.green;
// }
// }
/// Charge les images manquantes en arrière-plan
void _loadMissingImagesInBackground(List<Trip> trips) {
// Lancer le chargement des images en arrière-plan sans bloquer l'UI
WidgetsBinding.instance.addPostFrameCallback((_) {
_tripImageService.loadMissingImages(trips);
});
}
// String _getStatusText(Trip trip) {
// final now = DateTime.now();
// if (now.isBefore(trip.startDate)) {
// return 'À venir';
// } else if (now.isAfter(trip.endDate)) {
// return 'Terminé';
// } else {
// return 'En cours';
// }
// }
/// Navigate to trip details page
Future<void> _showTripDetails(Trip trip) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShowTripDetailsContent(trip: trip),
),
);
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
if (result == true && mounted) {
final userState = context.read<UserBloc>().state;
if (userState is UserLoaded) {
context.read<TripBloc>().add(
LoadTripsByUserId(userId: userState.user.id),
);
}
}
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:travel_mate/models/trip.dart';
import 'package:travel_mate/services/place_image_service.dart';
import 'package:travel_mate/repositories/trip_repository.dart';
class TripCard extends StatefulWidget {
final Trip trip;
final VoidCallback? onTap;
const TripCard({
super.key,
required this.trip,
this.onTap,
});
@override
State<TripCard> createState() => _TripCardState();
}
class _TripCardState extends State<TripCard> {
final PlaceImageService _placeImageService = PlaceImageService();
final TripRepository _tripRepository = TripRepository();
String? _currentImageUrl;
bool _isLoadingImage = false;
bool _hasTriedLoading = false;
@override
void initState() {
super.initState();
_currentImageUrl = widget.trip.imageUrl;
// Si aucune image n'est disponible, essayer de la charger
if (_currentImageUrl == null || _currentImageUrl!.isEmpty) {
_loadImageForTrip();
}
}
Future<void> _loadImageForTrip() async {
if (_hasTriedLoading || _isLoadingImage) return;
setState(() {
_isLoadingImage = true;
_hasTriedLoading = true;
});
try {
print('TripCard: Tentative de chargement d\'image pour ${widget.trip.location}');
// D'abord vérifier si une image existe déjà dans le Storage
String? imageUrl = await _placeImageService.getExistingImageUrl(widget.trip.location);
// Si aucune image n'existe, en télécharger une nouvelle
if (imageUrl == null) {
print('TripCard: Aucune image existante, téléchargement via Google Places...');
imageUrl = await _placeImageService.getPlaceImageUrl(widget.trip.location);
}
if (mounted && imageUrl != null) {
setState(() {
_currentImageUrl = imageUrl;
_isLoadingImage = false;
});
// Mettre à jour le voyage dans la base de données avec l'imageUrl
_updateTripWithImage(imageUrl);
print('TripCard: Image chargée avec succès: $imageUrl');
} else {
setState(() {
_isLoadingImage = false;
});
}
} catch (e) {
print('TripCard: Erreur lors du chargement de l\'image: $e');
if (mounted) {
setState(() {
_isLoadingImage = false;
});
}
}
}
Future<void> _updateTripWithImage(String imageUrl) async {
try {
if (widget.trip.id != null) {
// Créer une copie du voyage avec la nouvelle imageUrl
final updatedTrip = widget.trip.copyWith(
imageUrl: imageUrl,
updatedAt: DateTime.now(),
);
// Mettre à jour dans la base de données
await _tripRepository.updateTrip(widget.trip.id!, updatedTrip);
print('TripCard: Voyage mis à jour avec la nouvelle image dans la base de données');
}
} catch (e) {
print('TripCard: Erreur lors de la mise à jour du voyage: $e');
// En cas d'erreur, on continue sans échec - l'image reste affichée localement
}
}
Widget _buildImageWidget() {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
if (_isLoadingImage) {
return Container(
color: isDarkMode ? Colors.grey[700] : Colors.grey[200],
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Chargement de l\'image...'),
],
),
),
);
}
if (_currentImageUrl != null && _currentImageUrl!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: _currentImageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[200],
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => _buildPlaceholderImage(isDarkMode),
);
}
return _buildPlaceholderImage(isDarkMode);
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDarkMode ? Colors.grey[800] : Colors.white;
final textColor = isDarkMode ? Colors.white : Colors.black;
final secondaryTextColor = isDarkMode ? Colors.white70 : Colors.grey[600];
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image du voyage
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: SizedBox(
height: 200,
width: double.infinity,
child: _buildImageWidget(),
),
), // Informations du voyage
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.trip.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.location_on, size: 16, color: secondaryTextColor),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.trip.location,
style: TextStyle(color: secondaryTextColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
widget.trip.description,
style: TextStyle(color: secondaryTextColor, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dates',
style: TextStyle(
color: secondaryTextColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
Text(
widget.trip.formattedDates,
style: TextStyle(color: textColor, fontSize: 12),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Participants',
style: TextStyle(
color: secondaryTextColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
Text(
'${widget.trip.totalParticipants - 1} personne${widget.trip.totalParticipants > 1 ? 's' : ''}',
style: TextStyle(color: textColor, fontSize: 12),
),
],
),
],
),
if (widget.trip.budget != null && widget.trip.budget! > 0) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Budget: ${widget.trip.budget!.toStringAsFixed(0)}',
style: TextStyle(
color: Colors.green[700],
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
],
),
),
),
);
}
Widget _buildPlaceholderImage(bool isDarkMode) {
return Container(
color: isDarkMode ? Colors.grey[700] : Colors.grey[200],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.location_city,
size: 48,
color: isDarkMode ? Colors.grey[500] : Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'Aucune image',
style: TextStyle(
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
fontSize: 12,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../services/trip_image_service.dart';
/// Page d'administration pour gérer les images des voyages
class ImageManagementPage extends StatefulWidget {
const ImageManagementPage({super.key});
@override
State<ImageManagementPage> createState() => _ImageManagementPageState();
}
class _ImageManagementPageState extends State<ImageManagementPage> {
final TripImageService _tripImageService = TripImageService();
Map<String, dynamic>? _statistics;
bool _isLoading = false;
bool _isCleaningUp = false;
bool _isCleaningDuplicates = false;
String? _cleanupResult;
String? _duplicateCleanupResult;
@override
void initState() {
super.initState();
_loadStatistics();
}
Future<void> _loadStatistics() async {
setState(() {
_isLoading = true;
});
try {
final userId = FirebaseAuth.instance.currentUser?.uid;
if (userId != null) {
final stats = await _tripImageService.getImageStatistics(userId);
setState(() {
_statistics = stats;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement des statistiques: $e')),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _cleanupUnusedImages() async {
// Demander confirmation
final shouldCleanup = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer les images inutilisées'),
content: const Text(
'Cette action supprimera définitivement toutes les images qui ne sont plus utilisées par vos voyages. '
'Voulez-vous continuer ?'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Nettoyer'),
),
],
),
);
if (shouldCleanup != true) return;
setState(() {
_isCleaningUp = true;
_cleanupResult = null;
});
try {
final userId = FirebaseAuth.instance.currentUser?.uid;
if (userId != null) {
await _tripImageService.cleanupUnusedImages(userId);
setState(() {
_cleanupResult = 'Nettoyage terminé avec succès !';
});
// Recharger les statistiques
await _loadStatistics();
}
} catch (e) {
setState(() {
_cleanupResult = 'Erreur lors du nettoyage: $e';
});
} finally {
setState(() {
_isCleaningUp = false;
});
}
}
Future<void> _cleanupDuplicateImages() async {
// Demander confirmation
final shouldCleanup = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer les doublons d\'images'),
content: const Text(
'Cette action analysera vos images et supprimera automatiquement les doublons pour la même destination. '
'Seule l\'image la plus récente sera conservée. Voulez-vous continuer ?'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Nettoyer les doublons'),
),
],
),
);
if (shouldCleanup != true) return;
setState(() {
_isCleaningDuplicates = true;
_duplicateCleanupResult = null;
});
try {
await _tripImageService.cleanupDuplicateImages();
setState(() {
_duplicateCleanupResult = 'Doublons supprimés avec succès !';
});
// Recharger les statistiques
await _loadStatistics();
} catch (e) {
setState(() {
_duplicateCleanupResult = 'Erreur lors du nettoyage des doublons: $e';
});
} finally {
setState(() {
_isCleaningDuplicates = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des Images'),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Statistiques
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statistiques',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_statistics != null) ...[
_buildStatItem(
'Voyages totaux',
_statistics!['totalTrips']?.toString() ?? '0'
),
_buildStatItem(
'Voyages avec image',
_statistics!['tripsWithImages']?.toString() ?? '0'
),
_buildStatItem(
'Voyages sans image',
_statistics!['tripsWithoutImages']?.toString() ?? '0'
),
const SizedBox(height: 8),
Text(
'Dernière mise à jour: ${_formatTimestamp(_statistics!['timestamp'])}',
style: Theme.of(context).textTheme.bodySmall,
),
] else
const Text('Impossible de charger les statistiques'),
],
),
),
),
const SizedBox(height: 24),
// Actions
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
// Bouton recharger statistiques
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _loadStatistics,
icon: const Icon(Icons.refresh),
label: const Text('Recharger les statistiques'),
),
),
const SizedBox(height: 12),
// Bouton nettoyer les images inutilisées
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isCleaningUp ? null : _cleanupUnusedImages,
icon: _isCleaningUp
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cleaning_services),
label: Text(_isCleaningUp ? 'Nettoyage en cours...' : 'Nettoyer les images inutilisées'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
),
const SizedBox(height: 12),
// Bouton nettoyer les doublons
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isCleaningDuplicates ? null : _cleanupDuplicateImages,
icon: _isCleaningDuplicates
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.content_copy),
label: Text(_isCleaningDuplicates ? 'Suppression doublons...' : 'Supprimer les doublons d\'images'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
),
if (_cleanupResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _cleanupResult!.contains('Erreur')
? Colors.red.withValues(alpha: 0.1)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _cleanupResult!.contains('Erreur')
? Colors.red
: Colors.green,
width: 1,
),
),
child: Text(
_cleanupResult!,
style: TextStyle(
color: _cleanupResult!.contains('Erreur')
? Colors.red
: Colors.green,
fontWeight: FontWeight.bold,
),
),
),
],
if (_duplicateCleanupResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _duplicateCleanupResult!.contains('Erreur')
? Colors.red.withValues(alpha: 0.1)
: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _duplicateCleanupResult!.contains('Erreur')
? Colors.red
: Colors.blue,
width: 1,
),
),
child: Text(
_duplicateCleanupResult!,
style: TextStyle(
color: _duplicateCleanupResult!.contains('Erreur')
? Colors.red
: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
),
const SizedBox(height: 24),
// Informations
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Explications',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
const Text(
'• Chaque voyage peut avoir une image automatiquement téléchargée depuis Google Places\n'
'• Les images sont stockées dans Firebase Storage\n'
'• Il peut y avoir des images inutilisées si des voyages ont été supprimés ou modifiés\n'
'• Le nettoyage supprime uniquement les images qui ne sont plus référencées par vos voyages',
style: TextStyle(height: 1.5),
),
],
),
),
),
],
),
),
);
}
Widget _buildStatItem(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
}
String _formatTimestamp(String? timestamp) {
if (timestamp == null) return 'Inconnue';
try {
final dateTime = DateTime.parse(timestamp);
return '${dateTime.day}/${dateTime.month}/${dateTime.year} '
'${dateTime.hour.toString().padLeft(2, '0')}:'
'${dateTime.minute.toString().padLeft(2, '0')}';
} catch (e) {
return 'Format invalide';
}
}
}

View File

@@ -16,15 +16,19 @@ class ExpenseRepository {
// Stream des dépenses d'un groupe
Stream<List<Expense>> getExpensesStream(String groupId) {
// Utiliser une requête simple pour éviter les problèmes d'index
return _expensesCollection
.where('groupId', isEqualTo: groupId)
.where('isArchived', isEqualTo: false)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
final expenses = snapshot.docs
.map((doc) => Expense.fromMap(doc.data() as Map<String, dynamic>, doc.id))
.where((expense) => !expense.isArchived) // Filtrer côté client
.toList();
// Trier côté client par date de création (plus récent en premier)
expenses.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return expenses;
}).handleError((error) {
_errorService.logError('ExpenseRepository', 'Erreur stream expenses: $error');
return <Expense>[];

View 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');
}
}
}

View File

@@ -0,0 +1,150 @@
import 'package:travel_mate/models/trip.dart';
import 'package:travel_mate/services/place_image_service.dart';
import 'package:travel_mate/repositories/trip_repository.dart';
import 'package:travel_mate/services/error_service.dart';
/// Service pour gérer le chargement automatique des images des voyages
class TripImageService {
final PlaceImageService _placeImageService = PlaceImageService();
final TripRepository _tripRepository = TripRepository();
final ErrorService _errorService = ErrorService();
/// Charge les images manquantes pour une liste de voyages
Future<void> loadMissingImages(List<Trip> trips) async {
final tripsWithoutImage = trips.where(
(trip) => trip.imageUrl == null || trip.imageUrl!.isEmpty
).toList();
if (tripsWithoutImage.isEmpty) {
return;
}
for (final trip in tripsWithoutImage) {
try {
await _loadImageForTrip(trip);
// Petite pause entre les requêtes pour éviter de surcharger l'API
await Future.delayed(const Duration(milliseconds: 500));
} catch (e) {
_errorService.logError(
'TripImageService',
'Erreur lors du chargement d\'image pour ${trip.title}: $e',
);
}
}
}
/// Charge l'image pour un voyage spécifique
Future<void> _loadImageForTrip(Trip trip) async {
print('TripImageService: Recherche d\'image pour ${trip.title} (${trip.location})');
// D'abord vérifier si une image existe déjà dans le Storage
String? imageUrl = await _placeImageService.getExistingImageUrl(trip.location);
// Si aucune image n'existe, en télécharger une nouvelle
if (imageUrl == null) {
print('TripImageService: Aucune image existante, téléchargement via Google Places...');
imageUrl = await _placeImageService.getPlaceImageUrl(trip.location);
}
if (imageUrl != null && trip.id != null) {
// Mettre à jour le voyage avec l'image (existante ou nouvelle)
final updatedTrip = trip.copyWith(
imageUrl: imageUrl,
updatedAt: DateTime.now(),
);
await _tripRepository.updateTrip(trip.id!, updatedTrip);
print('TripImageService: Image mise à jour pour ${trip.title}: $imageUrl');
} else {
print('TripImageService: Aucune image trouvée pour ${trip.title}');
}
}
/// Recharge l'image d'un voyage spécifique (force le rechargement)
Future<String?> reloadImageForTrip(Trip trip) async {
try {
final imageUrl = await _placeImageService.getPlaceImageUrl(trip.location);
if (imageUrl != null && trip.id != null) {
final updatedTrip = trip.copyWith(
imageUrl: imageUrl,
updatedAt: DateTime.now(),
);
await _tripRepository.updateTrip(trip.id!, updatedTrip);
return imageUrl;
}
return null;
} catch (e) {
_errorService.logError(
'TripImageService',
'Erreur lors du rechargement d\'image pour ${trip.title}: $e',
);
return null;
}
}
/// Nettoie les images inutilisées du stockage
Future<void> cleanupUnusedImages(String userId) async {
try {
// Récupérer tous les voyages de l'utilisateur
final tripsStream = _tripRepository.getTripsByUserId(userId);
final trips = await tripsStream.first;
// Extraire toutes les URLs d'images utilisées
final usedImageUrls = trips
.where((trip) => trip.imageUrl != null && trip.imageUrl!.isNotEmpty)
.map((trip) => trip.imageUrl!)
.toList();
// Nettoyer les images inutilisées
await _placeImageService.cleanupUnusedImages(usedImageUrls);
} catch (e) {
_errorService.logError(
'TripImageService',
'Erreur lors du nettoyage des images: $e',
);
}
}
/// Obtient des statistiques sur les images stockées
Future<Map<String, dynamic>> getImageStatistics(String userId) async {
try {
final tripsStream = _tripRepository.getTripsByUserId(userId);
final trips = await tripsStream.first;
final tripsWithImages = trips.where((trip) => trip.imageUrl != null && trip.imageUrl!.isNotEmpty).length;
final tripsWithoutImages = trips.length - tripsWithImages;
return {
'totalTrips': trips.length,
'tripsWithImages': tripsWithImages,
'tripsWithoutImages': tripsWithoutImages,
'timestamp': DateTime.now().toIso8601String(),
};
} catch (e) {
_errorService.logError('TripImageService', 'Erreur lors de l\'obtention des statistiques: $e');
return {
'error': e.toString(),
'timestamp': DateTime.now().toIso8601String(),
};
}
}
/// Nettoie spécifiquement les doublons d'images
Future<void> cleanupDuplicateImages() async {
try {
print('TripImageService: Début du nettoyage des doublons...');
await _placeImageService.cleanupDuplicateImages();
print('TripImageService: Nettoyage des doublons terminé');
} catch (e) {
print('TripImageService: Erreur lors du nettoyage des doublons: $e');
_errorService.logError(
'TripImageService',
'Erreur lors du nettoyage des doublons: $e',
);
}
}
}