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