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:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
299
lib/components/home/trip_card.dart
Normal file
299
lib/components/home/trip_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/components/settings/image_management_page.dart
Normal file
398
lib/components/settings/image_management_page.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>[];
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
150
lib/services/trip_image_service.dart
Normal file
150
lib/services/trip_image_service.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user