diff --git a/lib/blocs/activity/activity_bloc.dart b/lib/blocs/activity/activity_bloc.dart index 60770fe..6308075 100644 --- a/lib/blocs/activity/activity_bloc.dart +++ b/lib/blocs/activity/activity_bloc.dart @@ -61,17 +61,36 @@ class ActivityBloc extends Bloc { Emitter emit, ) async { try { - emit(const ActivitySearching()); + // Si c'est un append (charger plus), on garde l'état actuel et on met isLoading à true + if (event.appendToExisting && state is ActivitySearchResults) { + final currentState = state as ActivitySearchResults; + emit(currentState.copyWith(isLoading: true)); + } else { + emit(const ActivitySearching()); + } final searchResults = await _placesService.searchActivities( destination: event.destination, tripId: event.tripId, category: event.category, + maxResults: event.maxResults ?? 20, // Par défaut 20, ou utiliser la valeur spécifiée + offset: event.offset ?? 0, // Par défaut 0 ); + List finalResults; + + // Si on doit ajouter aux résultats existants + if (event.appendToExisting && state is ActivitySearchResults) { + final currentState = state as ActivitySearchResults; + finalResults = [...currentState.searchResults, ...searchResults]; + } else { + finalResults = searchResults; + } + emit(ActivitySearchResults( - searchResults: searchResults, + searchResults: finalResults, query: event.category?.displayName ?? 'Toutes les activités', + isLoading: false, )); } catch (e) { _errorService.logError('activity_bloc', 'Erreur recherche activités: $e'); diff --git a/lib/blocs/activity/activity_event.dart b/lib/blocs/activity/activity_event.dart index 67664de..e9dbf41 100644 --- a/lib/blocs/activity/activity_event.dart +++ b/lib/blocs/activity/activity_event.dart @@ -24,15 +24,23 @@ class SearchActivities extends ActivityEvent { final String tripId; final String destination; final ActivityCategory? category; + final int? maxResults; + final int? offset; + final bool reset; + final bool appendToExisting; const SearchActivities({ required this.tripId, required this.destination, this.category, + this.maxResults, + this.offset, + this.reset = false, + this.appendToExisting = false, }); @override - List get props => [tripId, destination, category]; + List get props => [tripId, destination, category, maxResults, offset, reset, appendToExisting]; } /// Event to search activities by text query diff --git a/lib/blocs/activity/approved_activities/approved_activity_bloc.dart b/lib/blocs/activity/approved_activities/approved_activity_bloc.dart new file mode 100644 index 0000000..dc2c9a7 --- /dev/null +++ b/lib/blocs/activity/approved_activities/approved_activity_bloc.dart @@ -0,0 +1,158 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../models/activity.dart'; +import '../../../repositories/activity_repository.dart'; +import '../../../services/error_service.dart'; +import 'approved_activity_event.dart'; +import 'approved_activity_state.dart'; + +/// BLoC pour gérer les activités approuvées par tous les participants +class ApprovedActivityBloc extends Bloc { + final ActivityRepository _repository; + final ErrorService _errorService; + + ApprovedActivityBloc({ + required ActivityRepository repository, + required ErrorService errorService, + }) : _repository = repository, + _errorService = errorService, + super(const ApprovedActivityInitial()) { + + on(_onLoadApprovedActivities); + on(_onSearchApprovedActivities); + on(_onFilterApprovedActivities); + on(_onRefreshApprovedActivities); + on(_onClearApprovedSearchResults); + } + + /// Charger les activités approuvées par tous les participants + Future _onLoadApprovedActivities( + LoadApprovedActivities event, + Emitter emit, + ) async { + try { + emit(const ApprovedActivityLoading()); + + final allActivities = await _repository.getActivitiesByTrip(event.tripId); + + // Filtrer les activités qui ont reçu des votes positifs de TOUS les participants + final approvedActivities = allActivities.where((activity) { + // Une activité est approuvée si tous les participants ont voté positivement + final positiveVoters = activity.votes.entries + .where((entry) => entry.value > 0) + .map((entry) => entry.key) + .toSet(); + + // Vérifier que tous les participants ont voté positivement + return event.tripParticipants.every((participant) => + positiveVoters.contains(participant)); + }).toList(); + + // Trier par nombre total de votes puis par rating + approvedActivities.sort((a, b) { + final aVotes = a.totalVotes; + final bVotes = b.totalVotes; + if (aVotes != bVotes) { + return bVotes.compareTo(aVotes); + } + return (b.rating ?? 0).compareTo(a.rating ?? 0); + }); + + emit(ApprovedActivityLoaded( + approvedActivities: approvedActivities, + tripParticipants: event.tripParticipants, + )); + } catch (e) { + _errorService.logError('approved_activity_bloc', 'Erreur chargement activités approuvées: $e'); + emit(const ApprovedActivityError('Impossible de charger les activités approuvées')); + } + } + + /// Rechercher dans les activités approuvées + Future _onSearchApprovedActivities( + SearchApprovedActivities event, + Emitter emit, + ) async { + try { + emit(const ApprovedActivitySearching()); + + // Charger d'abord toutes les activités approuvées + final allActivities = await _repository.getActivitiesByTrip(event.tripId); + + // Filtrer les approuvées puis rechercher + final approvedActivities = allActivities.where((activity) { + final positiveVoters = activity.votes.entries + .where((entry) => entry.value > 0) + .map((entry) => entry.key) + .toSet(); + + return event.tripParticipants.every((participant) => + positiveVoters.contains(participant)); + }).toList(); + + // Rechercher dans les activités approuvées + final results = approvedActivities + .where((activity) => + activity.name.toLowerCase().contains(event.query.toLowerCase()) || + activity.description.toLowerCase().contains(event.query.toLowerCase()) || + activity.category.toLowerCase().contains(event.query.toLowerCase())) + .toList(); + + emit(ApprovedActivitySearchResults( + results: results, + query: event.query, + tripParticipants: event.tripParticipants, + )); + } catch (e) { + _errorService.logError('approved_activity_bloc', 'Erreur recherche approuvées: $e'); + emit(const ApprovedActivityError('Erreur lors de la recherche')); + } + } + + /// Filtrer les activités approuvées + Future _onFilterApprovedActivities( + FilterApprovedActivities event, + Emitter emit, + ) async { + if (state is! ApprovedActivityLoaded) return; + + final currentState = state as ApprovedActivityLoaded; + List filteredActivities = List.from(currentState.approvedActivities); + + // Filtrer par catégorie + if (event.category != null && event.category!.isNotEmpty) { + filteredActivities = filteredActivities + .where((activity) => activity.category == event.category) + .toList(); + } + + // Filtrer par rating minimum + if (event.minRating != null) { + filteredActivities = filteredActivities + .where((activity) => (activity.rating ?? 0) >= event.minRating!) + .toList(); + } + + emit(currentState.copyWith(approvedActivities: filteredActivities)); + } + + /// Rafraîchir les activités approuvées + Future _onRefreshApprovedActivities( + RefreshApprovedActivities event, + Emitter emit, + ) async { + add(LoadApprovedActivities( + tripId: event.tripId, + tripParticipants: event.tripParticipants, + )); + } + + /// Effacer les résultats de recherche + Future _onClearApprovedSearchResults( + ClearApprovedSearchResults event, + Emitter emit, + ) async { + if (state is ApprovedActivitySearchResults) { + emit(const ApprovedActivityInitial()); + } + } +} \ No newline at end of file diff --git a/lib/blocs/activity/approved_activities/approved_activity_event.dart b/lib/blocs/activity/approved_activities/approved_activity_event.dart new file mode 100644 index 0000000..4526951 --- /dev/null +++ b/lib/blocs/activity/approved_activities/approved_activity_event.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; + +/// Events pour les activités approuvées par tous les participants +abstract class ApprovedActivityEvent extends Equatable { + const ApprovedActivityEvent(); + + @override + List get props => []; +} + +/// Charger les activités approuvées par tous +class LoadApprovedActivities extends ApprovedActivityEvent { + final String tripId; + final List tripParticipants; + + const LoadApprovedActivities({ + required this.tripId, + required this.tripParticipants, + }); + + @override + List get props => [tripId, tripParticipants]; +} + +/// Rechercher dans les activités approuvées +class SearchApprovedActivities extends ApprovedActivityEvent { + final String tripId; + final String query; + final List tripParticipants; + + const SearchApprovedActivities({ + required this.tripId, + required this.query, + required this.tripParticipants, + }); + + @override + List get props => [tripId, query, tripParticipants]; +} + +/// Filtrer les activités approuvées +class FilterApprovedActivities extends ApprovedActivityEvent { + final String? category; + final double? minRating; + + const FilterApprovedActivities({ + this.category, + this.minRating, + }); + + @override + List get props => [category, minRating]; +} + +/// Rafraîchir les activités approuvées +class RefreshApprovedActivities extends ApprovedActivityEvent { + final String tripId; + final List tripParticipants; + + const RefreshApprovedActivities({ + required this.tripId, + required this.tripParticipants, + }); + + @override + List get props => [tripId, tripParticipants]; +} + +/// Effacer les résultats de recherche approuvées +class ClearApprovedSearchResults extends ApprovedActivityEvent { + const ClearApprovedSearchResults(); +} \ No newline at end of file diff --git a/lib/blocs/activity/approved_activities/approved_activity_state.dart b/lib/blocs/activity/approved_activities/approved_activity_state.dart new file mode 100644 index 0000000..e8a5912 --- /dev/null +++ b/lib/blocs/activity/approved_activities/approved_activity_state.dart @@ -0,0 +1,76 @@ +import 'package:equatable/equatable.dart'; +import '../../../models/activity.dart'; + +/// States pour les activités approuvées par tous les participants +abstract class ApprovedActivityState extends Equatable { + const ApprovedActivityState(); + + @override + List get props => []; +} + +/// État initial +class ApprovedActivityInitial extends ApprovedActivityState { + const ApprovedActivityInitial(); +} + +/// État de chargement +class ApprovedActivityLoading extends ApprovedActivityState { + const ApprovedActivityLoading(); +} + +/// État de recherche +class ApprovedActivitySearching extends ApprovedActivityState { + const ApprovedActivitySearching(); +} + +/// État avec les activités approuvées chargées +class ApprovedActivityLoaded extends ApprovedActivityState { + final List approvedActivities; + final List tripParticipants; + + const ApprovedActivityLoaded({ + required this.approvedActivities, + required this.tripParticipants, + }); + + @override + List get props => [approvedActivities, tripParticipants]; + + /// Créer une copie avec des modifications + ApprovedActivityLoaded copyWith({ + List? approvedActivities, + List? tripParticipants, + }) { + return ApprovedActivityLoaded( + approvedActivities: approvedActivities ?? this.approvedActivities, + tripParticipants: tripParticipants ?? this.tripParticipants, + ); + } +} + +/// État avec résultats de recherche approuvées +class ApprovedActivitySearchResults extends ApprovedActivityState { + final List results; + final String query; + final List tripParticipants; + + const ApprovedActivitySearchResults({ + required this.results, + required this.query, + required this.tripParticipants, + }); + + @override + List get props => [results, query, tripParticipants]; +} + +/// État d'erreur +class ApprovedActivityError extends ApprovedActivityState { + final String message; + + const ApprovedActivityError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/blocs/activity/db_activities/db_activity_bloc.dart b/lib/blocs/activity/db_activities/db_activity_bloc.dart new file mode 100644 index 0000000..390b9c6 --- /dev/null +++ b/lib/blocs/activity/db_activities/db_activity_bloc.dart @@ -0,0 +1,267 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../models/activity.dart'; +import '../../../repositories/activity_repository.dart'; +import '../../../services/error_service.dart'; +import 'db_activity_event.dart'; +import 'db_activity_state.dart'; + +/// BLoC pour gérer les activités de la base de données +class DbActivityBloc extends Bloc { + final ActivityRepository _repository; + final ErrorService _errorService; + + DbActivityBloc({ + required ActivityRepository repository, + required ErrorService errorService, + }) : _repository = repository, + _errorService = errorService, + super(const DbActivityInitial()) { + + on(_onLoadDbActivities); + on(_onSearchDbActivities); + on(_onAddDbActivity); + on(_onAddDbActivitiesBatch); + on(_onVoteForDbActivity); + on(_onDeleteDbActivity); + on(_onFilterDbActivities); + on(_onRefreshDbActivities); + on(_onUpdateDbActivity); + on(_onToggleDbActivityFavorite); + on(_onClearDbSearchResults); + } + + /// Charger les activités d'un voyage + Future _onLoadDbActivities( + LoadDbActivities event, + Emitter emit, + ) async { + try { + emit(const DbActivityLoading()); + + final activities = await _repository.getActivitiesByTrip(event.tripId); + + emit(DbActivityLoaded( + activities: activities, + tripParticipants: event.tripParticipants, + )); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur chargement activités DB: $e'); + emit(const DbActivityError('Impossible de charger les activités du voyage')); + } + } + + /// Rechercher des activités + Future _onSearchDbActivities( + SearchDbActivities event, + Emitter emit, + ) async { + try { + emit(const DbActivitySearching()); + + // Pour l'instant, on fait une recherche simple en filtrant les activités existantes + final allActivities = await _repository.getActivitiesByTrip(event.tripId); + final results = allActivities + .where((activity) => + activity.name.toLowerCase().contains(event.query.toLowerCase()) || + activity.description.toLowerCase().contains(event.query.toLowerCase())) + .toList(); + + emit(DbActivitySearchResults( + results: results, + query: event.query, + tripParticipants: event.tripParticipants, + )); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur recherche activités: $e'); + emit(const DbActivityError('Erreur lors de la recherche')); + } + } + + /// Ajouter une activité + Future _onAddDbActivity( + AddDbActivity event, + Emitter emit, + ) async { + try { + await _repository.addActivity(event.activity); + + // Recharger les activités si on est dans l'état chargé + if (state is DbActivityLoaded) { + final currentState = state as DbActivityLoaded; + add(LoadDbActivities( + tripId: event.activity.tripId, + tripParticipants: currentState.tripParticipants, + )); + } + + emit(const DbActivityOperationSuccess('Activité ajoutée avec succès')); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur ajout activité: $e'); + emit(const DbActivityError('Impossible d\'ajouter l\'activité')); + } + } + + /// Ajouter plusieurs activités + Future _onAddDbActivitiesBatch( + AddDbActivitiesBatch event, + Emitter emit, + ) async { + try { + await _repository.addActivitiesBatch(event.activities); + + // Recharger les activités si on est dans l'état chargé + if (state is DbActivityLoaded && event.activities.isNotEmpty) { + final currentState = state as DbActivityLoaded; + add(LoadDbActivities( + tripId: event.activities.first.tripId, + tripParticipants: currentState.tripParticipants, + )); + } + + emit(DbActivityOperationSuccess('${event.activities.length} activités ajoutées')); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur ajout batch: $e'); + emit(const DbActivityError('Impossible d\'ajouter les activités')); + } + } + + /// Voter pour une activité + Future _onVoteForDbActivity( + VoteForDbActivity event, + Emitter emit, + ) async { + try { + await _repository.voteForActivity( + event.activityId, + event.userId, + event.isUpvote ? 1 : -1, + ); + + // Recharger les activités pour refléter le nouveau vote + if (state is DbActivityLoaded) { + final currentState = state as DbActivityLoaded; + final activity = currentState.activities.firstWhere( + (a) => a.id == event.activityId, + ); + add(LoadDbActivities( + tripId: activity.tripId, + tripParticipants: currentState.tripParticipants, + )); + } + + emit(const DbActivityOperationSuccess('Vote enregistré')); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur vote: $e'); + emit(const DbActivityError('Impossible d\'enregistrer le vote')); + } + } + + /// Supprimer une activité + Future _onDeleteDbActivity( + DeleteDbActivity event, + Emitter emit, + ) async { + try { + await _repository.deleteActivity(event.activityId); + + // Mettre à jour la liste locale si on est dans l'état chargé + if (state is DbActivityLoaded) { + final currentState = state as DbActivityLoaded; + final updatedActivities = currentState.activities + .where((activity) => activity.id != event.activityId) + .toList(); + + emit(currentState.copyWith(activities: updatedActivities)); + } + + emit(const DbActivityOperationSuccess('Activité supprimée')); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur suppression: $e'); + emit(const DbActivityError('Impossible de supprimer l\'activité')); + } + } + + /// Filtrer les activités + Future _onFilterDbActivities( + FilterDbActivities event, + Emitter emit, + ) async { + if (state is! DbActivityLoaded) return; + + final currentState = state as DbActivityLoaded; + List filteredActivities = List.from(currentState.activities); + + // Filtrer par catégorie + if (event.category != null && event.category!.isNotEmpty) { + filteredActivities = filteredActivities + .where((activity) => activity.category == event.category) + .toList(); + } + + // Note: isFavorite n'existe pas dans le modèle Activity actuel + // if (event.showFavorites == true) { + // filteredActivities = filteredActivities + // .where((activity) => activity.isFavorite == true) + // .toList(); + // } + + emit(currentState.copyWith(activities: filteredActivities)); + } + + /// Rafraîchir les activités + Future _onRefreshDbActivities( + RefreshDbActivities event, + Emitter emit, + ) async { + add(LoadDbActivities( + tripId: event.tripId, + tripParticipants: event.tripParticipants, + )); + } + + /// Mettre à jour une activité + Future _onUpdateDbActivity( + UpdateDbActivity event, + Emitter emit, + ) async { + try { + await _repository.updateActivity(event.activity); + + // Mettre à jour la liste locale + if (state is DbActivityLoaded) { + final currentState = state as DbActivityLoaded; + final updatedActivities = currentState.activities.map((activity) { + return activity.id == event.activity.id ? event.activity : activity; + }).toList(); + + emit(currentState.copyWith(activities: updatedActivities)); + } + + emit(const DbActivityOperationSuccess('Activité mise à jour')); + } catch (e) { + _errorService.logError('db_activity_bloc', 'Erreur mise à jour: $e'); + emit(const DbActivityError('Impossible de mettre à jour l\'activité')); + } + } + + /// Basculer le favori d'une activité + Future _onToggleDbActivityFavorite( + ToggleDbActivityFavorite event, + Emitter emit, + ) async { + // Note: La fonctionnalité favori n'existe pas encore dans le repository + // Cette méthode peut être implémentée plus tard + emit(const DbActivityError('Fonctionnalité favori non disponible')); + } + + /// Effacer les résultats de recherche + Future _onClearDbSearchResults( + ClearDbSearchResults event, + Emitter emit, + ) async { + if (state is DbActivitySearchResults) { + // Retourner à l'état précédent ou initial + emit(const DbActivityInitial()); + } + } +} \ No newline at end of file diff --git a/lib/blocs/activity/db_activities/db_activity_event.dart b/lib/blocs/activity/db_activities/db_activity_event.dart new file mode 100644 index 0000000..c753052 --- /dev/null +++ b/lib/blocs/activity/db_activities/db_activity_event.dart @@ -0,0 +1,143 @@ +import 'package:equatable/equatable.dart'; +import '../../../models/activity.dart'; + +/// Events pour les activités de la base de données +abstract class DbActivityEvent extends Equatable { + const DbActivityEvent(); + + @override + List get props => []; +} + +/// Charger les activités d'un voyage depuis la DB +class LoadDbActivities extends DbActivityEvent { + final String tripId; + final List tripParticipants; + + const LoadDbActivities({ + required this.tripId, + required this.tripParticipants, + }); + + @override + List get props => [tripId, tripParticipants]; +} + +/// Rechercher des activités dans la DB +class SearchDbActivities extends DbActivityEvent { + final String tripId; + final String query; + final List tripParticipants; + + const SearchDbActivities({ + required this.tripId, + required this.query, + required this.tripParticipants, + }); + + @override + List get props => [tripId, query, tripParticipants]; +} + +/// Ajouter une activité à la DB +class AddDbActivity extends DbActivityEvent { + final Activity activity; + + const AddDbActivity({required this.activity}); + + @override + List get props => [activity]; +} + +/// Ajouter plusieurs activités à la DB +class AddDbActivitiesBatch extends DbActivityEvent { + final List activities; + + const AddDbActivitiesBatch({required this.activities}); + + @override + List get props => [activities]; +} + +/// Voter pour une activité +class VoteForDbActivity extends DbActivityEvent { + final String activityId; + final String userId; + final bool isUpvote; + + const VoteForDbActivity({ + required this.activityId, + required this.userId, + required this.isUpvote, + }); + + @override + List get props => [activityId, userId, isUpvote]; +} + +/// Supprimer une activité +class DeleteDbActivity extends DbActivityEvent { + final String activityId; + + const DeleteDbActivity({required this.activityId}); + + @override + List get props => [activityId]; +} + +/// Filtrer les activités +class FilterDbActivities extends DbActivityEvent { + final String? category; + final bool? showFavorites; + + const FilterDbActivities({ + this.category, + this.showFavorites, + }); + + @override + List get props => [category, showFavorites]; +} + +/// Rafraîchir les activités +class RefreshDbActivities extends DbActivityEvent { + final String tripId; + final List tripParticipants; + + const RefreshDbActivities({ + required this.tripId, + required this.tripParticipants, + }); + + @override + List get props => [tripId, tripParticipants]; +} + +/// Mettre à jour une activité +class UpdateDbActivity extends DbActivityEvent { + final Activity activity; + + const UpdateDbActivity({required this.activity}); + + @override + List get props => [activity]; +} + +/// Basculer le favori d'une activité +class ToggleDbActivityFavorite extends DbActivityEvent { + final String activityId; + final String userId; + + const ToggleDbActivityFavorite({ + required this.activityId, + required this.userId, + }); + + @override + List get props => [activityId, userId]; +} + +/// Effacer les résultats de recherche +class ClearDbSearchResults extends DbActivityEvent { + const ClearDbSearchResults(); +} \ No newline at end of file diff --git a/lib/blocs/activity/db_activities/db_activity_state.dart b/lib/blocs/activity/db_activities/db_activity_state.dart new file mode 100644 index 0000000..05e447e --- /dev/null +++ b/lib/blocs/activity/db_activities/db_activity_state.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import '../../../models/activity.dart'; + +/// States pour les activités de la base de données +abstract class DbActivityState extends Equatable { + const DbActivityState(); + + @override + List get props => []; +} + +/// État initial +class DbActivityInitial extends DbActivityState { + const DbActivityInitial(); +} + +/// État de chargement +class DbActivityLoading extends DbActivityState { + const DbActivityLoading(); +} + +/// État de recherche +class DbActivitySearching extends DbActivityState { + const DbActivitySearching(); +} + +/// État avec les activités chargées +class DbActivityLoaded extends DbActivityState { + final List activities; + final List tripParticipants; + + const DbActivityLoaded({ + required this.activities, + required this.tripParticipants, + }); + + @override + List get props => [activities, tripParticipants]; + + /// Créer une copie avec des modifications + DbActivityLoaded copyWith({ + List? activities, + List? tripParticipants, + }) { + return DbActivityLoaded( + activities: activities ?? this.activities, + tripParticipants: tripParticipants ?? this.tripParticipants, + ); + } +} + +/// État avec résultats de recherche +class DbActivitySearchResults extends DbActivityState { + final List results; + final String query; + final List tripParticipants; + + const DbActivitySearchResults({ + required this.results, + required this.query, + required this.tripParticipants, + }); + + @override + List get props => [results, query, tripParticipants]; +} + +/// État de succès d'opération +class DbActivityOperationSuccess extends DbActivityState { + final String message; + + const DbActivityOperationSuccess(this.message); + + @override + List get props => [message]; +} + +/// État d'erreur +class DbActivityError extends DbActivityState { + final String message; + + const DbActivityError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/blocs/activity/google_activities/google_activity_bloc.dart b/lib/blocs/activity/google_activities/google_activity_bloc.dart new file mode 100644 index 0000000..249817a --- /dev/null +++ b/lib/blocs/activity/google_activities/google_activity_bloc.dart @@ -0,0 +1,157 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../models/activity.dart'; +import '../../../repositories/activity_repository.dart'; +import '../../../services/activity_places_service.dart'; +import '../../../services/error_service.dart'; +import 'google_activity_event.dart'; +import 'google_activity_state.dart'; + +/// BLoC pour gérer les activités Google Places +class GoogleActivityBloc extends Bloc { + final ActivityPlacesService _placesService; + final ActivityRepository _repository; + final ErrorService _errorService; + + GoogleActivityBloc({ + required ActivityPlacesService placesService, + required ActivityRepository repository, + required ErrorService errorService, + }) : _placesService = placesService, + _repository = repository, + _errorService = errorService, + super(const GoogleActivityInitial()) { + + on(_onLoadGoogleActivities); + on(_onLoadMoreGoogleActivities); + on(_onUpdateGoogleActivities); + on(_onAddGoogleActivityToDb); + on(_onSearchGoogleActivitiesByCategory); + on(_onClearGoogleActivities); + } + + /// Charger les activités Google Places avec pagination (6 par page) + Future _onLoadGoogleActivities( + LoadGoogleActivities event, + Emitter emit, + ) async { + try { + emit(const GoogleActivitySearching()); + + final result = await _placesService.searchActivitiesPaginated( + destination: event.destination, + tripId: event.tripId, + category: event.category, + pageSize: 6, + ); + + emit(GoogleActivityLoaded( + googleActivities: result['activities'] as List, + nextPageToken: result['nextPageToken'] as String?, + hasMoreData: result['hasMoreData'] as bool? ?? false, + query: event.category?.displayName ?? 'Toutes les activités', + )); + } catch (e) { + _errorService.logError('google_activity_bloc', 'Erreur chargement activités Google: $e'); + emit(const GoogleActivityError('Impossible de charger les activités Google')); + } + } + + /// Charger plus d'activités (pagination) + Future _onLoadMoreGoogleActivities( + LoadMoreGoogleActivities event, + Emitter emit, + ) async { + if (state is! GoogleActivityLoaded) return; + + final currentState = state as GoogleActivityLoaded; + if (!currentState.hasMoreData || currentState.nextPageToken == null) return; + + try { + emit(GoogleActivityLoadingMore( + currentActivities: currentState.googleActivities, + query: currentState.query, + )); + + final result = await _placesService.searchActivitiesPaginated( + destination: event.destination, + tripId: event.tripId, + category: event.category, + pageSize: 6, + nextPageToken: currentState.nextPageToken, + ); + + final newActivities = result['activities'] as List; + final allActivities = [...currentState.googleActivities, ...newActivities]; + + emit(GoogleActivityLoaded( + googleActivities: allActivities, + nextPageToken: result['nextPageToken'] as String?, + hasMoreData: result['hasMoreData'] as bool? ?? false, + query: currentState.query, + )); + } catch (e) { + _errorService.logError('google_activity_bloc', 'Erreur chargement plus activités: $e'); + emit(const GoogleActivityError('Impossible de charger plus d\'activités')); + } + } + + /// Mettre à jour les activités Google + Future _onUpdateGoogleActivities( + UpdateGoogleActivities event, + Emitter emit, + ) async { + emit(GoogleActivityLoaded( + googleActivities: event.activities, + nextPageToken: event.nextPageToken, + hasMoreData: event.hasMoreData, + query: event.query, + )); + } + + /// Ajouter une activité Google à la base de données + Future _onAddGoogleActivityToDb( + AddGoogleActivityToDb event, + Emitter emit, + ) async { + try { + // Vérifier si l'activité existe déjà + if (event.activity.placeId != null) { + final existingActivity = await _repository.findExistingActivity( + event.activity.tripId, + event.activity.placeId!, + ); + + if (existingActivity != null) { + emit(const GoogleActivityOperationSuccess('Cette activité est déjà dans votre voyage')); + return; + } + } + + await _repository.addActivity(event.activity); + emit(const GoogleActivityOperationSuccess('Activité ajoutée au voyage')); + } catch (e) { + _errorService.logError('google_activity_bloc', 'Erreur ajout activité: $e'); + emit(const GoogleActivityError('Impossible d\'ajouter l\'activité')); + } + } + + /// Rechercher par catégorie + Future _onSearchGoogleActivitiesByCategory( + SearchGoogleActivitiesByCategory event, + Emitter emit, + ) async { + add(LoadGoogleActivities( + tripId: event.tripId, + destination: event.destination, + category: event.category, + )); + } + + /// Effacer les activités Google + Future _onClearGoogleActivities( + ClearGoogleActivities event, + Emitter emit, + ) async { + emit(const GoogleActivityInitial()); + } +} \ No newline at end of file diff --git a/lib/blocs/activity/google_activities/google_activity_event.dart b/lib/blocs/activity/google_activities/google_activity_event.dart new file mode 100644 index 0000000..f1bc44f --- /dev/null +++ b/lib/blocs/activity/google_activities/google_activity_event.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import '../../../models/activity.dart'; + +/// Events pour les activités Google Places +abstract class GoogleActivityEvent extends Equatable { + const GoogleActivityEvent(); + + @override + List get props => []; +} + +/// Charger les activités Google Places +class LoadGoogleActivities extends GoogleActivityEvent { + final String tripId; + final String destination; + final ActivityCategory? category; + + const LoadGoogleActivities({ + required this.tripId, + required this.destination, + this.category, + }); + + @override + List get props => [tripId, destination, category]; +} + +/// Charger plus d'activités Google (pagination) +class LoadMoreGoogleActivities extends GoogleActivityEvent { + final String tripId; + final String destination; + final ActivityCategory? category; + final String? nextPageToken; + + const LoadMoreGoogleActivities({ + required this.tripId, + required this.destination, + this.category, + this.nextPageToken, + }); + + @override + List get props => [tripId, destination, category, nextPageToken]; +} + +/// Mettre à jour les activités Google +class UpdateGoogleActivities extends GoogleActivityEvent { + final List activities; + final String? nextPageToken; + final bool hasMoreData; + final String query; + + const UpdateGoogleActivities({ + required this.activities, + this.nextPageToken, + required this.hasMoreData, + required this.query, + }); + + @override + List get props => [activities, nextPageToken, hasMoreData, query]; +} + +/// Ajouter une activité Google à la DB +class AddGoogleActivityToDb extends GoogleActivityEvent { + final Activity activity; + + const AddGoogleActivityToDb({required this.activity}); + + @override + List get props => [activity]; +} + +/// Rechercher des activités Google par catégorie +class SearchGoogleActivitiesByCategory extends GoogleActivityEvent { + final String tripId; + final String destination; + final ActivityCategory category; + + const SearchGoogleActivitiesByCategory({ + required this.tripId, + required this.destination, + required this.category, + }); + + @override + List get props => [tripId, destination, category]; +} + +/// Effacer les résultats Google +class ClearGoogleActivities extends GoogleActivityEvent { + const ClearGoogleActivities(); +} \ No newline at end of file diff --git a/lib/blocs/activity/google_activities/google_activity_state.dart b/lib/blocs/activity/google_activities/google_activity_state.dart new file mode 100644 index 0000000..80cac66 --- /dev/null +++ b/lib/blocs/activity/google_activities/google_activity_state.dart @@ -0,0 +1,87 @@ +import 'package:equatable/equatable.dart'; +import '../../../models/activity.dart'; + +/// States pour les activités Google Places +abstract class GoogleActivityState extends Equatable { + const GoogleActivityState(); + + @override + List get props => []; +} + +/// État initial +class GoogleActivityInitial extends GoogleActivityState { + const GoogleActivityInitial(); +} + +/// État de recherche +class GoogleActivitySearching extends GoogleActivityState { + const GoogleActivitySearching(); +} + +/// État avec les activités Google chargées +class GoogleActivityLoaded extends GoogleActivityState { + final List googleActivities; + final String? nextPageToken; + final bool hasMoreData; + final String query; + + const GoogleActivityLoaded({ + required this.googleActivities, + this.nextPageToken, + required this.hasMoreData, + required this.query, + }); + + @override + List get props => [googleActivities, nextPageToken, hasMoreData, query]; + + /// Créer une copie avec des modifications + GoogleActivityLoaded copyWith({ + List? googleActivities, + String? nextPageToken, + bool? hasMoreData, + String? query, + }) { + return GoogleActivityLoaded( + googleActivities: googleActivities ?? this.googleActivities, + nextPageToken: nextPageToken ?? this.nextPageToken, + hasMoreData: hasMoreData ?? this.hasMoreData, + query: query ?? this.query, + ); + } +} + +/// État de chargement de plus d'activités (pagination) +class GoogleActivityLoadingMore extends GoogleActivityState { + final List currentActivities; + final String query; + + const GoogleActivityLoadingMore({ + required this.currentActivities, + required this.query, + }); + + @override + List get props => [currentActivities, query]; +} + +/// État de succès d'opération +class GoogleActivityOperationSuccess extends GoogleActivityState { + final String message; + + const GoogleActivityOperationSuccess(this.message); + + @override + List get props => [message]; +} + +/// État d'erreur +class GoogleActivityError extends GoogleActivityState { + final String message; + + const GoogleActivityError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/components/activities/activities_page.dart b/lib/components/activities/activities_page.dart index 8fcc760..9edab65 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -3,42 +3,83 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../blocs/activity/activity_bloc.dart'; import '../../blocs/activity/activity_event.dart'; import '../../blocs/activity/activity_state.dart'; -import '../../blocs/user/user_bloc.dart'; -import '../../blocs/user/user_state.dart' as user_state; -import '../../models/activity.dart'; import '../../models/trip.dart'; -import '../../services/error_service.dart'; -import 'activity_card.dart'; -import 'add_activity_bottom_sheet.dart'; +import '../../models/activity.dart'; +import '../activities/add_activity_bottom_sheet.dart'; -/// Page principale des activités pour un voyage class ActivitiesPage extends StatefulWidget { final Trip trip; const ActivitiesPage({ - Key? key, + super.key, required this.trip, - }) : super(key: key); + }); @override State createState() => _ActivitiesPageState(); } class _ActivitiesPageState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { late TabController _tabController; - final ErrorService _errorService = ErrorService(); - String? _selectedCategory; final TextEditingController _searchController = TextEditingController(); + String _selectedCategory = 'Toutes les catégories'; + String _selectedPrice = 'Prix'; + String _selectedRating = 'Note'; + + // Cache pour éviter de recharger les données + bool _activitiesLoaded = false; + bool _googleSearchPerformed = false; + + // Variables pour stocker les activités localement + List _tripActivities = []; + List _approvedActivities = []; + bool _isLoadingTripActivities = false; + int _totalGoogleActivitiesRequested = 0; // Compteur pour les recherches progressives + bool _autoReloadInProgress = false; // Protection contre les rechargements en boucle + int _lastAutoReloadTriggerCount = 0; // Éviter de redéclencher pour le même nombre + + @override + bool get wantKeepAlive => true; // Maintient l'état de la page @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); - // Charger les activités - if (widget.trip.id != null) { - context.read().add(LoadActivities(widget.trip.id!)); + // Charger les activités au démarrage + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadActivitiesIfNeeded(); + }); + + // Écouter les changements d'onglets + _tabController.addListener(() { + if (_tabController.indexIsChanging) { + _handleTabChange(); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Recharger si nécessaire quand on revient sur la page + if (!_activitiesLoaded) { + _loadActivitiesIfNeeded(); + } + } + + void _loadActivitiesIfNeeded() { + final state = context.read().state; + if (state is! ActivityLoaded || !_activitiesLoaded) { + _loadActivities(); + } + } + + void _handleTabChange() { + // Si on va sur l'onglet suggestions Google et qu'aucune recherche n'a été faite + if (_tabController.index == 2 && !_googleSearchPerformed) { + _searchGoogleActivities(); } } @@ -49,257 +90,217 @@ class _ActivitiesPageState extends State super.dispose(); } + void _loadActivities() { + setState(() { + _isLoadingTripActivities = true; + }); + context.read().add(LoadActivities(widget.trip.id!)); + _activitiesLoaded = true; + } + @override Widget build(BuildContext context) { + super.build(context); // Nécessaire pour AutomaticKeepAliveClientMixin final theme = Theme.of(context); - final isDarkMode = theme.brightness == Brightness.dark; - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - body: BlocListener( - listener: (context, state) { - if (state is ActivityOperationSuccess) { - _errorService.showSnackbar(message: state.message, isError: false); - } else if (state is ActivityError) { - _errorService.showSnackbar(message: state.message, isError: true); + return BlocListener( + listener: (context, state) { + if (state is ActivityError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + action: SnackBarAction( + label: 'Réessayer', + textColor: Colors.white, + onPressed: () { + if (_tabController.index == 2) { + _searchGoogleActivities(); + } else { + _loadActivities(); + } + }, + ), + ), + ); + } + + if (state is ActivityLoaded) { + print('✅ Activités chargées: ${state.activities.length}'); + // Stocker les activités localement + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _tripActivities = state.activities; + _approvedActivities = state.activities.where((a) => a.totalVotes > 0).toList(); + _isLoadingTripActivities = false; + }); + + print('🔄 [ActivityLoaded] Activités du voyage mises à jour: ${_tripActivities.length}'); + // Vérifier si on a besoin de charger plus d'activités dans les suggestions + Future.delayed(const Duration(milliseconds: 500), () { + print('🚀 [ActivityLoaded] Déclenchement de la vérification auto-reload'); + _checkAndLoadMoreActivitiesIfNeeded(); + }); + }); + } + + if (state is ActivitySearchResults) { + print('🔍 Résultats Google: ${state.searchResults.length}'); + // Déclencher l'auto-reload uniquement pour la recherche initiale (6 résultats) + // et pas pour les rechargements automatiques + if (state.searchResults.length <= 6 && !_autoReloadInProgress) { + WidgetsBinding.instance.addPostFrameCallback((_) { + print('🎯 [ActivitySearchResults] Première recherche avec peu de résultats, vérification auto-reload'); + Future.delayed(const Duration(milliseconds: 500), () { + _checkAndLoadMoreActivitiesIfNeeded(); + }); + }); } - }, - child: CustomScrollView( - slivers: [ - // AppBar personnalisée - SliverAppBar( - expandedHeight: 120, - floating: false, - pinned: true, - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: Icon(Icons.person_add, color: theme.colorScheme.onSurface), - onPressed: () { - // TODO: Ajouter participants - }, - ), - ], - flexibleSpace: FlexibleSpaceBar( - titlePadding: const EdgeInsets.only(left: 16, bottom: 16), - title: Text( - 'Voyage à ${widget.trip.location}', - style: theme.textTheme.titleLarge?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), + } + + if (state is ActivityVoteRecorded) { + print('�️ Vote enregistré pour activité: ${state.activityId}'); + // Recharger les activités du voyage pour mettre à jour les votes + _loadActivities(); + } + + if (state is ActivityAdded) { + print('✅ Activité ajoutée avec succès: ${state.activity.name}'); + // Recharger automatiquement les activités du voyage + _loadActivities(); + } + }, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: Text( + 'Voyage à ${widget.trip.location}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, ), - + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + backgroundColor: theme.colorScheme.surface, + elevation: 0, + foregroundColor: theme.colorScheme.onSurface, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: _showAddActivityBottomSheet, + ), + ], + ), + body: Column( + children: [ // Barre de recherche - SliverToBoxAdapter( - child: Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.black.withOpacity(0.1), - ), - ), - child: Row( - children: [ - Icon( - Icons.search, - color: theme.colorScheme.onSurface.withOpacity(0.5), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher restaurants, musées...', - border: InputBorder.none, - hintStyle: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.5), - ), - ), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, - ), - onSubmitted: (query) { - if (query.isNotEmpty) { - context.read().add( - SearchActivitiesByText( - tripId: widget.trip.id!, - destination: widget.trip.location, - query: query, - ), - ); - } - }, - ), - ), - ], - ), - ), - ), - - // Filtres par catégorie - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Bouton suggestions Google - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _searchGoogleActivities, - icon: const Icon(Icons.place), - label: const Text('Découvrir des activités avec Google'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(height: 16), - - // Catégories populaires - Text( - 'Catégories populaires', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildCategoryButton(ActivityCategory.attraction, 'Attractions'), - const SizedBox(width: 8), - _buildCategoryButton(ActivityCategory.restaurant, 'Restaurants'), - const SizedBox(width: 8), - _buildCategoryButton(ActivityCategory.museum, 'Musées'), - const SizedBox(width: 8), - _buildCategoryButton(ActivityCategory.nature, 'Nature'), - const SizedBox(width: 8), - _buildCategoryButton(ActivityCategory.culture, 'Culture'), - ], - ), - ), - const SizedBox(height: 16), - - // Filtres - Row( - children: [ - _buildFilterChip( - label: 'Catégorie', - icon: Icons.filter_list, - isActive: _selectedCategory != null, - onTap: _showCategoryFilter, - ), - const SizedBox(width: 8), - _buildFilterChip( - label: 'Prix', - icon: Icons.euro, - isActive: false, - onTap: _showPriceFilter, - ), - const SizedBox(width: 8), - _buildFilterChip( - label: 'Heure', - icon: Icons.access_time, - isActive: false, - onTap: () { - // TODO: Filtre par heure - }, - ), - ], - ), - ], - ), - ), - ), - - // Onglets - SliverToBoxAdapter( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(8), - ), - child: TabBar( - controller: _tabController, - indicator: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(8), - ), - labelColor: Colors.white, - unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.7), - dividerColor: Colors.transparent, - tabs: const [ - Tab(text: 'Suggestions'), - Tab(text: 'Activités votées'), - ], - ), - ), - ), - + _buildSearchBar(theme), + + // Filtres + _buildFilters(theme), + + // Onglets de catégories + _buildCategoryTabs(theme), + // Contenu des onglets - SliverFillRemaining( + Expanded( child: TabBarView( controller: _tabController, children: [ - _buildSuggestionsTab(), - _buildVotedActivitiesTab(), + _buildTripActivitiesTab(), + _buildApprovedActivitiesTab(), + _buildGoogleSuggestionsTab(), ], ), ), ], ), ), - - // Bouton flottant pour ajouter une activité - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddActivitySheet(), - backgroundColor: Colors.blue, - child: const Icon(Icons.add, color: Colors.white), + ); + } + + Widget _buildSearchBar(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(16), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher restaurants, musées...', + hintStyle: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + prefixIcon: Icon( + Icons.search, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + // TODO: Implémenter la recherche + }, + ), ), ); } - Widget _buildFilterChip({ - required String label, - required IconData icon, - required bool isActive, - required VoidCallback onTap, - }) { - final theme = Theme.of(context); - - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isActive ? Colors.blue : theme.cardColor, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isActive - ? Colors.blue - : theme.colorScheme.onSurface.withOpacity(0.2), + Widget _buildFilters(ThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: _buildFilterButton( + theme, + _selectedCategory, + Icons.category, + () => _showCategoryFilter(), + ), ), + const SizedBox(width: 12), + _buildFilterButton( + theme, + _selectedPrice, + Icons.euro, + () => _showPriceFilter(), + ), + const SizedBox(width: 12), + _buildFilterButton( + theme, + _selectedRating, + Icons.star, + () => _showRatingFilter(), + ), + ], + ), + ); + } + + Widget _buildFilterButton( + ThemeData theme, + String text, + IconData icon, + VoidCallback onPressed, + ) { + return GestureDetector( + onTap: onPressed, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.5), + ), + borderRadius: BorderRadius.circular(20), + color: theme.colorScheme.surface, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -307,17 +308,13 @@ class _ActivitiesPageState extends State Icon( icon, size: 16, - color: isActive - ? Colors.white - : theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withOpacity(0.7), ), - const SizedBox(width: 4), + const SizedBox(width: 6), Text( - label, + text, style: theme.textTheme.bodySmall?.copyWith( - color: isActive - ? Colors.white - : theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withOpacity(0.7), fontWeight: FontWeight.w500, ), ), @@ -327,268 +324,30 @@ class _ActivitiesPageState extends State ); } - Widget _buildSuggestionsTab() { - return BlocBuilder( - builder: (context, state) { - if (state is ActivityLoading || state is ActivitySearching) { - return const Center(child: CircularProgressIndicator()); - } - - if (state is ActivitySearchResults) { - return _buildActivityList(state.searchResults, isSearchResults: true); - } - - if (state is ActivityLoaded) { - return _buildActivityList(state.filteredActivities); - } - - // État initial - montrer les suggestions par défaut - return _buildInitialSuggestions(); - }, - ); - } - - Widget _buildVotedActivitiesTab() { - return BlocBuilder( - builder: (context, state) { - if (state is ActivityLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (state is ActivityLoaded) { - // Filtrer les activités avec des votes - final votedActivities = state.activities - .where((activity) => activity.votes.isNotEmpty) - .toList(); - - // Trier par score de vote - votedActivities.sort((a, b) => b.totalVotes.compareTo(a.totalVotes)); - - return _buildActivityList(votedActivities); - } - - return const Center( - child: Text('Aucune activité votée pour le moment'), - ); - }, - ); - } - - Widget _buildActivityList(List activities, {bool isSearchResults = false}) { - if (activities.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.explore_off, - size: 64, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3), - ), - const SizedBox(height: 16), - Text( - isSearchResults - ? 'Aucun résultat trouvé' - : 'Aucune activité pour le moment', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - ), - ), - const SizedBox(height: 8), - Text( - isSearchResults - ? 'Essayez une autre recherche ou explorez les catégories' - : 'Utilisez le bouton "Découvrir des activités avec Google" pour commencer', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - ), - textAlign: TextAlign.center, - ), - if (isSearchResults) ...[ - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () { - context.read().add(LoadActivities(widget.trip.id!)); - }, - icon: const Icon(Icons.arrow_back), - label: const Text('Retour aux suggestions'), - ), - ] else ...[ - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _searchGoogleActivities, - icon: const Icon(Icons.place), - label: const Text('Découvrir des activités'), - ), - ], + Widget _buildCategoryTabs(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(16), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: TabBar( + controller: _tabController, + labelColor: Colors.white, + unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.7), + indicator: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + tabs: const [ + Tab(text: 'Activités du voyage'), + Tab(text: 'Activités approuvées'), + Tab(text: 'Suggestions Google'), ], ), - ); - } - - return BlocBuilder( - builder: (context, userState) { - final currentUserId = userState is user_state.UserLoaded - ? userState.user.id - : ''; - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: activities.length, - itemBuilder: (context, index) { - final activity = activities[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ActivityCard( - activity: activity, - currentUserId: currentUserId, - onVote: (vote) => _handleVote(activity.id, currentUserId, vote), - onAddToTrip: isSearchResults - ? () => _addActivityToTrip(activity) - : null, - ), - ); - }, - ); - }, - ); - } - - Widget _buildInitialSuggestions() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.explore, - size: 64, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - 'Découvrez ${widget.trip.location}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Utilisez le bouton "Découvrir des activités avec Google" ci-dessus\npour explorer les meilleures attractions de la région', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lightbulb_outline, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'Conseil : Explorez par catégorie ci-dessous', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: ActivityCategory.values.take(6).map((category) { - return ElevatedButton.icon( - onPressed: () => _searchByCategory(category), - icon: Icon(_getCategoryIcon(category), size: 18), - label: Text(category.displayName), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).cardColor, - foregroundColor: Theme.of(context).colorScheme.onSurface, - elevation: 2, - ), - ); - }).toList(), - ), - ], - ), - ); - } - - void _searchByCategory(ActivityCategory category) { - context.read().add( - SearchActivities( - tripId: widget.trip.id!, - destination: widget.trip.location, - category: category, - ), - ); - } - - void _handleVote(String activityId, String userId, int vote) { - if (userId.isEmpty) { - _errorService.showSnackbar( - message: 'Vous devez être connecté pour voter', - isError: true, - ); - return; - } - - context.read().add( - VoteForActivity( - activityId: activityId, - userId: userId, - vote: vote, - ), - ); - } - - void _addActivityToTrip(Activity activity) { - context.read().add(AddActivity(activity)); - } - - void _searchGoogleActivities() { - // Rechercher toutes les catégories d'activités - context.read().add( - SearchActivities( - tripId: widget.trip.id!, - destination: widget.trip.location, - category: null, // Null pour rechercher toutes les catégories - ), - ); - } - - Widget _buildCategoryButton(ActivityCategory category, String label) { - return ElevatedButton.icon( - onPressed: () => _searchByCategory(category), - icon: Icon(_getCategoryIcon(category), size: 18), - label: Text(label), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - ); - } - - void _showAddActivitySheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => AddActivityBottomSheet( - trip: widget.trip, ), ); } @@ -596,7 +355,6 @@ class _ActivitiesPageState extends State void _showCategoryFilter() { showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, builder: (context) => _buildCategoryFilterSheet(), ); } @@ -604,139 +362,1003 @@ class _ActivitiesPageState extends State void _showPriceFilter() { showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, builder: (context) => _buildPriceFilterSheet(), ); } + void _showRatingFilter() { + showModalBottomSheet( + context: context, + builder: (context) => _buildRatingFilterSheet(), + ); + } + Widget _buildCategoryFilterSheet() { final theme = Theme.of(context); - + final categories = [ + 'Toutes les catégories', + ...ActivityCategory.values.map((e) => e.displayName), + ]; + return Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.all(16), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, // Limite à 70% de l'écran ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withOpacity(0.3), - borderRadius: BorderRadius.circular(2), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Catégories', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Filtrer par catégorie', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ...ActivityCategory.values.map((category) { - final isSelected = _selectedCategory == category.displayName; - - return ListTile( - leading: Icon(_getCategoryIcon(category)), - title: Text(category.displayName), - trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, - onTap: () { - setState(() { - _selectedCategory = isSelected ? null : category.displayName; - }); - - context.read().add( - FilterActivities(category: _selectedCategory), - ); - - Navigator.pop(context); - }, - ); - }).toList(), + ), const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + children: categories.map((category) => ListTile( + title: Text(category), + onTap: () { + setState(() { + _selectedCategory = category; + }); + Navigator.pop(context); + _applyFilters(); + }, + trailing: _selectedCategory == category + ? Icon(Icons.check, color: theme.colorScheme.primary) + : null, + )).toList(), + ), + ), + ), ], - ), ), ); } Widget _buildPriceFilterSheet() { final theme = Theme.of(context); - + final prices = ['Prix', 'Gratuit', 'Bon marché', 'Modéré', 'Cher', 'Très cher']; + return Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.all(16), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withOpacity(0.3), - borderRadius: BorderRadius.circular(2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Niveau de prix', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Filtrer par prix', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + children: prices.map((price) => ListTile( + title: Text(price), + onTap: () { + setState(() { + _selectedPrice = price; + }); + Navigator.pop(context); + _applyFilters(); + }, + trailing: _selectedPrice == price + ? Icon(Icons.check, color: theme.colorScheme.primary) + : null, + )).toList(), ), ), ), - ...PriceLevel.values.map((priceLevel) { - return ListTile( - leading: Icon(Icons.euro), - title: Text(priceLevel.displayName), - onTap: () { - // TODO: Implémenter le filtre par prix - Navigator.pop(context); - }, - ); - }).toList(), - const SizedBox(height: 16), ], + ), + ); + } + + Widget _buildRatingFilterSheet() { + final theme = Theme.of(context); + final ratings = ['Note', '4+ étoiles', '3+ étoiles', '2+ étoiles', '1+ étoiles']; + + return Container( + padding: const EdgeInsets.all(16), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Note minimale', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Column( + children: ratings.map((rating) => ListTile( + title: Text(rating), + onTap: () { + setState(() { + _selectedRating = rating; + }); + Navigator.pop(context); + _applyFilters(); + }, + trailing: _selectedRating == rating + ? Icon(Icons.check, color: theme.colorScheme.primary) + : null, + )).toList(), + ), + ), + ), + ], + ), + ); + } + + void _applyFilters() { + String? category = _selectedCategory == 'Toutes les catégories' ? null : _selectedCategory; + double? minRating = _getMinRatingFromString(_selectedRating); + + context.read().add(FilterActivities( + category: category, + minRating: minRating, + )); + } + + double? _getMinRatingFromString(String rating) { + switch (rating) { + case '4+ étoiles': + return 4.0; + case '3+ étoiles': + return 3.0; + case '2+ étoiles': + return 2.0; + case '1+ étoiles': + return 1.0; + default: + return null; + } + } + + void _showAddActivityBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: AddActivityBottomSheet(trip: widget.trip), + ), + ); + } + + Widget _buildTripActivitiesTab() { + // Utiliser les données locales au lieu du BLoC + if (_isLoadingTripActivities) { + return const Center(child: CircularProgressIndicator()); + } + + if (_tripActivities.isEmpty) { + return _buildEmptyState( + 'Aucune activité du voyage', + 'Ajoutez vos premières activités pour ce voyage', + Icons.add_location, + ); + } + + return _buildActivityList(_tripActivities); + } + + Widget _buildApprovedActivitiesTab() { + // Utiliser les données locales au lieu du BLoC + if (_isLoadingTripActivities) { + return const Center(child: CircularProgressIndicator()); + } + + if (_approvedActivities.isEmpty) { + return _buildEmptyState( + 'Aucune activité approuvée', + 'Les activités avec des votes positifs apparaîtront ici', + Icons.thumb_up_outlined, + ); + } + + return _buildActivityList(_approvedActivities); + } + + Widget _buildGoogleSuggestionsTab() { + return BlocBuilder( + builder: (context, state) { + if (state is ActivitySearching) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Recherche d\'activités en cours...', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } + + if (state is ActivitySearchResults) { + final googleActivities = state.searchResults; + + // Filtrer les activités déjà présentes dans le voyage + final filteredActivities = googleActivities.where((googleActivity) { + return !_tripActivities.any((tripActivity) => + tripActivity.name.toLowerCase().trim() == googleActivity.name.toLowerCase().trim()); + }).toList(); + + print('🔍 [Google Search] ${googleActivities.length} résultats trouvés, ${filteredActivities.length} après filtrage'); + + if (filteredActivities.isEmpty && googleActivities.isNotEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildEmptyState( + 'Toutes les activités sont déjà dans votre voyage', + 'Recherchez plus d\'activités pour découvrir de nouvelles suggestions', + Icons.check_circle, + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () => _loadMoreGoogleActivities(), + icon: const Icon(Icons.add_circle_outline), + label: const Text('Rechercher plus d\'activités'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ); + } + + if (filteredActivities.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildEmptyState( + 'Aucun résultat trouvé', + 'Aucune activité trouvée pour cette destination', + Icons.search_off, + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () => _searchGoogleActivities(), + icon: const Icon(Icons.refresh), + label: const Text('Rechercher à nouveau'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ); + } + + return Column( + children: [ + // Liste des activités + Expanded( + child: RefreshIndicator( + onRefresh: () async { + _resetAndSearchGoogleActivities(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filteredActivities.length, + itemBuilder: (context, index) { + final activity = filteredActivities[index]; + return _buildActivityCard(activity, isGoogleSuggestion: true); + }, + ), + ), + ), + + // Bouton "Rechercher plus d'activités" + if (state.isLoading) + Container( + padding: const EdgeInsets.all(16), + child: const Center( + child: CircularProgressIndicator(), + ), + ) + else + Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: () => _loadMoreGoogleActivities(), + icon: const Icon(Icons.add_circle_outline), + label: const Text('Rechercher plus d\'activités'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + // Bouton de debug temporaire + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: OutlinedButton.icon( + onPressed: () { + print('🧪 [DEBUG] Force auto-reload check - État actuel:'); + print('🧪 [DEBUG] _tripActivities: ${_tripActivities.length}'); + print('🧪 [DEBUG] _autoReloadInProgress: $_autoReloadInProgress'); + print('🧪 [DEBUG] _lastAutoReloadTriggerCount: $_lastAutoReloadTriggerCount'); + _checkAndLoadMoreActivitiesIfNeeded(); + }, + icon: const Icon(Icons.bug_report, size: 16), + label: const Text('🧪 Test Auto-Reload'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + ], + ), + ], + ); + } + + // État initial - aucune recherche effectuée + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildEmptyState( + 'Découvrir des activités', + 'Trouvez des restaurants, musées et attractions près de ${widget.trip.location}', + Icons.explore, + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () => _searchGoogleActivities(), + icon: const Icon(Icons.search), + label: const Text('Rechercher des activités (6 résultats)'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + Text( + 'Recherche powered by Google Places', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ); + }, + ); + } + + Widget _buildEmptyState(String title, String subtitle, IconData icon) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: theme.colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], ), ), ); } - IconData _getCategoryIcon(ActivityCategory category) { - switch (category) { - case ActivityCategory.museum: + Widget _buildActivityList(List activities) { + return RefreshIndicator( + onRefresh: () async { + // Rafraîchir selon l'onglet actuel + if (_tabController.index == 2) { + // Onglet Google - relancer la recherche + _searchGoogleActivities(); + } else { + // Onglets activités du voyage - recharger depuis la base de données + _loadActivities(); + } + }, + child: activities.isEmpty + ? ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: _buildEmptyState( + 'Aucune activité', + 'Tirez vers le bas pour actualiser', + Icons.refresh, + ), + ), + ], + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: activities.length, + itemBuilder: (context, index) { + final activity = activities[index]; + return _buildActivityCard(activity); + }, + ), + ); + } + + Widget _buildActivityCard(Activity activity, {bool isGoogleSuggestion = false}) { + final theme = Theme.of(context); + + // Vérifier si l'activité existe déjà dans le voyage (pour les suggestions Google) + final bool activityAlreadyExists = isGoogleSuggestion && + _tripActivities.any((tripActivity) => + tripActivity.name.toLowerCase().trim() == activity.name.toLowerCase().trim()); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image de l'activité + if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Container( + height: 200, + width: double.infinity, + child: Image.network( + activity.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 200, + color: theme.colorScheme.surfaceContainerHighest, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + 'Image non disponible', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + height: 200, + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ), + ), + ), + + // Contenu de la carte + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Icône de catégorie + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _getCategoryIcon(activity.category), + color: theme.colorScheme.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + // Nom et catégorie + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + activity.category, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + // Note + if (activity.rating != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, color: Colors.amber, size: 16), + const SizedBox(width: 4), + Text( + activity.rating!.toStringAsFixed(1), + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + if (activity.description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + activity.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.8), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (activity.address != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.location_on, + size: 16, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + activity.address!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ], + ), + ], + const SizedBox(height: 12), + // Boutons d'action et votes (différents selon le contexte) + if (isGoogleSuggestion) ...[ + // Pour les suggestions Google : bouton d'ajout ou indication si déjà ajoutée + Row( + children: [ + if (activityAlreadyExists) ...[ + // Activité déjà dans le voyage + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 16, + color: Colors.orange.shade700, + ), + const SizedBox(width: 6), + Text( + 'Déjà dans le voyage', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.orange.shade700, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ] else ...[ + // Bouton pour ajouter l'activité + Expanded( + child: ElevatedButton.icon( + onPressed: () => _addGoogleActivityToTrip(activity), + icon: const Icon(Icons.add, size: 18), + label: const Text('Ajouter au voyage'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ], + ), + ] else ...[ + // Pour les activités du voyage : système de votes + Row( + children: [ + // Votes positifs (pouces verts) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${activity.positiveVotes}', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.green.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.thumb_up, + size: 16, + color: Colors.green.shade700, + ), + ], + ), + ), + const SizedBox(width: 8), + // Votes négatifs (pouces rouges) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${activity.negativeVotes}', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.red.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.thumb_down, + size: 16, + color: Colors.red.shade700, + ), + ], + ), + ), + const Spacer(), + // Bouton J'aime/J'aime pas + IconButton( + onPressed: () => _voteForActivity(activity.id, 1), + icon: const Icon(Icons.thumb_up), + iconSize: 20, + ), + IconButton( + onPressed: () => _voteForActivity(activity.id, -1), + icon: const Icon(Icons.thumb_down), + iconSize: 20, + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } + + IconData _getCategoryIcon(String category) { + switch (category.toLowerCase()) { + case 'museum': + case 'musée': return Icons.museum; - case ActivityCategory.restaurant: + case 'restaurant': return Icons.restaurant; - case ActivityCategory.attraction: + case 'attraction': return Icons.place; - case ActivityCategory.entertainment: + case 'divertissement': + case 'entertainment': return Icons.sports_esports; - case ActivityCategory.shopping: + case 'shopping': return Icons.shopping_bag; - case ActivityCategory.nature: - return Icons.nature; - case ActivityCategory.culture: - return Icons.palette; - case ActivityCategory.nightlife: + case 'nature': + return Icons.park; + case 'culture': + return Icons.account_balance; + case 'vie nocturne': + case 'nightlife': return Icons.nightlife; - case ActivityCategory.sports: + case 'sports': return Icons.sports; - case ActivityCategory.relaxation: + case 'détente': + case 'relaxation': return Icons.spa; + default: + return Icons.place; } } -} \ No newline at end of file + + void _voteForActivity(String activityId, int vote) { + print('🗳️ Vote pour activité $activityId: $vote'); + + // TODO: Récupérer l'ID utilisateur actuel + // Pour l'instant, on utilise un ID temporaire + final userId = 'current_user_id'; + + context.read().add(VoteForActivity( + activityId: activityId, + userId: userId, + vote: vote, + )); + + // Afficher un feedback à l'utilisateur + final message = vote == 1 ? 'Vote positif ajouté !' : 'Vote négatif ajouté !'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 1), + backgroundColor: vote == 1 ? Colors.green : Colors.orange, + ), + ); + } + + void _addGoogleActivityToTrip(Activity activity) { + print('➕ [Add Activity] Adding ${activity.name} to trip'); + + // Créer une nouvelle activité avec l'ID du voyage + final newActivity = activity.copyWith( + tripId: widget.trip.id, + // Générer un nouvel ID unique pour cette activité dans le voyage + id: DateTime.now().millisecondsSinceEpoch.toString(), + ); + + context.read().add(AddActivity(newActivity)); + + // Afficher un feedback à l'utilisateur + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${activity.name} ajoutée au voyage !'), + duration: const Duration(seconds: 2), + backgroundColor: Colors.green, + action: SnackBarAction( + label: 'Voir', + textColor: Colors.white, + onPressed: () { + // Revenir à l'onglet des activités du voyage + _tabController.animateTo(0); + }, + ), + ), + ); + } + + void _checkAndLoadMoreActivitiesIfNeeded() { + // Protection contre les rechargements en boucle + if (_autoReloadInProgress) { + print('⏸️ [Auto-reload] Auto-reload déjà en cours, skip'); + return; + } + + final currentState = context.read().state; + if (currentState is ActivitySearchResults) { + final googleActivities = currentState.searchResults; + + print('🔍 [Auto-reload] Activités du voyage en mémoire: ${_tripActivities.length}'); + print('🔍 [Auto-reload] Activités Google total: ${googleActivities.length}'); + + // Filtrer les activités déjà présentes dans le voyage + final filteredActivities = googleActivities.where((googleActivity) { + final isDuplicate = _tripActivities.any((tripActivity) => + tripActivity.name.toLowerCase().trim() == googleActivity.name.toLowerCase().trim()); + if (isDuplicate) { + print('🔍 [Auto-reload] Activité filtrée: ${googleActivity.name}'); + } + return !isDuplicate; + }).toList(); + + print('🔍 [Auto-reload] ${filteredActivities.length} activités visibles après filtrage sur ${googleActivities.length} total'); + + // Protection: ne pas redéclencher pour le même nombre d'activités Google + if (googleActivities.length == _lastAutoReloadTriggerCount) { + print('🔒 [Auto-reload] Même nombre qu\'avant (${googleActivities.length}), skip pour éviter la boucle'); + return; + } + + // Si on a moins de 4 activités visibles ET qu'on n'a pas déjà beaucoup d'activités Google + if (filteredActivities.length < 4 && googleActivities.length < 20) { + _autoReloadInProgress = true; + _lastAutoReloadTriggerCount = googleActivities.length; + + // Calculer combien d'activités on doit demander + final activitiesNeeded = 6 - filteredActivities.length; // Manque pour arriver à 6 + final newTotalToRequest = googleActivities.length + activitiesNeeded + 6; // Activités actuelles + ce qui manque + buffer de 6 + + print('🔄 [Auto-reload] DÉCLENCHEMENT: Besoin de ${activitiesNeeded} activités supplémentaires'); + print('📊 [Auto-reload] Demande totale: ${newTotalToRequest} activités (actuellement: ${googleActivities.length})'); + + // Mettre à jour le compteur et recharger avec le nouveau total + _totalGoogleActivitiesRequested = newTotalToRequest; + _loadMoreGoogleActivitiesWithTotal(newTotalToRequest); + + // Libérer le verrou après un délai + Future.delayed(const Duration(seconds: 3), () { + _autoReloadInProgress = false; + print('🔓 [Auto-reload] Verrou libéré'); + }); + } else if (filteredActivities.length >= 4) { + print('✅ [Auto-reload] Suffisamment d\'activités visibles (${filteredActivities.length} >= 4)'); + } else { + print('🚫 [Auto-reload] Trop d\'activités Google déjà chargées (${googleActivities.length} >= 20), arrêt auto-reload'); + } + } else { + print('⚠️ [Auto-reload] État pas prêt pour auto-chargement: ${currentState.runtimeType}'); + } + } + + void _searchGoogleActivities() { + print('🔍 [Google Search] Initializing first search with 6 results'); + _totalGoogleActivitiesRequested = 6; // Reset du compteur + _autoReloadInProgress = false; // Reset des protections + _lastAutoReloadTriggerCount = 0; + context.read().add(SearchActivities( + tripId: widget.trip.id!, + destination: widget.trip.location, + category: null, // Rechercher dans toutes les catégories + maxResults: 6, // Charger 6 résultats à la fois + reset: true, // Nouveau flag pour reset + )); + _googleSearchPerformed = true; + } + + void _resetAndSearchGoogleActivities() { + print('🔄 [Google Search] Resetting and starting fresh search'); + _totalGoogleActivitiesRequested = 6; // Reset du compteur + _autoReloadInProgress = false; // Reset des protections + _lastAutoReloadTriggerCount = 0; + context.read().add(SearchActivities( + tripId: widget.trip.id!, + destination: widget.trip.location, + category: null, + maxResults: 6, + reset: true, + )); + _googleSearchPerformed = true; + } + + void _loadMoreGoogleActivities() { + print('📄 [Google Search] Loading more activities (next 6 results)'); + final currentState = context.read().state; + + if (currentState is ActivitySearchResults) { + final currentCount = currentState.searchResults.length; + final newTotal = currentCount + 6; + print('📊 [Google Search] Current results count: $currentCount, requesting total: $newTotal'); + + _totalGoogleActivitiesRequested = newTotal; + context.read().add(SearchActivities( + tripId: widget.trip.id!, + destination: widget.trip.location, + category: null, + maxResults: newTotal, // Demander le total cumulé + reset: true, // Reset pour avoir tous les résultats d'un coup + )); + } + } + + void _loadMoreGoogleActivitiesWithTotal(int totalToRequest) { + print('📈 [Google Search] Loading activities with specific total: $totalToRequest'); + + // Au lieu de reset, on utilise l'offset et append pour forcer plus de résultats + final currentState = context.read().state; + if (currentState is ActivitySearchResults) { + final currentCount = currentState.searchResults.length; + final additionalNeeded = totalToRequest - currentCount; + + print('📊 [Google Search] Current: $currentCount, Total demandé: $totalToRequest, Additional: $additionalNeeded'); + + if (additionalNeeded > 0) { + context.read().add(SearchActivities( + tripId: widget.trip.id!, + destination: widget.trip.location, + category: null, + maxResults: additionalNeeded, + offset: currentCount, + appendToExisting: true, // Ajouter aux résultats existants + )); + } else { + print('⚠️ [Google Search] Pas besoin de charger plus (déjà suffisant)'); + } + } else { + // Si pas de résultats existants, faire une recherche complète + context.read().add(SearchActivities( + tripId: widget.trip.id!, + destination: widget.trip.location, + category: null, + maxResults: totalToRequest, + reset: true, + )); + } + } +} diff --git a/lib/components/activities/activity_card.dart b/lib/components/activities/activity_card.dart index 9ba733f..0418de2 100644 --- a/lib/components/activities/activity_card.dart +++ b/lib/components/activities/activity_card.dart @@ -9,28 +9,29 @@ class ActivityCard extends StatelessWidget { final VoidCallback? onAddToTrip; const ActivityCard({ - Key? key, + super.key, required this.activity, required this.currentUserId, required this.onVote, this.onAddToTrip, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDarkMode = theme.brightness == Brightness.dark; final userVote = activity.getUserVote(currentUserId); + final totalVotes = activity.positiveVotes + activity.negativeVotes; + final positivePercentage = totalVotes > 0 ? (activity.positiveVotes / totalVotes) : 0.0; return Container( + margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: BorderRadius.circular(16), + color: Colors.white, + borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), - blurRadius: 10, - offset: const Offset(0, 5), + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), @@ -40,32 +41,32 @@ class ActivityCard extends StatelessWidget { // Image de l'activité if (activity.imageUrl != null) ...[ ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: Stack( children: [ Image.network( activity.imageUrl!, - height: 200, + height: 160, width: double.infinity, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( - height: 200, + height: 160, width: double.infinity, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + decoration: const BoxDecoration( + color: Color(0xFFF5F5F5), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), - child: Icon( + child: const Icon( Icons.image_not_supported, size: 48, - color: theme.colorScheme.onSurfaceVariant, + color: Color(0xFF9E9E9E), ), ); }, ), - // Badge catégorie + // Badge catégorie (simplifié) if (activity.category.isNotEmpty) Positioned( top: 12, @@ -73,13 +74,14 @@ class ActivityCard extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: _getCategoryColor(activity.category), - borderRadius: BorderRadius.circular(12), + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), ), child: Text( activity.category, - style: theme.textTheme.bodySmall?.copyWith( + style: const TextStyle( color: Colors.white, + fontSize: 12, fontWeight: FontWeight.w500, ), ), @@ -88,6 +90,21 @@ class ActivityCard extends StatelessWidget { ], ), ), + ] else ...[ + // Placeholder si pas d'image + Container( + height: 160, + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFFF5F5F5), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + child: const Icon( + Icons.image_not_supported, + size: 48, + color: Color(0xFF9E9E9E), + ), + ), ], // Contenu de la carte @@ -103,9 +120,10 @@ class ActivityCard extends StatelessWidget { Expanded( child: Text( activity.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -116,19 +134,20 @@ class ActivityCard extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.amber.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.star, size: 14, color: Colors.amber), + const Icon(Icons.star, size: 12, color: Color(0xFFFFB400)), const SizedBox(width: 2), Text( activity.rating!.toStringAsFixed(1), - style: theme.textTheme.bodySmall?.copyWith( + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w500, - color: Colors.amber[800], + color: Color(0xFFB8860B), ), ), ], @@ -140,161 +159,186 @@ class ActivityCard extends StatelessWidget { const SizedBox(height: 8), - // Description + // Description (simplifiée) Text( activity.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + style: const TextStyle( + fontSize: 14, + color: Color(0xFF6B6B6B), + height: 1.4, ), - maxLines: 3, + maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), - // Informations supplémentaires - if (activity.priceLevel != null || activity.address != null) ...[ + // Adresse (si disponible) + if (activity.address != null) ...[ Row( children: [ - if (activity.priceLevel != null) ...[ - Icon( - Icons.euro, - size: 16, - color: theme.colorScheme.onSurface.withOpacity(0.5), - ), - const SizedBox(width: 4), - Text( - activity.priceLevel!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + const Icon( + Icons.location_on, + size: 14, + color: Color(0xFF9E9E9E), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + activity.address!, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF9E9E9E), ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - if (activity.address != null) ...[ - const SizedBox(width: 16), - Icon( - Icons.location_on, - size: 16, - color: theme.colorScheme.onSurface.withOpacity(0.5), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - activity.address!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ] else if (activity.address != null) ...[ - Icon( - Icons.location_on, - size: 16, - color: theme.colorScheme.onSurface.withOpacity(0.5), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - activity.address!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + ), ], ), const SizedBox(height: 12), ], - // Section vote et actions - Row( - children: [ - // Boutons de vote - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), + // Section vote avec barre de progression + if (activity.id.isNotEmpty) ...[ + // Barre de progression des votes + Container( + height: 6, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(3), + ), + child: totalVotes > 0 + ? Row( + children: [ + if (activity.positiveVotes > 0) + Expanded( + flex: activity.positiveVotes, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF4CAF50), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(3), + bottomLeft: const Radius.circular(3), + topRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero, + bottomRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero, + ), + ), + ), + ), + if (activity.negativeVotes > 0) + Expanded( + flex: activity.negativeVotes, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFF44336), + borderRadius: BorderRadius.only( + topLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero, + bottomLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero, + topRight: const Radius.circular(3), + bottomRight: const Radius.circular(3), + ), + ), + ), + ), + ], + ) + : null, + ), + + const SizedBox(height: 8), + + // Stats et boutons de vote + Row( + children: [ + // Stats des votes + Text( + totalVotes > 0 + ? '${(positivePercentage * 100).round()}% positif • $totalVotes vote${totalVotes > 1 ? 's' : ''}' + : 'Aucun vote', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF9E9E9E), + ), ), - child: Row( - mainAxisSize: MainAxisSize.min, + + const Spacer(), + + // Boutons de vote compacts + Row( children: [ - // Vote positif - _buildVoteButton( + _buildCompactVoteButton( icon: Icons.thumb_up, - count: activity.positiveVotes, isActive: userVote == 1, onTap: () => onVote(userVote == 1 ? 0 : 1), - activeColor: Colors.blue, + activeColor: const Color(0xFF4CAF50), ), - - Container( - width: 1, - height: 24, - color: theme.colorScheme.onSurface.withOpacity(0.2), - ), - - // Vote négatif - _buildVoteButton( + const SizedBox(width: 8), + _buildCompactVoteButton( icon: Icons.thumb_down, - count: activity.negativeVotes, isActive: userVote == -1, onTap: () => onVote(userVote == -1 ? 0 : -1), - activeColor: Colors.red, + activeColor: const Color(0xFFF44336), ), ], ), - ), - - const Spacer(), - - // Bouton d'action - if (onAddToTrip != null) - ElevatedButton( - onPressed: onAddToTrip, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: const Text('Voter'), - ) - else - // Score total + ], + ), + ] else ...[ + // Pour les activités non sauvegardées + Row( + children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: _getScoreColor(activity.totalVotes).withOpacity(0.2), + color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(12), ), - child: Row( + child: const Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - '${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}', - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - color: _getScoreColor(activity.totalVotes), - ), + Icon( + Icons.info_outline, + size: 14, + color: Color(0xFF9E9E9E), ), - const SizedBox(width: 4), + SizedBox(width: 4), Text( - 'vote${activity.votes.length > 1 ? 's' : ''}', - style: theme.textTheme.bodySmall?.copyWith( - color: _getScoreColor(activity.totalVotes), + 'Ajoutez pour voter', + style: TextStyle( + fontSize: 12, + color: Color(0xFF9E9E9E), ), ), ], ), ), - ], - ), + + const Spacer(), + + // Bouton d'ajout + if (onAddToTrip != null) + ElevatedButton.icon( + onPressed: onAddToTrip, + icon: const Icon(Icons.add, size: 16), + label: const Text('Ajouter'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF007AFF), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], ], ), ), @@ -303,9 +347,8 @@ class ActivityCard extends StatelessWidget { ); } - Widget _buildVoteButton({ + Widget _buildCompactVoteButton({ required IconData icon, - required int count, required bool isActive, required VoidCallback onTap, required Color activeColor, @@ -313,62 +356,19 @@ class ActivityCard extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 18, - color: isActive ? activeColor : Colors.grey, - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Text( - count.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isActive ? activeColor : Colors.grey, - ), - ), - ], - ], + width: 32, + height: 32, + decoration: BoxDecoration( + color: isActive ? activeColor : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + icon, + size: 16, + color: isActive ? Colors.white : const Color(0xFF9E9E9E), ), ), ); } - Color _getCategoryColor(String category) { - switch (category.toLowerCase()) { - case 'musée': - return Colors.purple; - case 'restaurant': - return Colors.orange; - case 'attraction': - return Colors.blue; - case 'divertissement': - return Colors.pink; - case 'shopping': - return Colors.green; - case 'nature': - return Colors.teal; - case 'culture': - return Colors.indigo; - case 'vie nocturne': - return Colors.deepPurple; - case 'sports': - return Colors.red; - case 'détente': - return Colors.cyan; - default: - return Colors.grey; - } - } - - Color _getScoreColor(int score) { - if (score > 0) return Colors.green; - if (score < 0) return Colors.red; - return Colors.grey; - } } \ No newline at end of file diff --git a/lib/components/activities/add_activity_bottom_sheet.dart b/lib/components/activities/add_activity_bottom_sheet.dart index facbf25..9afeef9 100644 --- a/lib/components/activities/add_activity_bottom_sheet.dart +++ b/lib/components/activities/add_activity_bottom_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'dart:math' as math; import '../../blocs/activity/activity_bloc.dart'; import '../../blocs/activity/activity_event.dart'; import '../../models/activity.dart'; @@ -41,10 +42,17 @@ class _AddActivityBottomSheetState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); + final keyboardHeight = mediaQuery.viewInsets.bottom; - return Container( + return AnimatedContainer( + duration: const Duration(milliseconds: 200), height: mediaQuery.size.height * 0.85, - margin: const EdgeInsets.all(16), + margin: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16, + ), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(20), @@ -84,10 +92,15 @@ class _AddActivityBottomSheetState extends State { const Divider(), - // Formulaire + // Formulaire avec SingleChildScrollView pour le scroll automatique Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.all(20), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: math.max(20, keyboardHeight), + ), child: Form( key: _formKey, child: Column( diff --git a/lib/models/activity.dart b/lib/models/activity.dart index e60f9e8..7f99e1d 100644 --- a/lib/models/activity.dart +++ b/lib/models/activity.dart @@ -67,6 +67,23 @@ class Activity { return votes[userId] ?? 0; } + /// Vérifie si tous les participants du voyage ont voté positivement pour cette activité + bool isApprovedByAllParticipants(List tripParticipants) { + if (tripParticipants.isEmpty) return false; + + // Tous les participants doivent avoir voté + for (String participantId in tripParticipants) { + if (!votes.containsKey(participantId)) { + return false; // Quelqu'un n'a pas encore voté + } + if (votes[participantId] != 1) { + return false; // Quelqu'un a voté négativement ou neutre + } + } + + return true; // Tous ont voté positivement + } + /// Crée une copie avec des modifications Activity copyWith({ String? id, diff --git a/lib/repositories/activity_repository.dart b/lib/repositories/activity_repository.dart index 5cbe54f..8cff2ff 100644 --- a/lib/repositories/activity_repository.dart +++ b/lib/repositories/activity_repository.dart @@ -37,16 +37,20 @@ class ActivityRepository { try { print('ActivityRepository: Récupération des activités pour le voyage: $tripId'); + // Modifié pour éviter l'erreur d'index composite + // On récupère d'abord par tripId, puis on trie en mémoire final querySnapshot = await _firestore .collection(_collection) .where('tripId', isEqualTo: tripId) - .orderBy('updatedAt', descending: true) .get(); final activities = querySnapshot.docs .map((doc) => Activity.fromSnapshot(doc)) .toList(); + // Tri en mémoire par date de mise à jour (plus récent en premier) + activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + print('ActivityRepository: ${activities.length} activités trouvées'); return activities; } catch (e) { @@ -111,6 +115,19 @@ class ActivityRepository { /// Vote pour une activité Future voteForActivity(String activityId, String userId, int vote) async { try { + // Validation des paramètres + if (activityId.isEmpty) { + print('ActivityRepository: ID d\'activité vide'); + _errorService.logError('activity_repository', 'ID d\'activité vide pour le vote'); + return false; + } + + if (userId.isEmpty) { + print('ActivityRepository: ID d\'utilisateur vide'); + _errorService.logError('activity_repository', 'ID d\'utilisateur vide pour le vote'); + return false; + } + print('ActivityRepository: Vote pour l\'activité $activityId: $vote'); // vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote @@ -155,12 +172,16 @@ class ActivityRepository { return _firestore .collection(_collection) .where('tripId', isEqualTo: tripId) - .orderBy('updatedAt', descending: true) .snapshots() .map((snapshot) { - return snapshot.docs + final activities = snapshot.docs .map((doc) => Activity.fromSnapshot(doc)) .toList(); + + // Tri en mémoire par date de mise à jour (plus récent en premier) + activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + return activities; }); } catch (e) { print('ActivityRepository: Erreur stream activités: $e'); @@ -198,16 +219,23 @@ class ActivityRepository { /// Recherche des activités par catégorie Future> getActivitiesByCategory(String tripId, String category) async { try { + print('ActivityRepository: Recherche par catégorie: $category pour le voyage: $tripId'); + + // Récupérer toutes les activités du voyage puis filtrer en mémoire final querySnapshot = await _firestore .collection(_collection) .where('tripId', isEqualTo: tripId) - .where('category', isEqualTo: category) - .orderBy('updatedAt', descending: true) .get(); - return querySnapshot.docs + final activities = querySnapshot.docs .map((doc) => Activity.fromSnapshot(doc)) + .where((activity) => activity.category == category) .toList(); + + // Tri en mémoire par date de mise à jour (plus récent en premier) + activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + return activities; } catch (e) { print('ActivityRepository: Erreur recherche par catégorie: $e'); _errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e'); diff --git a/lib/services/activity_places_service.dart b/lib/services/activity_places_service.dart index 6fa2f83..465a0af 100644 --- a/lib/services/activity_places_service.dart +++ b/lib/services/activity_places_service.dart @@ -19,15 +19,14 @@ class ActivityPlacesService { required String tripId, ActivityCategory? category, int radius = 5000, + int maxResults = 20, + int offset = 0, }) async { try { - print('ActivityPlacesService: Recherche d\'activités pour: $destination'); + print('ActivityPlacesService: Recherche d\'activités pour: $destination (max: $maxResults, offset: $offset)'); // 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 allActivities = []; @@ -67,8 +66,21 @@ class ActivityPlacesService { 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 + 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'); @@ -78,29 +90,56 @@ class ActivityPlacesService { } /// Géocode une destination pour obtenir les coordonnées - Future?> _geocodeDestination(String destination) async { + Future> _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']; - return { + 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}'); } - - return null; } catch (e) { print('ActivityPlacesService: Erreur géocodage: $e'); - return null; + throw e; // Rethrow pour permettre la gestion d'erreur en amont } } @@ -268,9 +307,6 @@ class ActivityPlacesService { // 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' @@ -338,4 +374,190 @@ class ActivityPlacesService { return ActivityCategory.attraction; // Par défaut } + + /// Recherche d'activités avec pagination (6 par page) + Future> searchActivitiesPaginated({ + required String destination, + required String tripId, + ActivityCategory? category, + int pageSize = 6, + String? nextPageToken, + int radius = 5000, + }) async { + try { + print('ActivityPlacesService: Recherche paginée pour: $destination (page: ${nextPageToken ?? "première"})'); + + // 1. Géocoder la destination + final coordinates = await _geocodeDestination(destination); + + // 2. Rechercher les activités par catégorie avec pagination + if (category != null) { + return await _searchByCategoryPaginated( + coordinates['lat']!, + coordinates['lng']!, + category, + tripId, + radius, + pageSize, + nextPageToken, + ); + } else { + // Pour toutes les catégories, faire une recherche générale paginée + return await _searchAllCategoriesPaginated( + coordinates['lat']!, + coordinates['lng']!, + tripId, + radius, + pageSize, + nextPageToken, + ); + } + } catch (e) { + print('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) { + print('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) { + print('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) { + print('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) { + print('ActivityPlacesService: Erreur recherche toutes catégories paginée: $e'); + return { + 'activities': [], + 'nextPageToken': null, + 'hasMoreData': false, + }; + } + } } \ No newline at end of file diff --git a/scripts/cleanup_london_duplicates.dart b/scripts/cleanup_london_duplicates.dart deleted file mode 100644 index 85629ad..0000000 --- a/scripts/cleanup_london_duplicates.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:io'; -import 'package:firebase_core/firebase_core.dart'; -import '../lib/services/trip_image_service.dart'; -import '../lib/firebase_options.dart'; - -/// Script pour nettoyer les doublons d'images de Londres -void main() async { - - try { - // Initialiser Firebase - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - - - final tripImageService = TripImageService(); - - await tripImageService.cleanupDuplicateImages(); - - - } catch (e) { - exit(1); - } -} - -/* -Instructions d'utilisation: - -1. Assurez-vous que Firebase est configuré dans votre projet -2. Exécutez: dart run scripts/cleanup_london_duplicates.dart -3. Le script analysera automatiquement tous les doublons et les supprimera -4. Vérifiez Firebase Storage après l'exécution - -Le script: -- Groupe toutes les images par destination (normalisée) -- Identifie les doublons pour la même destination -- Garde l'image la plus récente (basé sur le timestamp) -- Supprime les anciennes versions - -Pour Londres spécifiquement, si vous avez: -- Londres_Royaume_Uni_1762175016594.jpg -- Londres_Royaume_Uni_1762175016603.jpg - -Le script gardera la version _1762175016603.jpg (plus récente) -et supprimera _1762175016594.jpg (plus ancienne) -*/ \ No newline at end of file diff --git a/scripts/simple_cleanup.dart b/scripts/simple_cleanup.dart deleted file mode 100644 index af4502f..0000000 --- a/scripts/simple_cleanup.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_storage/firebase_storage.dart'; - -void main() async { - - try { - await Firebase.initializeApp(); - - final storage = FirebaseStorage.instance; - final ref = storage.ref().child('trip_images'); - - final result = await ref.listAll(); - - - // Grouper les images par ville - Map> imagesByCity = {}; - - for (var item in result.items) { - final name = item.name; - - // Extraire la ville du nom de fichier - String city = 'unknown'; - if (name.contains('_')) { - // Format: londres_timestamp.jpg ou london_timestamp.jpg - city = name.split('_')[0].toLowerCase(); - } - - if (!imagesByCity.containsKey(city)) { - imagesByCity[city] = []; - } - imagesByCity[city]!.add(item); - } - - for (var entry in imagesByCity.entries) { - } - - // Focus sur Londres/London - final londonImages = []; - londonImages.addAll(imagesByCity['londres'] ?? []); - londonImages.addAll(imagesByCity['london'] ?? []); - - - if (londonImages.length > 1) { - - // Trier par timestamp (garder la plus récente) - londonImages.sort((a, b) { - final timestampA = _extractTimestamp(a.name); - final timestampB = _extractTimestamp(b.name); - return timestampB.compareTo(timestampA); // Plus récent en premier - }); - - for (var image in londonImages) { - final timestamp = _extractTimestamp(image.name); - } - - // Supprimer toutes sauf la première (plus récente) - for (int i = 1; i < londonImages.length; i++) { - await londonImages[i].delete(); - } - - } else { - } - - - } catch (e) { - } -} - -int _extractTimestamp(String filename) { - try { - // Extraire le timestamp du nom de fichier - // Format: ville_timestamp.jpg - final parts = filename.split('_'); - if (parts.length >= 2) { - final timestampPart = parts[1].split('.')[0]; // Enlever l'extension - return int.parse(timestampPart); - } - } catch (e) { - } - return 0; // Timestamp par défaut -} \ No newline at end of file