feat: Implement activity management feature with Firestore integration

- Added AddActivityBottomSheet for adding custom activities to trips.
- Created Activity model to represent tourist activities.
- Developed ActivityRepository for managing activities in Firestore.
- Integrated ActivityPlacesService for searching activities via Google Places API.
- Updated ShowTripDetailsContent to navigate to activities page.
- Enhanced main.dart to include ActivityBloc and necessary repositories.
This commit is contained in:
Dayron
2025-11-03 16:40:33 +01:00
parent 64fcc88984
commit 8ff9e12fd4
11 changed files with 3185 additions and 1 deletions

View File

@@ -0,0 +1,341 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../models/activity.dart';
import '../services/error_service.dart';
/// Service pour rechercher des activités touristiques via Google Places API
class ActivityPlacesService {
static final ActivityPlacesService _instance = ActivityPlacesService._internal();
factory ActivityPlacesService() => _instance;
ActivityPlacesService._internal();
final ErrorService _errorService = ErrorService();
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
/// Recherche des activités près d'une destination
Future<List<Activity>> searchActivities({
required String destination,
required String tripId,
ActivityCategory? category,
int radius = 5000,
}) async {
try {
print('ActivityPlacesService: Recherche d\'activités pour: $destination');
// 1. Géocoder la destination
final coordinates = await _geocodeDestination(destination);
if (coordinates == null) {
throw Exception('Impossible de localiser la destination: $destination');
}
// 2. Rechercher les activités par catégorie ou toutes les catégories
List<Activity> allActivities = [];
if (category != null) {
final activities = await _searchByCategory(
coordinates['lat']!,
coordinates['lng']!,
category,
tripId,
radius,
);
allActivities.addAll(activities);
} else {
// Rechercher dans toutes les catégories principales
final mainCategories = [
ActivityCategory.attraction,
ActivityCategory.museum,
ActivityCategory.restaurant,
ActivityCategory.culture,
ActivityCategory.nature,
];
for (final cat in mainCategories) {
final activities = await _searchByCategory(
coordinates['lat']!,
coordinates['lng']!,
cat,
tripId,
radius,
);
allActivities.addAll(activities);
}
}
// 3. Supprimer les doublons et trier par note
final uniqueActivities = _removeDuplicates(allActivities);
uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0));
print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées');
return uniqueActivities.take(50).toList(); // Limiter à 50 résultats
} catch (e) {
print('ActivityPlacesService: Erreur lors de la recherche: $e');
_errorService.logError('activity_places_service', e);
return [];
}
}
/// Géocode une destination pour obtenir les coordonnées
Future<Map<String, double>?> _geocodeDestination(String destination) async {
try {
final encodedDestination = Uri.encodeComponent(destination);
final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
final location = data['results'][0]['geometry']['location'];
return {
'lat': location['lat'].toDouble(),
'lng': location['lng'].toDouble(),
};
}
}
return null;
} catch (e) {
print('ActivityPlacesService: Erreur géocodage: $e');
return null;
}
}
/// Recherche des activités par catégorie
Future<List<Activity>> _searchByCategory(
double lat,
double lng,
ActivityCategory category,
String tripId,
int radius,
) async {
try {
final url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json'
'?location=$lat,$lng'
'&radius=$radius'
'&type=${category.googlePlaceType}'
'&key=$_apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final List<Activity> activities = [];
for (final place in data['results']) {
try {
final activity = await _convertPlaceToActivity(place, tripId, category);
if (activity != null) {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
}
}
return activities;
}
}
return [];
} catch (e) {
print('ActivityPlacesService: Erreur recherche par catégorie: $e');
return [];
}
}
/// Convertit un résultat Google Places en Activity
Future<Activity?> _convertPlaceToActivity(
Map<String, dynamic> place,
String tripId,
ActivityCategory category,
) async {
try {
final placeId = place['place_id'];
if (placeId == null) return null;
// Récupérer les détails supplémentaires
final details = await _getPlaceDetails(placeId);
final geometry = place['geometry']?['location'];
final photos = place['photos'] as List?;
// Obtenir une image de qualité
String? imageUrl;
if (photos != null && photos.isNotEmpty) {
final photoReference = photos.first['photo_reference'];
imageUrl = 'https://maps.googleapis.com/maps/api/place/photo'
'?maxwidth=800'
'&photoreference=$photoReference'
'&key=$_apiKey';
}
return Activity(
id: '', // Sera généré lors de la sauvegarde
tripId: tripId,
name: place['name'] ?? 'Activité inconnue',
description: details?['editorial_summary']?['overview'] ??
details?['formatted_address'] ??
'Découvrez cette activité incontournable !',
category: category.displayName,
imageUrl: imageUrl,
rating: place['rating']?.toDouble(),
priceLevel: _getPriceLevelString(place['price_level']),
address: details?['formatted_address'] ?? place['vicinity'],
latitude: geometry?['lat']?.toDouble(),
longitude: geometry?['lng']?.toDouble(),
placeId: placeId,
website: details?['website'],
phoneNumber: details?['formatted_phone_number'],
openingHours: _parseOpeningHours(details?['opening_hours']),
votes: {},
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
return null;
}
}
/// Récupère les détails d'un lieu
Future<Map<String, dynamic>?> _getPlaceDetails(String placeId) async {
try {
final url = 'https://maps.googleapis.com/maps/api/place/details/json'
'?place_id=$placeId'
'&fields=formatted_address,formatted_phone_number,website,opening_hours,editorial_summary'
'&key=$_apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
return data['result'];
}
}
return null;
} catch (e) {
print('ActivityPlacesService: Erreur récupération détails: $e');
return null;
}
}
/// Convertit le niveau de prix en string
String? _getPriceLevelString(int? priceLevel) {
if (priceLevel == null) return null;
final level = PriceLevel.fromLevel(priceLevel);
return level?.displayName;
}
/// Parse les heures d'ouverture
List<String> _parseOpeningHours(Map<String, dynamic>? openingHours) {
if (openingHours == null) return [];
final weekdayText = openingHours['weekday_text'] as List?;
if (weekdayText == null) return [];
return weekdayText.cast<String>();
}
/// Supprime les doublons basés sur le placeId
List<Activity> _removeDuplicates(List<Activity> activities) {
final seen = <String>{};
return activities.where((activity) {
if (activity.placeId == null) return true;
if (seen.contains(activity.placeId)) return false;
seen.add(activity.placeId!);
return true;
}).toList();
}
/// Recherche d'activités par texte libre
Future<List<Activity>> searchActivitiesByText({
required String query,
required String destination,
required String tripId,
int radius = 5000,
}) async {
try {
print('ActivityPlacesService: Recherche textuelle: $query à $destination');
// Géocoder la destination
final coordinates = await _geocodeDestination(destination);
if (coordinates == null) {
throw Exception('Impossible de localiser la destination');
}
final encodedQuery = Uri.encodeComponent(query);
final url = 'https://maps.googleapis.com/maps/api/place/textsearch/json'
'?query=$encodedQuery in $destination'
'&location=${coordinates['lat']},${coordinates['lng']}'
'&radius=$radius'
'&key=$_apiKey';
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final List<Activity> activities = [];
for (final place in data['results']) {
try {
// Déterminer la catégorie basée sur les types du lieu
final types = List<String>.from(place['types'] ?? []);
final category = _determineCategoryFromTypes(types);
final activity = await _convertPlaceToActivity(place, tripId, category);
if (activity != null) {
activities.add(activity);
}
} catch (e) {
print('ActivityPlacesService: Erreur conversion place: $e');
}
}
return activities;
}
}
return [];
} catch (e) {
print('ActivityPlacesService: Erreur recherche textuelle: $e');
return [];
}
}
/// Détermine la catégorie d'activité basée sur les types Google Places
ActivityCategory _determineCategoryFromTypes(List<String> types) {
for (final type in types) {
for (final category in ActivityCategory.values) {
if (category.googlePlaceType == type) {
return category;
}
}
}
// Catégories par défaut basées sur des types communs
if (types.contains('restaurant') || types.contains('food')) {
return ActivityCategory.restaurant;
} else if (types.contains('museum')) {
return ActivityCategory.museum;
} else if (types.contains('park')) {
return ActivityCategory.nature;
} else if (types.contains('shopping_mall') || types.contains('store')) {
return ActivityCategory.shopping;
} else if (types.contains('night_club') || types.contains('bar')) {
return ActivityCategory.nightlife;
}
return ActivityCategory.attraction; // Par défaut
}
}