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,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,
),
),
],
),
),
);
}
}