From 8ff9e12fd417422e2c152f8d5b0bd468c25262e5 Mon Sep 17 00:00:00 2001 From: Dayron Date: Mon, 3 Nov 2025 16:40:33 +0100 Subject: [PATCH] feat: Implement activity management feature with Firestore integration - Added AddActivityBottomSheet for adding custom activities to trips. - Created Activity model to represent tourist activities. - Developed ActivityRepository for managing activities in Firestore. - Integrated ActivityPlacesService for searching activities via Google Places API. - Updated ShowTripDetailsContent to navigate to activities page. - Enhanced main.dart to include ActivityBloc and necessary repositories. --- lib/blocs/activity/activity_bloc.dart | 409 ++++++++++ lib/blocs/activity/activity_event.dart | 153 ++++ lib/blocs/activity/activity_state.dart | 240 ++++++ .../activities/activities_page.dart | 742 ++++++++++++++++++ lib/components/activities/activity_card.dart | 374 +++++++++ .../activities/add_activity_bottom_sheet.dart | 402 ++++++++++ .../home/show_trip_details_content.dart | 12 +- lib/main.dart | 19 + lib/models/activity.dart | 231 ++++++ lib/repositories/activity_repository.dart | 263 +++++++ lib/services/activity_places_service.dart | 341 ++++++++ 11 files changed, 3185 insertions(+), 1 deletion(-) create mode 100644 lib/blocs/activity/activity_bloc.dart create mode 100644 lib/blocs/activity/activity_event.dart create mode 100644 lib/blocs/activity/activity_state.dart create mode 100644 lib/components/activities/activities_page.dart create mode 100644 lib/components/activities/activity_card.dart create mode 100644 lib/components/activities/add_activity_bottom_sheet.dart create mode 100644 lib/models/activity.dart create mode 100644 lib/repositories/activity_repository.dart create mode 100644 lib/services/activity_places_service.dart diff --git a/lib/blocs/activity/activity_bloc.dart b/lib/blocs/activity/activity_bloc.dart new file mode 100644 index 0000000..60770fe --- /dev/null +++ b/lib/blocs/activity/activity_bloc.dart @@ -0,0 +1,409 @@ +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 'activity_event.dart'; +import 'activity_state.dart'; + +/// BLoC for managing activity-related state and operations +class ActivityBloc extends Bloc { + final ActivityRepository _repository; + final ActivityPlacesService _placesService; + final ErrorService _errorService; + + ActivityBloc({ + required ActivityRepository repository, + required ActivityPlacesService placesService, + required ErrorService errorService, + }) : _repository = repository, + _placesService = placesService, + _errorService = errorService, + super(const ActivityInitial()) { + + on(_onLoadActivities); + on(_onSearchActivities); + on(_onSearchActivitiesByText); + on(_onAddActivity); + on(_onAddActivitiesBatch); + on(_onVoteForActivity); + on(_onDeleteActivity); + on(_onFilterActivities); + on(_onRefreshActivities); + on(_onClearSearchResults); + on(_onUpdateActivity); + on(_onToggleActivityFavorite); + } + + /// Handles loading activities for a trip + Future _onLoadActivities( + LoadActivities event, + Emitter emit, + ) async { + try { + emit(const ActivityLoading()); + + final activities = await _repository.getActivitiesByTrip(event.tripId); + + emit(ActivityLoaded( + activities: activities, + filteredActivities: activities, + )); + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur chargement activités: $e'); + emit(const ActivityError('Impossible de charger les activités')); + } + } + + /// Handles searching activities using Google Places API + Future _onSearchActivities( + SearchActivities event, + Emitter emit, + ) async { + try { + emit(const ActivitySearching()); + + final searchResults = await _placesService.searchActivities( + destination: event.destination, + tripId: event.tripId, + category: event.category, + ); + + emit(ActivitySearchResults( + searchResults: searchResults, + query: event.category?.displayName ?? 'Toutes les activités', + )); + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur recherche activités: $e'); + emit(const ActivityError('Impossible de rechercher les activités')); + } + } + + /// Handles text-based activity search + Future _onSearchActivitiesByText( + SearchActivitiesByText event, + Emitter emit, + ) async { + try { + emit(const ActivitySearching()); + + final searchResults = await _placesService.searchActivitiesByText( + query: event.query, + destination: event.destination, + tripId: event.tripId, + ); + + emit(ActivitySearchResults( + searchResults: searchResults, + query: event.query, + )); + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur recherche textuelle: $e'); + emit(const ActivityError('Impossible de rechercher les activités')); + } + } + + /// Handles adding a single activity + Future _onAddActivity( + AddActivity event, + Emitter emit, + ) async { + try { + // Check if activity already exists + if (event.activity.placeId != null) { + final existing = await _repository.findExistingActivity( + event.activity.tripId, + event.activity.placeId!, + ); + + if (existing != null) { + emit(const ActivityError('Cette activité a déjà été ajoutée')); + return; + } + } + + final activityId = await _repository.addActivity(event.activity); + + if (activityId != null) { + emit(ActivityAdded( + activity: event.activity.copyWith(id: activityId), + message: 'Activité ajoutée avec succès', + )); + // Reload activities + add(LoadActivities(event.activity.tripId)); + } else { + emit(const ActivityError('Impossible d\'ajouter l\'activité')); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur ajout activité: $e'); + emit(const ActivityError('Impossible d\'ajouter l\'activité')); + } + } + + /// Handles adding multiple activities in batch + Future _onAddActivitiesBatch( + AddActivitiesBatch event, + Emitter emit, + ) async { + try { + // Filter out existing activities + final filteredActivities = []; + + emit(ActivityBatchAdding( + activitiesToAdd: event.activities, + progress: 0, + total: event.activities.length, + )); + + for (int i = 0; i < event.activities.length; i++) { + final activity = event.activities[i]; + + if (activity.placeId != null) { + final existing = await _repository.findExistingActivity( + activity.tripId, + activity.placeId!, + ); + + if (existing == null) { + filteredActivities.add(activity); + } + } else { + filteredActivities.add(activity); + } + + // Update progress + emit(ActivityBatchAdding( + activitiesToAdd: event.activities, + progress: i + 1, + total: event.activities.length, + )); + } + + if (filteredActivities.isEmpty) { + emit(const ActivityError('Toutes les activités ont déjà été ajoutées')); + return; + } + + final addedIds = await _repository.addActivitiesBatch(filteredActivities); + + if (addedIds.isNotEmpty) { + emit(ActivityOperationSuccess( + '${addedIds.length} activité(s) ajoutée(s) avec succès', + operationType: 'batch_add', + )); + // Reload activities + add(LoadActivities(event.activities.first.tripId)); + } else { + emit(const ActivityError('Impossible d\'ajouter les activités')); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur ajout en lot: $e'); + emit(const ActivityError('Impossible d\'ajouter les activités')); + } + } + + /// Handles voting for an activity + Future _onVoteForActivity( + VoteForActivity event, + Emitter emit, + ) async { + try { + // Show voting state + if (state is ActivityLoaded) { + final currentState = state as ActivityLoaded; + emit(ActivityVoting( + activityId: event.activityId, + activities: currentState.activities, + )); + } + + final success = await _repository.voteForActivity( + event.activityId, + event.userId, + event.vote, + ); + + if (success) { + emit(ActivityVoteRecorded( + activityId: event.activityId, + vote: event.vote, + userId: event.userId, + )); + + // Reload activities to reflect the new vote + if (state is ActivityLoaded) { + final currentState = state as ActivityLoaded; + final activities = await _repository.getActivitiesByTrip( + currentState.activities.first.tripId, + ); + + emit(currentState.copyWith( + activities: activities, + filteredActivities: _applyFilters( + activities, + currentState.activeFilter, + currentState.minRating, + currentState.showVotedOnly, + event.userId, + ), + )); + } + } else { + emit(const ActivityError('Impossible d\'enregistrer le vote')); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur vote: $e'); + emit(const ActivityError('Impossible d\'enregistrer le vote')); + } + } + + /// Handles deleting an activity + Future _onDeleteActivity( + DeleteActivity event, + Emitter emit, + ) async { + try { + final success = await _repository.deleteActivity(event.activityId); + + if (success) { + emit(ActivityDeleted( + activityId: event.activityId, + message: 'Activité supprimée avec succès', + )); + + // Reload if we're on the activity list + if (state is ActivityLoaded) { + final currentState = state as ActivityLoaded; + if (currentState.activities.isNotEmpty) { + add(LoadActivities(currentState.activities.first.tripId)); + } + } + } else { + emit(const ActivityError('Impossible de supprimer l\'activité')); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur suppression: $e'); + emit(const ActivityError('Impossible de supprimer l\'activité')); + } + } + + /// Handles filtering activities + Future _onFilterActivities( + FilterActivities event, + Emitter emit, + ) async { + if (state is ActivityLoaded) { + final currentState = state as ActivityLoaded; + + final filteredActivities = _applyFilters( + currentState.activities, + event.category, + event.minRating, + event.showVotedOnly ?? false, + '', // UserId would be needed for showVotedOnly filter + ); + + emit(currentState.copyWith( + filteredActivities: filteredActivities, + activeFilter: event.category, + minRating: event.minRating, + showVotedOnly: event.showVotedOnly ?? false, + )); + } + } + + /// Handles refreshing activities + Future _onRefreshActivities( + RefreshActivities event, + Emitter emit, + ) async { + add(LoadActivities(event.tripId)); + } + + /// Handles clearing search results + Future _onClearSearchResults( + ClearSearchResults event, + Emitter emit, + ) async { + if (state is ActivitySearchResults) { + emit(const ActivityInitial()); + } + } + + /// Handles updating an activity + Future _onUpdateActivity( + UpdateActivity event, + Emitter emit, + ) async { + try { + if (state is ActivityLoaded) { + final currentState = state as ActivityLoaded; + emit(ActivityUpdating( + activityId: event.activity.id, + activities: currentState.activities, + )); + } + + final success = await _repository.updateActivity(event.activity); + + if (success) { + emit(const ActivityOperationSuccess( + 'Activité mise à jour avec succès', + operationType: 'update', + )); + + // Reload activities + add(LoadActivities(event.activity.tripId)); + } else { + emit(const ActivityError('Impossible de mettre à jour l\'activité')); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur mise à jour: $e'); + emit(const ActivityError('Impossible de mettre à jour l\'activité')); + } + } + + /// Handles toggling activity favorite status + Future _onToggleActivityFavorite( + ToggleActivityFavorite event, + Emitter emit, + ) async { + try { + // This would require extending the Activity model to include favorites + // For now, we'll use the voting system as a favorite system + add(VoteForActivity( + activityId: event.activityId, + userId: event.userId, + vote: 1, + )); + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur favori: $e'); + emit(const ActivityError('Impossible de modifier les favoris')); + } + } + + /// Applies filters to the activities list + List _applyFilters( + List activities, + String? category, + double? minRating, + bool showVotedOnly, + String userId, + ) { + var filtered = activities; + + if (category != null) { + filtered = filtered.where((a) => a.category == category).toList(); + } + + if (minRating != null) { + filtered = filtered.where((a) => (a.rating ?? 0) >= minRating).toList(); + } + + if (showVotedOnly && userId.isNotEmpty) { + filtered = filtered.where((a) => a.hasUserVoted(userId)).toList(); + } + + return filtered; + } +} diff --git a/lib/blocs/activity/activity_event.dart b/lib/blocs/activity/activity_event.dart new file mode 100644 index 0000000..67664de --- /dev/null +++ b/lib/blocs/activity/activity_event.dart @@ -0,0 +1,153 @@ +import 'package:equatable/equatable.dart'; +import '../../models/activity.dart'; + +/// Base class for all activity-related events +abstract class ActivityEvent extends Equatable { + const ActivityEvent(); + + @override + List get props => []; +} + +/// Event to load activities for a specific trip +class LoadActivities extends ActivityEvent { + final String tripId; + + const LoadActivities(this.tripId); + + @override + List get props => [tripId]; +} + +/// Event to search activities using Google Places API +class SearchActivities extends ActivityEvent { + final String tripId; + final String destination; + final ActivityCategory? category; + + const SearchActivities({ + required this.tripId, + required this.destination, + this.category, + }); + + @override + List get props => [tripId, destination, category]; +} + +/// Event to search activities by text query +class SearchActivitiesByText extends ActivityEvent { + final String tripId; + final String destination; + final String query; + + const SearchActivitiesByText({ + required this.tripId, + required this.destination, + required this.query, + }); + + @override + List get props => [tripId, destination, query]; +} + +/// Event to add a single activity to the trip +class AddActivity extends ActivityEvent { + final Activity activity; + + const AddActivity(this.activity); + + @override + List get props => [activity]; +} + +/// Event to add multiple activities at once +class AddActivitiesBatch extends ActivityEvent { + final List activities; + + const AddActivitiesBatch(this.activities); + + @override + List get props => [activities]; +} + +/// Event to vote for an activity +class VoteForActivity extends ActivityEvent { + final String activityId; + final String userId; + final int vote; // 1 for positive, -1 for negative, 0 to remove vote + + const VoteForActivity({ + required this.activityId, + required this.userId, + required this.vote, + }); + + @override + List get props => [activityId, userId, vote]; +} + +/// Event to delete an activity +class DeleteActivity extends ActivityEvent { + final String activityId; + + const DeleteActivity(this.activityId); + + @override + List get props => [activityId]; +} + +/// Event to filter activities +class FilterActivities extends ActivityEvent { + final String? category; + final double? minRating; + final bool? showVotedOnly; + + const FilterActivities({ + this.category, + this.minRating, + this.showVotedOnly, + }); + + @override + List get props => [category, minRating, showVotedOnly]; +} + +/// Event to refresh activities +class RefreshActivities extends ActivityEvent { + final String tripId; + + const RefreshActivities(this.tripId); + + @override + List get props => [tripId]; +} + +/// Event to clear search results +class ClearSearchResults extends ActivityEvent { + const ClearSearchResults(); +} + +/// Event to update activity details +class UpdateActivity extends ActivityEvent { + final Activity activity; + + const UpdateActivity(this.activity); + + @override + List get props => [activity]; +} + +/// Event to toggle favorite status +class ToggleActivityFavorite extends ActivityEvent { + final String activityId; + final String userId; + + const ToggleActivityFavorite({ + required this.activityId, + required this.userId, + }); + + @override + List get props => [activityId, userId]; +} \ No newline at end of file diff --git a/lib/blocs/activity/activity_state.dart b/lib/blocs/activity/activity_state.dart new file mode 100644 index 0000000..395191b --- /dev/null +++ b/lib/blocs/activity/activity_state.dart @@ -0,0 +1,240 @@ +import 'package:equatable/equatable.dart'; +import '../../models/activity.dart'; + +/// Base class for all activity-related states +abstract class ActivityState extends Equatable { + const ActivityState(); + + @override + List get props => []; +} + +/// Initial state when no activities have been loaded +class ActivityInitial extends ActivityState { + const ActivityInitial(); +} + +/// State when activities are being loaded +class ActivityLoading extends ActivityState { + const ActivityLoading(); +} + +/// State when activities are being searched +class ActivitySearching extends ActivityState { + const ActivitySearching(); +} + +/// State when activities have been loaded successfully +class ActivityLoaded extends ActivityState { + final List activities; + final List filteredActivities; + final String? activeFilter; + final double? minRating; + final bool showVotedOnly; + + const ActivityLoaded({ + required this.activities, + required this.filteredActivities, + this.activeFilter, + this.minRating, + this.showVotedOnly = false, + }); + + @override + List get props => [ + activities, + filteredActivities, + activeFilter, + minRating, + showVotedOnly, + ]; + + /// Creates a copy of the current state with optional modifications + ActivityLoaded copyWith({ + List? activities, + List? filteredActivities, + String? activeFilter, + double? minRating, + bool? showVotedOnly, + }) { + return ActivityLoaded( + activities: activities ?? this.activities, + filteredActivities: filteredActivities ?? this.filteredActivities, + activeFilter: activeFilter ?? this.activeFilter, + minRating: minRating ?? this.minRating, + showVotedOnly: showVotedOnly ?? this.showVotedOnly, + ); + } + + /// Gets activities by category + List getActivitiesByCategory(String category) { + return activities.where((activity) => activity.category == category).toList(); + } + + /// Gets top rated activities + List getTopRatedActivities({int limit = 10}) { + final sorted = List.from(activities); + sorted.sort((a, b) { + final aScore = a.totalVotes; + final bScore = b.totalVotes; + + if (aScore != bScore) { + return bScore.compareTo(aScore); + } + + return (b.rating ?? 0).compareTo(a.rating ?? 0); + }); + + return sorted.take(limit).toList(); + } +} + +/// State when search results are available +class ActivitySearchResults extends ActivityState { + final List searchResults; + final String query; + final bool isLoading; + + const ActivitySearchResults({ + required this.searchResults, + required this.query, + this.isLoading = false, + }); + + @override + List get props => [searchResults, query, isLoading]; + + /// Creates a copy with optional modifications + ActivitySearchResults copyWith({ + List? searchResults, + String? query, + bool? isLoading, + }) { + return ActivitySearchResults( + searchResults: searchResults ?? this.searchResults, + query: query ?? this.query, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +/// State when an operation has completed successfully +class ActivityOperationSuccess extends ActivityState { + final String message; + final String? operationType; + + const ActivityOperationSuccess( + this.message, { + this.operationType, + }); + + @override + List get props => [message, operationType]; +} + +/// State when an error has occurred +class ActivityError extends ActivityState { + final String message; + final String? errorCode; + final dynamic error; + + const ActivityError( + this.message, { + this.errorCode, + this.error, + }); + + @override + List get props => [message, errorCode, error]; +} + +/// State when voting is in progress +class ActivityVoting extends ActivityState { + final String activityId; + final List activities; + + const ActivityVoting({ + required this.activityId, + required this.activities, + }); + + @override + List get props => [activityId, activities]; +} + +/// State when activity is being updated +class ActivityUpdating extends ActivityState { + final String activityId; + final List activities; + + const ActivityUpdating({ + required this.activityId, + required this.activities, + }); + + @override + List get props => [activityId, activities]; +} + +/// State when activities are being added in batch +class ActivityBatchAdding extends ActivityState { + final List activitiesToAdd; + final int progress; + final int total; + + const ActivityBatchAdding({ + required this.activitiesToAdd, + required this.progress, + required this.total, + }); + + @override + List get props => [activitiesToAdd, progress, total]; + + /// Gets the progress as a percentage + double get progressPercentage => total > 0 ? progress / total : 0.0; +} + +/// State when an activity has been successfully added +class ActivityAdded extends ActivityState { + final Activity activity; + final String message; + + const ActivityAdded({ + required this.activity, + required this.message, + }); + + @override + List get props => [activity, message]; +} + +/// State when an activity has been successfully deleted +class ActivityDeleted extends ActivityState { + final String activityId; + final String message; + + const ActivityDeleted({ + required this.activityId, + required this.message, + }); + + @override + List get props => [activityId, message]; +} + +/// State when vote has been successfully recorded +class ActivityVoteRecorded extends ActivityState { + final String activityId; + final int vote; + final String userId; + + const ActivityVoteRecorded({ + required this.activityId, + required this.vote, + required this.userId, + }); + + @override + List get props => [activityId, vote, userId]; +} \ No newline at end of file diff --git a/lib/components/activities/activities_page.dart b/lib/components/activities/activities_page.dart new file mode 100644 index 0000000..8fcc760 --- /dev/null +++ b/lib/components/activities/activities_page.dart @@ -0,0 +1,742 @@ +import 'package:flutter/material.dart'; +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'; + +/// Page principale des activités pour un voyage +class ActivitiesPage extends StatefulWidget { + final Trip trip; + + const ActivitiesPage({ + Key? key, + required this.trip, + }) : super(key: key); + + @override + State createState() => _ActivitiesPageState(); +} + +class _ActivitiesPageState extends State + with TickerProviderStateMixin { + late TabController _tabController; + final ErrorService _errorService = ErrorService(); + String? _selectedCategory; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + + // Charger les activités + if (widget.trip.id != null) { + context.read().add(LoadActivities(widget.trip.id!)); + } + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + 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); + } + }, + 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, + ), + ), + ), + ), + + // 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'), + ], + ), + ), + ), + + // Contenu des onglets + SliverFillRemaining( + child: TabBarView( + controller: _tabController, + children: [ + _buildSuggestionsTab(), + _buildVotedActivitiesTab(), + ], + ), + ), + ], + ), + ), + + // Bouton flottant pour ajouter une activité + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddActivitySheet(), + backgroundColor: Colors.blue, + child: const Icon(Icons.add, color: Colors.white), + ), + ); + } + + 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), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: isActive + ? Colors.white + : theme.colorScheme.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: isActive + ? Colors.white + : theme.colorScheme.onSurface.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + 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'), + ), + ], + ], + ), + ); + } + + 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, + ), + ); + } + + void _showCategoryFilter() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => _buildCategoryFilterSheet(), + ); + } + + void _showPriceFilter() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => _buildPriceFilterSheet(), + ); + } + + Widget _buildCategoryFilterSheet() { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16), + ), + 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), + ), + ), + 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), + ], + ), + ), + ); + } + + Widget _buildPriceFilterSheet() { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16), + ), + 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), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Filtrer par prix', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...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), + ], + ), + ), + ); + } + + IconData _getCategoryIcon(ActivityCategory category) { + switch (category) { + case ActivityCategory.museum: + return Icons.museum; + case ActivityCategory.restaurant: + return Icons.restaurant; + case ActivityCategory.attraction: + return Icons.place; + case ActivityCategory.entertainment: + return Icons.sports_esports; + case ActivityCategory.shopping: + return Icons.shopping_bag; + case ActivityCategory.nature: + return Icons.nature; + case ActivityCategory.culture: + return Icons.palette; + case ActivityCategory.nightlife: + return Icons.nightlife; + case ActivityCategory.sports: + return Icons.sports; + case ActivityCategory.relaxation: + return Icons.spa; + } + } +} \ No newline at end of file diff --git a/lib/components/activities/activity_card.dart b/lib/components/activities/activity_card.dart new file mode 100644 index 0000000..9ba733f --- /dev/null +++ b/lib/components/activities/activity_card.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import '../../models/activity.dart'; + +/// Widget représentant une carte d'activité avec système de vote +class ActivityCard extends StatelessWidget { + final Activity activity; + final String currentUserId; + final Function(int) onVote; + final VoidCallback? onAddToTrip; + + const ActivityCard({ + Key? 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); + + return Container( + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image de l'activité + if (activity.imageUrl != null) ...[ + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: Stack( + children: [ + Image.network( + activity.imageUrl!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Icon( + Icons.image_not_supported, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + }, + ), + + // Badge catégorie + if (activity.category.isNotEmpty) + Positioned( + top: 12, + right: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getCategoryColor(activity.category), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + activity.category, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ], + + // Contenu de la carte + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre et rating + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + activity.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (activity.rating != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, size: 14, color: Colors.amber), + const SizedBox(width: 2), + Text( + activity.rating!.toStringAsFixed(1), + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: Colors.amber[800], + ), + ), + ], + ), + ), + ], + ], + ), + + const SizedBox(height: 8), + + // Description + Text( + activity.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 12), + + // Informations supplémentaires + if (activity.priceLevel != null || 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), + ), + ), + 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), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Vote positif + _buildVoteButton( + icon: Icons.thumb_up, + count: activity.positiveVotes, + isActive: userVote == 1, + onTap: () => onVote(userVote == 1 ? 0 : 1), + activeColor: Colors.blue, + ), + + Container( + width: 1, + height: 24, + color: theme.colorScheme.onSurface.withOpacity(0.2), + ), + + // Vote négatif + _buildVoteButton( + icon: Icons.thumb_down, + count: activity.negativeVotes, + isActive: userVote == -1, + onTap: () => onVote(userVote == -1 ? 0 : -1), + activeColor: Colors.red, + ), + ], + ), + ), + + 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 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getScoreColor(activity.totalVotes).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: _getScoreColor(activity.totalVotes), + ), + ), + const SizedBox(width: 4), + Text( + 'vote${activity.votes.length > 1 ? 's' : ''}', + style: theme.textTheme.bodySmall?.copyWith( + color: _getScoreColor(activity.totalVotes), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildVoteButton({ + required IconData icon, + required int count, + required bool isActive, + required VoidCallback onTap, + required Color activeColor, + }) { + 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, + ), + ), + ], + ], + ), + ), + ); + } + + 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 new file mode 100644 index 0000000..facbf25 --- /dev/null +++ b/lib/components/activities/add_activity_bottom_sheet.dart @@ -0,0 +1,402 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../blocs/activity/activity_bloc.dart'; +import '../../blocs/activity/activity_event.dart'; +import '../../models/activity.dart'; +import '../../models/trip.dart'; +import '../../services/error_service.dart'; + +/// Bottom sheet pour ajouter une activité personnalisée +class AddActivityBottomSheet extends StatefulWidget { + final Trip trip; + + const AddActivityBottomSheet({ + Key? key, + required this.trip, + }) : super(key: key); + + @override + State createState() => _AddActivityBottomSheetState(); +} + +class _AddActivityBottomSheetState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _addressController = TextEditingController(); + final ErrorService _errorService = ErrorService(); + + ActivityCategory _selectedCategory = ActivityCategory.attraction; + bool _isLoading = false; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _addressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + return Container( + height: mediaQuery.size.height * 0.85, + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + 'Ajouter une activité', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + ), + + const Divider(), + + // Formulaire + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Nom de l'activité + _buildSectionTitle('Nom de l\'activité'), + const SizedBox(height: 8), + _buildTextField( + controller: _nameController, + hintText: 'Ex: Visite du Louvre', + icon: Icons.event, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Nom requis'; + } + return null; + }, + ), + + const SizedBox(height: 20), + + // Description + _buildSectionTitle('Description'), + const SizedBox(height: 8), + _buildTextField( + controller: _descriptionController, + hintText: 'Décrivez cette activité...', + icon: Icons.description, + maxLines: 3, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Description requise'; + } + return null; + }, + ), + + const SizedBox(height: 20), + + // Catégorie + _buildSectionTitle('Catégorie'), + const SizedBox(height: 8), + _buildCategorySelector(), + + const SizedBox(height: 20), + + // Adresse (optionnel) + _buildSectionTitle('Adresse (optionnel)'), + const SizedBox(height: 8), + _buildTextField( + controller: _addressController, + hintText: 'Adresse ou lieu', + icon: Icons.location_on, + ), + + const SizedBox(height: 40), + + // Boutons d'action + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: theme.colorScheme.outline), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Annuler'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _addActivity, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Ajouter'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String hintText, + required IconData icon, + int maxLines = 1, + String? Function(String?)? validator, + }) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + + return TextFormField( + controller: controller, + maxLines: maxLines, + validator: validator, + decoration: InputDecoration( + hintText: hintText, + prefixIcon: Icon(icon), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: isDarkMode + ? Colors.white.withOpacity(0.2) + : Colors.black.withOpacity(0.2), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: isDarkMode + ? Colors.white.withOpacity(0.2) + : Colors.black.withOpacity(0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + filled: true, + fillColor: theme.colorScheme.surface, + ), + ); + } + + Widget _buildCategorySelector() { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sélectionnez une catégorie', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: ActivityCategory.values.map((category) { + final isSelected = _selectedCategory == category; + + return GestureDetector( + onTap: () { + setState(() { + _selectedCategory = category; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Colors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? Colors.blue : theme.colorScheme.outline, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getCategoryIcon(category), + size: 16, + color: isSelected + ? Colors.white + : theme.colorScheme.onSurface, + ), + const SizedBox(width: 6), + Text( + category.displayName, + style: theme.textTheme.bodySmall?.copyWith( + color: isSelected + ? Colors.white + : theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + void _addActivity() async { + if (!_formKey.currentState!.validate()) { + return; + } + + if (widget.trip.id == null) { + _errorService.showSnackbar( + message: 'Erreur: ID du voyage manquant', + isError: true, + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final activity = Activity( + id: '', // Sera généré par Firestore + tripId: widget.trip.id!, + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + category: _selectedCategory.displayName, + address: _addressController.text.trim().isNotEmpty + ? _addressController.text.trim() + : null, + votes: {}, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + context.read().add(AddActivity(activity)); + + // Fermer le bottom sheet + Navigator.pop(context); + + } catch (e) { + _errorService.showSnackbar( + message: 'Erreur lors de l\'ajout de l\'activité', + isError: true, + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + IconData _getCategoryIcon(ActivityCategory category) { + switch (category) { + case ActivityCategory.museum: + return Icons.museum; + case ActivityCategory.restaurant: + return Icons.restaurant; + case ActivityCategory.attraction: + return Icons.place; + case ActivityCategory.entertainment: + return Icons.sports_esports; + case ActivityCategory.shopping: + return Icons.shopping_bag; + case ActivityCategory.nature: + return Icons.nature; + case ActivityCategory.culture: + return Icons.palette; + case ActivityCategory.nightlife: + return Icons.nightlife; + case ActivityCategory.sports: + return Icons.sports; + case ActivityCategory.relaxation: + return Icons.spa; + } + } +} \ No newline at end of file diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 427f892..2b79a32 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -6,6 +6,7 @@ import 'package:travel_mate/components/home/create_trip_content.dart'; import 'package:travel_mate/models/trip.dart'; import 'package:travel_mate/components/map/map_content.dart'; import 'package:travel_mate/services/error_service.dart'; +import 'package:travel_mate/components/activities/activities_page.dart'; import 'package:url_launcher/url_launcher.dart'; class ShowTripDetailsContent extends StatefulWidget { @@ -402,7 +403,7 @@ class _ShowTripDetailsContentState extends State { icon: Icons.local_activity, title: 'Activités', color: Colors.green, - onTap: () => _showComingSoon('Activités'), + onTap: () => _navigateToActivities(), ), _buildActionButton( icon: Icons.account_balance_wallet, @@ -624,4 +625,13 @@ class _ShowTripDetailsContentState extends State { ), ); } + + void _navigateToActivities() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ActivitiesPage(trip: widget.trip), + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 6c49a61..17d09e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,9 +4,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:travel_mate/blocs/balance/balance_bloc.dart'; import 'package:travel_mate/blocs/expense/expense_bloc.dart'; import 'package:travel_mate/blocs/message/message_bloc.dart'; +import 'package:travel_mate/blocs/activity/activity_bloc.dart'; import 'package:travel_mate/firebase_options.dart'; import 'package:travel_mate/services/balance_service.dart'; import 'package:travel_mate/services/error_service.dart'; +import 'package:travel_mate/services/activity_places_service.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:travel_mate/services/expense_service.dart'; import 'blocs/auth/auth_bloc.dart'; @@ -26,6 +28,7 @@ import 'repositories/message_repository.dart'; import 'repositories/account_repository.dart'; import 'repositories/expense_repository.dart'; import 'repositories/balance_repository.dart'; +import 'repositories/activity_repository.dart'; import 'pages/login.dart'; import 'pages/home.dart'; import 'pages/signup.dart'; @@ -99,6 +102,14 @@ class MyApp extends StatelessWidget { expenseRepository: context.read(), ), ), + // Activity repository for managing trip activities + RepositoryProvider( + create: (context) => ActivityRepository(), + ), + // Activity places service for Google Places API integration + RepositoryProvider( + create: (context) => ActivityPlacesService(), + ), // Balance service for business logic related to balances RepositoryProvider( create: (context) => BalanceService( @@ -151,6 +162,14 @@ class MyApp extends StatelessWidget { expenseRepository: context.read(), ), ), + // Activity BLoC for managing trip activities + BlocProvider( + create: (context) => ActivityBloc( + repository: context.read(), + placesService: context.read(), + errorService: ErrorService(), + ), + ), ], child: BlocBuilder( builder: (context, themeState) { diff --git a/lib/models/activity.dart b/lib/models/activity.dart new file mode 100644 index 0000000..e60f9e8 --- /dev/null +++ b/lib/models/activity.dart @@ -0,0 +1,231 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +/// Modèle représentant une activité touristique liée à un voyage +class Activity { + final String id; + final String tripId; + final String name; + final String description; + final String category; + final String? imageUrl; + final double? rating; + final String? priceLevel; + final String? address; + final double? latitude; + final double? longitude; + final String? placeId; // Google Places ID + final String? website; + final String? phoneNumber; + final List openingHours; + final Map votes; // userId -> vote (1 pour pour, -1 pour contre) + final DateTime createdAt; + final DateTime updatedAt; + + Activity({ + required this.id, + required this.tripId, + required this.name, + required this.description, + required this.category, + this.imageUrl, + this.rating, + this.priceLevel, + this.address, + this.latitude, + this.longitude, + this.placeId, + this.website, + this.phoneNumber, + this.openingHours = const [], + this.votes = const {}, + required this.createdAt, + required this.updatedAt, + }); + + /// Calcule le score total des votes + int get totalVotes { + return votes.values.fold(0, (sum, vote) => sum + vote); + } + + /// Calcule le nombre de votes positifs + int get positiveVotes { + return votes.values.where((vote) => vote > 0).length; + } + + /// Calcule le nombre de votes négatifs + int get negativeVotes { + return votes.values.where((vote) => vote < 0).length; + } + + /// Vérifie si l'utilisateur a voté + bool hasUserVoted(String userId) { + return votes.containsKey(userId); + } + + /// Récupère le vote d'un utilisateur (-1, 0, 1) + int getUserVote(String userId) { + return votes[userId] ?? 0; + } + + /// Crée une copie avec des modifications + Activity copyWith({ + String? id, + String? tripId, + String? name, + String? description, + String? category, + String? imageUrl, + double? rating, + String? priceLevel, + String? address, + double? latitude, + double? longitude, + String? placeId, + String? website, + String? phoneNumber, + List? openingHours, + Map? votes, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Activity( + id: id ?? this.id, + tripId: tripId ?? this.tripId, + name: name ?? this.name, + description: description ?? this.description, + category: category ?? this.category, + imageUrl: imageUrl ?? this.imageUrl, + rating: rating ?? this.rating, + priceLevel: priceLevel ?? this.priceLevel, + address: address ?? this.address, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + placeId: placeId ?? this.placeId, + website: website ?? this.website, + phoneNumber: phoneNumber ?? this.phoneNumber, + openingHours: openingHours ?? this.openingHours, + votes: votes ?? this.votes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// Conversion vers Map pour Firestore + Map toMap() { + return { + 'id': id, + 'tripId': tripId, + 'name': name, + 'description': description, + 'category': category, + 'imageUrl': imageUrl, + 'rating': rating, + 'priceLevel': priceLevel, + 'address': address, + 'latitude': latitude, + 'longitude': longitude, + 'placeId': placeId, + 'website': website, + 'phoneNumber': phoneNumber, + 'openingHours': openingHours, + 'votes': votes, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + }; + } + + /// Création depuis Map Firestore + factory Activity.fromMap(Map map) { + return Activity( + id: map['id'] ?? '', + tripId: map['tripId'] ?? '', + name: map['name'] ?? '', + description: map['description'] ?? '', + category: map['category'] ?? '', + imageUrl: map['imageUrl'], + rating: map['rating']?.toDouble(), + priceLevel: map['priceLevel'], + address: map['address'], + latitude: map['latitude']?.toDouble(), + longitude: map['longitude']?.toDouble(), + placeId: map['placeId'], + website: map['website'], + phoneNumber: map['phoneNumber'], + openingHours: List.from(map['openingHours'] ?? []), + votes: Map.from(map['votes'] ?? {}), + createdAt: (map['createdAt'] as Timestamp).toDate(), + updatedAt: (map['updatedAt'] as Timestamp).toDate(), + ); + } + + /// Création depuis snapshot Firestore + factory Activity.fromSnapshot(DocumentSnapshot snapshot) { + final data = snapshot.data() as Map; + return Activity.fromMap({...data, 'id': snapshot.id}); + } + + @override + String toString() { + return 'Activity(id: $id, name: $name, category: $category, votes: ${totalVotes})'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Activity && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +/// Énumération des catégories d'activités +enum ActivityCategory { + museum('Musée', 'museum'), + restaurant('Restaurant', 'restaurant'), + attraction('Attraction', 'tourist_attraction'), + entertainment('Divertissement', 'amusement_park'), + shopping('Shopping', 'shopping_mall'), + nature('Nature', 'park'), + culture('Culture', 'establishment'), + nightlife('Vie nocturne', 'night_club'), + sports('Sports', 'gym'), + relaxation('Détente', 'spa'); + + const ActivityCategory(this.displayName, this.googlePlaceType); + + final String displayName; + final String googlePlaceType; + + static ActivityCategory? fromGoogleType(String type) { + for (final category in ActivityCategory.values) { + if (category.googlePlaceType == type) { + return category; + } + } + return null; + } +} + +/// Énumération des niveaux de prix +enum PriceLevel { + free('Gratuit', 0), + inexpensive('Bon marché', 1), + moderate('Modéré', 2), + expensive('Cher', 3), + veryExpensive('Très cher', 4); + + const PriceLevel(this.displayName, this.level); + + final String displayName; + final int level; + + static PriceLevel? fromLevel(int level) { + for (final price in PriceLevel.values) { + if (price.level == level) { + return price; + } + } + return null; + } +} \ No newline at end of file diff --git a/lib/repositories/activity_repository.dart b/lib/repositories/activity_repository.dart new file mode 100644 index 0000000..5cbe54f --- /dev/null +++ b/lib/repositories/activity_repository.dart @@ -0,0 +1,263 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../models/activity.dart'; +import '../services/error_service.dart'; + +/// Repository pour la gestion des activités dans Firestore +class ActivityRepository { + static final ActivityRepository _instance = ActivityRepository._internal(); + factory ActivityRepository() => _instance; + ActivityRepository._internal(); + + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final ErrorService _errorService = ErrorService(); + + static const String _collection = 'activities'; + + /// Ajoute une nouvelle activité + Future addActivity(Activity activity) async { + try { + print('ActivityRepository: Ajout d\'une activité: ${activity.name}'); + + final docRef = await _firestore.collection(_collection).add(activity.toMap()); + + // Mettre à jour l'activité avec l'ID généré + await docRef.update({'id': docRef.id}); + + print('ActivityRepository: Activité ajoutée avec ID: ${docRef.id}'); + return docRef.id; + } catch (e) { + print('ActivityRepository: Erreur lors de l\'ajout: $e'); + _errorService.logError('activity_repository', 'Erreur ajout activité: $e'); + return null; + } + } + + /// Récupère toutes les activités d'un voyage + Future> getActivitiesByTrip(String tripId) async { + try { + print('ActivityRepository: Récupération des activités pour le voyage: $tripId'); + + 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(); + + print('ActivityRepository: ${activities.length} activités trouvées'); + return activities; + } catch (e) { + print('ActivityRepository: Erreur lors de la récupération: $e'); + _errorService.logError('activity_repository', 'Erreur récupération activités: $e'); + return []; + } + } + + /// Récupère une activité par son ID + Future getActivityById(String activityId) async { + try { + final doc = await _firestore.collection(_collection).doc(activityId).get(); + + if (doc.exists) { + return Activity.fromSnapshot(doc); + } + + return null; + } catch (e) { + print('ActivityRepository: Erreur récupération activité: $e'); + _errorService.logError('activity_repository', 'Erreur récupération activité: $e'); + return null; + } + } + + /// Met à jour une activité + Future updateActivity(Activity activity) async { + try { + print('ActivityRepository: Mise à jour de l\'activité: ${activity.id}'); + + await _firestore + .collection(_collection) + .doc(activity.id) + .update(activity.copyWith(updatedAt: DateTime.now()).toMap()); + + print('ActivityRepository: Activité mise à jour avec succès'); + return true; + } catch (e) { + print('ActivityRepository: Erreur lors de la mise à jour: $e'); + _errorService.logError('activity_repository', 'Erreur mise à jour activité: $e'); + return false; + } + } + + /// Supprime une activité + Future deleteActivity(String activityId) async { + try { + print('ActivityRepository: Suppression de l\'activité: $activityId'); + + await _firestore.collection(_collection).doc(activityId).delete(); + + print('ActivityRepository: Activité supprimée avec succès'); + return true; + } catch (e) { + print('ActivityRepository: Erreur lors de la suppression: $e'); + _errorService.logError('activity_repository', 'Erreur suppression activité: $e'); + return false; + } + } + + /// Vote pour une activité + Future voteForActivity(String activityId, String userId, int vote) async { + try { + print('ActivityRepository: Vote pour l\'activité $activityId: $vote'); + + // vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote + final activityRef = _firestore.collection(_collection).doc(activityId); + + await _firestore.runTransaction((transaction) async { + final snapshot = await transaction.get(activityRef); + + if (!snapshot.exists) { + throw Exception('Activité non trouvée'); + } + + final activity = Activity.fromSnapshot(snapshot); + final newVotes = Map.from(activity.votes); + + if (vote == 0) { + // Supprimer le vote + newVotes.remove(userId); + } else { + // Ajouter ou modifier le vote + newVotes[userId] = vote; + } + + transaction.update(activityRef, { + 'votes': newVotes, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + }); + + print('ActivityRepository: Vote enregistré avec succès'); + return true; + } catch (e) { + print('ActivityRepository: Erreur lors du vote: $e'); + _errorService.logError('activity_repository', 'Erreur vote: $e'); + return false; + } + } + + /// Récupère les activités avec un stream pour les mises à jour en temps réel + Stream> getActivitiesStream(String tripId) { + try { + return _firestore + .collection(_collection) + .where('tripId', isEqualTo: tripId) + .orderBy('updatedAt', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => Activity.fromSnapshot(doc)) + .toList(); + }); + } catch (e) { + print('ActivityRepository: Erreur stream activités: $e'); + _errorService.logError('activity_repository', 'Erreur stream activités: $e'); + return Stream.value([]); + } + } + + /// Ajoute plusieurs activités en lot + Future> addActivitiesBatch(List activities) async { + try { + print('ActivityRepository: Ajout en lot de ${activities.length} activités'); + + final batch = _firestore.batch(); + final addedIds = []; + + for (final activity in activities) { + final docRef = _firestore.collection(_collection).doc(); + final activityWithId = activity.copyWith(id: docRef.id); + batch.set(docRef, activityWithId.toMap()); + addedIds.add(docRef.id); + } + + await batch.commit(); + + print('ActivityRepository: ${addedIds.length} activités ajoutées en lot'); + return addedIds; + } catch (e) { + print('ActivityRepository: Erreur ajout en lot: $e'); + _errorService.logError('activity_repository', 'Erreur ajout en lot: $e'); + return []; + } + } + + /// Recherche des activités par catégorie + Future> getActivitiesByCategory(String tripId, String category) async { + try { + final querySnapshot = await _firestore + .collection(_collection) + .where('tripId', isEqualTo: tripId) + .where('category', isEqualTo: category) + .orderBy('updatedAt', descending: true) + .get(); + + return querySnapshot.docs + .map((doc) => Activity.fromSnapshot(doc)) + .toList(); + } catch (e) { + print('ActivityRepository: Erreur recherche par catégorie: $e'); + _errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e'); + return []; + } + } + + /// Récupère les activités les mieux notées d'un voyage + Future> getTopRatedActivities(String tripId, {int limit = 10}) async { + try { + final activities = await getActivitiesByTrip(tripId); + + // Trier par nombre de votes positifs puis par note Google + activities.sort((a, b) { + final aScore = a.totalVotes; + final bScore = b.totalVotes; + + if (aScore != bScore) { + return bScore.compareTo(aScore); + } + + return (b.rating ?? 0).compareTo(a.rating ?? 0); + }); + + return activities.take(limit).toList(); + } catch (e) { + print('ActivityRepository: Erreur activités top rated: $e'); + _errorService.logError('activity_repository', 'Erreur top rated: $e'); + return []; + } + } + + /// Vérifie si une activité existe déjà pour un voyage (basé sur placeId) + Future findExistingActivity(String tripId, String placeId) async { + try { + final querySnapshot = await _firestore + .collection(_collection) + .where('tripId', isEqualTo: tripId) + .where('placeId', isEqualTo: placeId) + .limit(1) + .get(); + + if (querySnapshot.docs.isNotEmpty) { + return Activity.fromSnapshot(querySnapshot.docs.first); + } + + return null; + } catch (e) { + print('ActivityRepository: Erreur recherche activité existante: $e'); + return null; + } + } +} \ No newline at end of file diff --git a/lib/services/activity_places_service.dart b/lib/services/activity_places_service.dart new file mode 100644 index 0000000..6fa2f83 --- /dev/null +++ b/lib/services/activity_places_service.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../models/activity.dart'; +import '../services/error_service.dart'; + +/// Service pour rechercher des activités touristiques via Google Places API +class ActivityPlacesService { + static final ActivityPlacesService _instance = ActivityPlacesService._internal(); + factory ActivityPlacesService() => _instance; + ActivityPlacesService._internal(); + + final ErrorService _errorService = ErrorService(); + static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? ''; + + /// Recherche des activités près d'une destination + Future> searchActivities({ + required String destination, + required String tripId, + ActivityCategory? category, + int radius = 5000, + }) async { + try { + print('ActivityPlacesService: Recherche d\'activités pour: $destination'); + + // 1. Géocoder la destination + final coordinates = await _geocodeDestination(destination); + if (coordinates == null) { + throw Exception('Impossible de localiser la destination: $destination'); + } + + // 2. Rechercher les activités par catégorie ou toutes les catégories + List allActivities = []; + + if (category != null) { + final activities = await _searchByCategory( + coordinates['lat']!, + coordinates['lng']!, + category, + tripId, + radius, + ); + allActivities.addAll(activities); + } else { + // Rechercher dans toutes les catégories principales + final mainCategories = [ + ActivityCategory.attraction, + ActivityCategory.museum, + ActivityCategory.restaurant, + ActivityCategory.culture, + ActivityCategory.nature, + ]; + + for (final cat in mainCategories) { + final activities = await _searchByCategory( + coordinates['lat']!, + coordinates['lng']!, + cat, + tripId, + radius, + ); + allActivities.addAll(activities); + } + } + + // 3. Supprimer les doublons et trier par note + final uniqueActivities = _removeDuplicates(allActivities); + uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0)); + + print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées'); + return uniqueActivities.take(50).toList(); // Limiter à 50 résultats + + } catch (e) { + print('ActivityPlacesService: Erreur lors de la recherche: $e'); + _errorService.logError('activity_places_service', e); + return []; + } + } + + /// Géocode une destination pour obtenir les coordonnées + Future?> _geocodeDestination(String destination) async { + try { + final encodedDestination = Uri.encodeComponent(destination); + final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data['status'] == 'OK' && data['results'].isNotEmpty) { + final location = data['results'][0]['geometry']['location']; + return { + 'lat': location['lat'].toDouble(), + 'lng': location['lng'].toDouble(), + }; + } + } + + return null; + } catch (e) { + print('ActivityPlacesService: Erreur géocodage: $e'); + return null; + } + } + + /// Recherche des activités par catégorie + Future> _searchByCategory( + double lat, + double lng, + ActivityCategory category, + String tripId, + int radius, + ) async { + try { + final url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json' + '?location=$lat,$lng' + '&radius=$radius' + '&type=${category.googlePlaceType}' + '&key=$_apiKey'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data['status'] == 'OK') { + final List activities = []; + + for (final place in data['results']) { + try { + final activity = await _convertPlaceToActivity(place, tripId, category); + if (activity != null) { + activities.add(activity); + } + } catch (e) { + print('ActivityPlacesService: Erreur conversion place: $e'); + } + } + + return activities; + } + } + + return []; + } catch (e) { + print('ActivityPlacesService: Erreur recherche par catégorie: $e'); + return []; + } + } + + /// Convertit un résultat Google Places en Activity + Future _convertPlaceToActivity( + Map place, + String tripId, + ActivityCategory category, + ) async { + try { + final placeId = place['place_id']; + if (placeId == null) return null; + + // Récupérer les détails supplémentaires + final details = await _getPlaceDetails(placeId); + + final geometry = place['geometry']?['location']; + final photos = place['photos'] as List?; + + // Obtenir une image de qualité + String? imageUrl; + if (photos != null && photos.isNotEmpty) { + final photoReference = photos.first['photo_reference']; + imageUrl = 'https://maps.googleapis.com/maps/api/place/photo' + '?maxwidth=800' + '&photoreference=$photoReference' + '&key=$_apiKey'; + } + + return Activity( + id: '', // Sera généré lors de la sauvegarde + tripId: tripId, + name: place['name'] ?? 'Activité inconnue', + description: details?['editorial_summary']?['overview'] ?? + details?['formatted_address'] ?? + 'Découvrez cette activité incontournable !', + category: category.displayName, + imageUrl: imageUrl, + rating: place['rating']?.toDouble(), + priceLevel: _getPriceLevelString(place['price_level']), + address: details?['formatted_address'] ?? place['vicinity'], + latitude: geometry?['lat']?.toDouble(), + longitude: geometry?['lng']?.toDouble(), + placeId: placeId, + website: details?['website'], + phoneNumber: details?['formatted_phone_number'], + openingHours: _parseOpeningHours(details?['opening_hours']), + votes: {}, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + } catch (e) { + print('ActivityPlacesService: Erreur conversion place: $e'); + return null; + } + } + + /// Récupère les détails d'un lieu + Future?> _getPlaceDetails(String placeId) async { + try { + final url = 'https://maps.googleapis.com/maps/api/place/details/json' + '?place_id=$placeId' + '&fields=formatted_address,formatted_phone_number,website,opening_hours,editorial_summary' + '&key=$_apiKey'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['status'] == 'OK') { + return data['result']; + } + } + + return null; + } catch (e) { + print('ActivityPlacesService: Erreur récupération détails: $e'); + return null; + } + } + + /// Convertit le niveau de prix en string + String? _getPriceLevelString(int? priceLevel) { + if (priceLevel == null) return null; + final level = PriceLevel.fromLevel(priceLevel); + return level?.displayName; + } + + /// Parse les heures d'ouverture + List _parseOpeningHours(Map? openingHours) { + if (openingHours == null) return []; + + final weekdayText = openingHours['weekday_text'] as List?; + if (weekdayText == null) return []; + + return weekdayText.cast(); + } + + /// Supprime les doublons basés sur le placeId + List _removeDuplicates(List activities) { + final seen = {}; + return activities.where((activity) { + if (activity.placeId == null) return true; + if (seen.contains(activity.placeId)) return false; + seen.add(activity.placeId!); + return true; + }).toList(); + } + + /// Recherche d'activités par texte libre + Future> searchActivitiesByText({ + required String query, + required String destination, + required String tripId, + int radius = 5000, + }) async { + try { + print('ActivityPlacesService: Recherche textuelle: $query à $destination'); + + // Géocoder la destination + final coordinates = await _geocodeDestination(destination); + if (coordinates == null) { + throw Exception('Impossible de localiser la destination'); + } + + final encodedQuery = Uri.encodeComponent(query); + final url = 'https://maps.googleapis.com/maps/api/place/textsearch/json' + '?query=$encodedQuery in $destination' + '&location=${coordinates['lat']},${coordinates['lng']}' + '&radius=$radius' + '&key=$_apiKey'; + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + + if (data['status'] == 'OK') { + final List activities = []; + + for (final place in data['results']) { + try { + // Déterminer la catégorie basée sur les types du lieu + final types = List.from(place['types'] ?? []); + final category = _determineCategoryFromTypes(types); + + final activity = await _convertPlaceToActivity(place, tripId, category); + if (activity != null) { + activities.add(activity); + } + } catch (e) { + print('ActivityPlacesService: Erreur conversion place: $e'); + } + } + + return activities; + } + } + + return []; + } catch (e) { + print('ActivityPlacesService: Erreur recherche textuelle: $e'); + return []; + } + } + + /// Détermine la catégorie d'activité basée sur les types Google Places + ActivityCategory _determineCategoryFromTypes(List types) { + for (final type in types) { + for (final category in ActivityCategory.values) { + if (category.googlePlaceType == type) { + return category; + } + } + } + + // Catégories par défaut basées sur des types communs + if (types.contains('restaurant') || types.contains('food')) { + return ActivityCategory.restaurant; + } else if (types.contains('museum')) { + return ActivityCategory.museum; + } else if (types.contains('park')) { + return ActivityCategory.nature; + } else if (types.contains('shopping_mall') || types.contains('store')) { + return ActivityCategory.shopping; + } else if (types.contains('night_club') || types.contains('bar')) { + return ActivityCategory.nightlife; + } + + return ActivityCategory.attraction; // Par défaut + } +} \ No newline at end of file