627 lines
19 KiB
Dart
627 lines
19 KiB
Dart
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,
|
|
int maxResults = 20,
|
|
int offset = 0,
|
|
}) async {
|
|
try {
|
|
print(
|
|
'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<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 au total',
|
|
);
|
|
|
|
// 4. Appliquer la pagination
|
|
final startIndex = offset;
|
|
final endIndex = (startIndex + maxResults).clamp(
|
|
0,
|
|
uniqueActivities.length,
|
|
);
|
|
|
|
if (startIndex >= uniqueActivities.length) {
|
|
print(
|
|
'ActivityPlacesService: Offset $startIndex dépasse le nombre total (${uniqueActivities.length})',
|
|
);
|
|
return [];
|
|
}
|
|
|
|
final paginatedResults = uniqueActivities.sublist(startIndex, endIndex);
|
|
print(
|
|
'ActivityPlacesService: Retour de ${paginatedResults.length} activités (offset: $offset, max: $maxResults)',
|
|
);
|
|
|
|
return paginatedResults;
|
|
} 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, dynamic>> _geocodeDestination(String destination) async {
|
|
try {
|
|
// Vérifier que la clé API est configurée
|
|
if (_apiKey.isEmpty) {
|
|
print('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';
|
|
|
|
print('ActivityPlacesService: Géocodage de "$destination"');
|
|
print('ActivityPlacesService: URL = $url');
|
|
|
|
final response = await http.get(Uri.parse(url));
|
|
|
|
print('ActivityPlacesService: Status code = ${response.statusCode}');
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body);
|
|
print('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(),
|
|
};
|
|
print('ActivityPlacesService: Coordonnées trouvées = $coordinates');
|
|
return coordinates;
|
|
} else {
|
|
print(
|
|
'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) {
|
|
print('ActivityPlacesService: Erreur géocodage: $e');
|
|
rethrow; // Rethrow pour permettre la gestion d'erreur en amont
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
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
|
|
}
|
|
|
|
/// Recherche d'activités avec pagination (6 par page)
|
|
Future<Map<String, dynamic>> 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;
|
|
print(
|
|
'ActivityPlacesService: Utilisation des coordonnées pré-géolocalisées: $lat, $lng',
|
|
);
|
|
} else if (destination != null) {
|
|
print(
|
|
'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');
|
|
}
|
|
|
|
print(
|
|
'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) {
|
|
print('ActivityPlacesService: Erreur recherche paginée: $e');
|
|
_errorService.logError('activity_places_service', e);
|
|
return {
|
|
'activities': <Activity>[],
|
|
'nextPageToken': null,
|
|
'hasMoreData': false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Recherche paginée par catégorie spécifique
|
|
Future<Map<String, dynamic>> _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<Activity> 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) {
|
|
print('ActivityPlacesService: Erreur conversion place: $e');
|
|
}
|
|
}
|
|
|
|
return {
|
|
'activities': activities,
|
|
'nextPageToken': data['next_page_token'],
|
|
'hasMoreData': data['next_page_token'] != null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
'activities': <Activity>[],
|
|
'nextPageToken': null,
|
|
'hasMoreData': false,
|
|
};
|
|
} catch (e) {
|
|
print('ActivityPlacesService: Erreur recherche catégorie paginée: $e');
|
|
return {
|
|
'activities': <Activity>[],
|
|
'nextPageToken': null,
|
|
'hasMoreData': false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Recherche paginée pour toutes les catégories
|
|
Future<Map<String, dynamic>> _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<Activity> 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<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': activities,
|
|
'nextPageToken': data['next_page_token'],
|
|
'hasMoreData': data['next_page_token'] != null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
'activities': <Activity>[],
|
|
'nextPageToken': null,
|
|
'hasMoreData': false,
|
|
};
|
|
} catch (e) {
|
|
print(
|
|
'ActivityPlacesService: Erreur recherche toutes catégories paginée: $e',
|
|
);
|
|
return {
|
|
'activities': <Activity>[],
|
|
'nextPageToken': null,
|
|
'hasMoreData': false,
|
|
};
|
|
}
|
|
}
|
|
}
|