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'; import '../services/logger_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> searchActivities({ required String destination, required String tripId, ActivityCategory? category, int radius = 5000, int maxResults = 20, int offset = 0, }) async { try { LoggerService.info( 'ActivityPlacesService: Recherche d\'activités pour: $destination (max: $maxResults, offset: $offset)', ); // 1. Géocoder la destination final coordinates = await _geocodeDestination(destination); // 2. Rechercher les activités par catégorie ou toutes les catégories List 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)); LoggerService.info( 'ActivityPlacesService: ${uniqueActivities.length} activités trouvées au total', ); // 4. Appliquer la pagination final startIndex = offset; final endIndex = (startIndex + maxResults).clamp( 0, uniqueActivities.length, ); if (startIndex >= uniqueActivities.length) { LoggerService.info( 'ActivityPlacesService: Offset $startIndex dépasse le nombre total (${uniqueActivities.length})', ); return []; } final paginatedResults = uniqueActivities.sublist(startIndex, endIndex); LoggerService.info( 'ActivityPlacesService: Retour de ${paginatedResults.length} activités (offset: $offset, max: $maxResults)', ); return paginatedResults; } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur lors de la recherche: $e', ); _errorService.logError('activity_places_service', e); return []; } } /// Géocode une destination pour obtenir les coordonnées Future> _geocodeDestination(String destination) async { try { // Vérifier que la clé API est configurée if (_apiKey.isEmpty) { LoggerService.error( 'ActivityPlacesService: Clé API Google Maps manquante', ); throw Exception('Clé API Google Maps non configurée'); } final encodedDestination = Uri.encodeComponent(destination); final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey'; LoggerService.info('ActivityPlacesService: Géocodage de "$destination"'); LoggerService.info('ActivityPlacesService: URL = $url'); final response = await http.get(Uri.parse(url)); LoggerService.info( 'ActivityPlacesService: Status code = ${response.statusCode}', ); if (response.statusCode == 200) { final data = json.decode(response.body); LoggerService.info( 'ActivityPlacesService: Réponse géocodage = ${data['status']}', ); if (data['status'] == 'OK' && data['results'].isNotEmpty) { final location = data['results'][0]['geometry']['location']; final coordinates = { 'lat': location['lat'].toDouble(), 'lng': location['lng'].toDouble(), }; LoggerService.info( 'ActivityPlacesService: Coordonnées trouvées = $coordinates', ); return coordinates; } else { LoggerService.error( 'ActivityPlacesService: Erreur API = ${data['error_message'] ?? data['status']}', ); if (data['status'] == 'REQUEST_DENIED') { throw Exception( '🔑 Clé API non autorisée. Activez les APIs suivantes dans Google Cloud Console:\n' '• Geocoding API\n' '• Places API\n' '• Maps JavaScript API\n' 'Puis ajoutez des restrictions appropriées.', ); } else if (data['status'] == 'ZERO_RESULTS') { throw Exception('Aucun résultat trouvé pour cette destination'); } else { throw Exception('Erreur API: ${data['status']}'); } } } else { throw Exception('Erreur HTTP ${response.statusCode}'); } } catch (e) { LoggerService.error('ActivityPlacesService: Erreur géocodage: $e'); rethrow; // Rethrow pour permettre la gestion d'erreur en amont } } /// Recherche des activités par catégorie Future> _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 activities = []; for (final place in data['results']) { try { final activity = await _convertPlaceToActivity( place, tripId, category, ); if (activity != null) { activities.add(activity); } } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur conversion place: $e', ); } } return activities; } } return []; } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur recherche par catégorie: $e', ); return []; } } /// Convertit un résultat Google Places en Activity Future _convertPlaceToActivity( Map 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) { LoggerService.error('ActivityPlacesService: Erreur conversion place: $e'); return null; } } /// Récupère les détails d'un lieu Future?> _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) { LoggerService.error( '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 _parseOpeningHours(Map? openingHours) { if (openingHours == null) return []; final weekdayText = openingHours['weekday_text'] as List?; if (weekdayText == null) return []; return weekdayText.cast(); } /// Supprime les doublons basés sur le placeId List _removeDuplicates(List activities) { final seen = {}; 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> searchActivitiesByText({ required String query, required String destination, required String tripId, int radius = 5000, }) async { try { LoggerService.info( 'ActivityPlacesService: Recherche textuelle: $query à $destination', ); // Géocoder la destination final coordinates = await _geocodeDestination(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 activities = []; for (final place in data['results']) { try { // Déterminer la catégorie basée sur les types du lieu final types = List.from(place['types'] ?? []); final category = _determineCategoryFromTypes(types); final activity = await _convertPlaceToActivity( place, tripId, category, ); if (activity != null) { activities.add(activity); } } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur conversion place: $e', ); } } return activities; } } return []; } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur recherche textuelle: $e', ); return []; } } /// Détermine la catégorie d'activité basée sur les types Google Places ActivityCategory _determineCategoryFromTypes(List 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 } /// Recherche d'activités avec pagination (6 par page) Future> searchActivitiesPaginated({ String? destination, double? latitude, double? longitude, required String tripId, ActivityCategory? category, int pageSize = 6, String? nextPageToken, int radius = 5000, }) async { try { double lat, lng; // Utiliser les coordonnées fournies ou géocoder la destination if (latitude != null && longitude != null) { lat = latitude; lng = longitude; LoggerService.info( 'ActivityPlacesService: Utilisation des coordonnées pré-géolocalisées: $lat, $lng', ); } else if (destination != null) { LoggerService.info( 'ActivityPlacesService: Géolocalisation de la destination: $destination', ); final coordinates = await _geocodeDestination(destination); lat = coordinates['lat']!; lng = coordinates['lng']!; } else { throw Exception('Destination ou coordonnées requises'); } LoggerService.info( 'ActivityPlacesService: Recherche paginée aux coordonnées: $lat, $lng (page: ${nextPageToken ?? "première"})', ); // 2. Rechercher les activités par catégorie avec pagination if (category != null) { return await _searchByCategoryPaginated( lat, lng, category, tripId, radius, pageSize, nextPageToken, ); } else { // Pour toutes les catégories, faire une recherche générale paginée return await _searchAllCategoriesPaginated( lat, lng, tripId, radius, pageSize, nextPageToken, ); } } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur recherche paginée: $e', ); _errorService.logError('activity_places_service', e); return { 'activities': [], 'nextPageToken': null, 'hasMoreData': false, }; } } /// Recherche paginée par catégorie spécifique Future> _searchByCategoryPaginated( double lat, double lng, ActivityCategory category, String tripId, int radius, int pageSize, String? nextPageToken, ) async { try { String url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json' '?location=$lat,$lng' '&radius=$radius' '&type=${category.googlePlaceType}' '&key=$_apiKey'; if (nextPageToken != null) { url += '&pagetoken=$nextPageToken'; } final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { final data = json.decode(response.body); if (data['status'] == 'OK') { final List activities = []; final results = data['results'] as List? ?? []; // Limiter à pageSize résultats final limitedResults = results.take(pageSize).toList(); for (final place in limitedResults) { try { final activity = await _convertPlaceToActivity( place, tripId, category, ); if (activity != null) { activities.add(activity); } } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur conversion place: $e', ); } } return { 'activities': activities, 'nextPageToken': data['next_page_token'], 'hasMoreData': data['next_page_token'] != null, }; } } return { 'activities': [], 'nextPageToken': null, 'hasMoreData': false, }; } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur recherche catégorie paginée: $e', ); return { 'activities': [], 'nextPageToken': null, 'hasMoreData': false, }; } } /// Recherche paginée pour toutes les catégories Future> _searchAllCategoriesPaginated( double lat, double lng, String tripId, int radius, int pageSize, String? nextPageToken, ) async { try { // Pour toutes les catégories, on utilise une recherche plus générale String url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json' '?location=$lat,$lng' '&radius=$radius' '&type=tourist_attraction' '&key=$_apiKey'; if (nextPageToken != null) { url += '&pagetoken=$nextPageToken'; } final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { final data = json.decode(response.body); if (data['status'] == 'OK') { final List activities = []; final results = data['results'] as List? ?? []; // Limiter à pageSize résultats final limitedResults = results.take(pageSize).toList(); for (final place in limitedResults) { try { // Déterminer la catégorie basée sur les types du lieu final types = List.from(place['types'] ?? []); final category = _determineCategoryFromTypes(types); final activity = await _convertPlaceToActivity( place, tripId, category, ); if (activity != null) { activities.add(activity); } } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur conversion place: $e', ); } } return { 'activities': activities, 'nextPageToken': data['next_page_token'], 'hasMoreData': data['next_page_token'] != null, }; } } return { 'activities': [], 'nextPageToken': null, 'hasMoreData': false, }; } catch (e) { LoggerService.error( 'ActivityPlacesService: Erreur recherche toutes catégories paginée: $e', ); return { 'activities': [], 'nextPageToken': null, 'hasMoreData': false, }; } } }