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

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