From f7eeb7c6f1233c5edf2e3d5d9fbca2515ad2c81c Mon Sep 17 00:00:00 2001 From: Van Leemput Dayron Date: Wed, 26 Nov 2025 12:15:13 +0100 Subject: [PATCH] feat: Add calendar page, enhance activity search and approval logic, and refactor activity filtering UI. --- lib/blocs/activity/activity_bloc.dart | 403 +++++++++------ lib/blocs/activity/activity_event.dart | 44 +- lib/blocs/activity/activity_state.dart | 54 +- .../activities/activities_page.dart | 488 +++++------------- .../home/calendar/calendar_page.dart | 275 ++++++++++ lib/components/home/home_content.dart | 12 +- .../home/show_trip_details_content.dart | 187 +++---- lib/models/activity.dart | 7 + lib/repositories/activity_repository.dart | 165 ++++-- pubspec.lock | 16 + pubspec.yaml | 1 + 11 files changed, 952 insertions(+), 700 deletions(-) create mode 100644 lib/components/home/calendar/calendar_page.dart diff --git a/lib/blocs/activity/activity_bloc.dart b/lib/blocs/activity/activity_bloc.dart index d224070..7b61196 100644 --- a/lib/blocs/activity/activity_bloc.dart +++ b/lib/blocs/activity/activity_bloc.dart @@ -17,13 +17,14 @@ class ActivityBloc extends Bloc { required ActivityRepository repository, required ActivityPlacesService placesService, required ErrorService errorService, - }) : _repository = repository, - _placesService = placesService, - _errorService = errorService, - super(const ActivityInitial()) { - + }) : _repository = repository, + _placesService = placesService, + _errorService = errorService, + super(const ActivityInitial()) { on(_onLoadActivities); - on(_onLoadTripActivitiesPreservingSearch); + on( + _onLoadTripActivitiesPreservingSearch, + ); on(_onSearchActivities); on(_onSearchActivitiesWithCoordinates); on(_onSearchActivitiesByText); @@ -39,6 +40,7 @@ class ActivityBloc extends Bloc { on(_onRestoreCachedSearchResults); on(_onRemoveFromSearchResults); on(_onAddActivityAndRemoveFromSearch); + on(_onUpdateActivityDate); } /// Handles loading activities for a trip @@ -48,15 +50,17 @@ class ActivityBloc extends Bloc { ) async { try { emit(const ActivityLoading()); - + final activities = await _repository.getActivitiesByTrip(event.tripId); - - emit(ActivityLoaded( - activities: activities, - filteredActivities: activities, - )); + + emit( + ActivityLoaded(activities: activities, filteredActivities: activities), + ); } catch (e) { - _errorService.logError('activity_bloc', 'Erreur chargement activités: $e'); + _errorService.logError( + 'activity_bloc', + 'Erreur chargement activités: $e', + ); emit(const ActivityError('Impossible de charger les activités')); } } @@ -68,24 +72,52 @@ class ActivityBloc extends Bloc { ) async { try { final activities = await _repository.getActivitiesByTrip(event.tripId); - + // Si on a un état de recherche actif, on le préserve if (state is ActivitySearchResults) { // On garde l'état de recherche inchangé, pas besoin d'émettre return; } - + // Sinon, on charge normalement - emit(ActivityLoaded( - activities: activities, - filteredActivities: activities, - )); + emit( + ActivityLoaded(activities: activities, filteredActivities: activities), + ); } catch (e) { - _errorService.logError('activity_bloc', 'Erreur chargement activités: $e'); + _errorService.logError( + 'activity_bloc', + 'Erreur chargement activités: $e', + ); emit(const ActivityError('Impossible de charger les activités')); } } + Future _onUpdateActivityDate( + UpdateActivityDate event, + Emitter emit, + ) async { + try { + final activity = await _repository.getActivity( + event.tripId, + event.activityId, + ); + + if (activity != null) { + final updatedActivity = activity.copyWith( + date: event.date, + clearDate: event.date == null, + ); + await _repository.updateActivity(updatedActivity); + + // Recharger les activités pour mettre à jour l'UI + add(LoadActivities(event.tripId)); + } + } catch (e) { + _errorService.logError('activity_bloc', 'Erreur mise à jour date: $e'); + emit(const ActivityError('Impossible de mettre à jour la date')); + } + } + /// Handles searching activities using Google Places API Future _onSearchActivities( SearchActivities event, @@ -99,17 +131,19 @@ class ActivityBloc extends Bloc { } 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 + 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; @@ -117,15 +151,17 @@ class ActivityBloc extends Bloc { } else { finalResults = searchResults; } - + // Mettre en cache les résultats ActivityCacheService().setCachedActivities(event.tripId, finalResults); - - emit(ActivitySearchResults( - searchResults: finalResults, - query: event.category?.displayName ?? 'Toutes les activités', - isLoading: false, - )); + + emit( + ActivitySearchResults( + searchResults: finalResults, + query: event.category?.displayName ?? 'Toutes les activités', + isLoading: false, + ), + ); } catch (e) { _errorService.logError('activity_bloc', 'Erreur recherche activités: $e'); emit(const ActivityError('Impossible de rechercher les activités')); @@ -145,7 +181,7 @@ class ActivityBloc extends Bloc { } else { emit(const ActivitySearching()); } - + final searchResults = await _placesService.searchActivitiesPaginated( latitude: event.latitude, longitude: event.longitude, @@ -153,10 +189,10 @@ class ActivityBloc extends Bloc { category: event.category, pageSize: event.maxResults ?? 20, ); - + final activities = searchResults['activities'] as List; List finalResults; - + // Si on doit ajouter aux résultats existants if (event.appendToExisting && state is ActivitySearchResults) { final currentState = state as ActivitySearchResults; @@ -164,17 +200,22 @@ class ActivityBloc extends Bloc { } else { finalResults = activities; } - + // Mettre en cache les résultats ActivityCacheService().setCachedActivities(event.tripId, finalResults); - - emit(ActivitySearchResults( - searchResults: finalResults, - query: event.category?.displayName ?? 'Toutes les activités', - isLoading: false, - )); + + emit( + ActivitySearchResults( + searchResults: finalResults, + query: event.category?.displayName ?? 'Toutes les activités', + isLoading: false, + ), + ); } catch (e) { - _errorService.logError('activity_bloc', 'Erreur recherche activités avec coordonnées: $e'); + _errorService.logError( + 'activity_bloc', + 'Erreur recherche activités avec coordonnées: $e', + ); emit(const ActivityError('Impossible de rechercher les activités')); } } @@ -186,20 +227,19 @@ class ActivityBloc extends Bloc { ) async { try { emit(const ActivitySearching()); - + final searchResults = await _placesService.searchActivitiesByText( query: event.query, destination: event.destination, tripId: event.tripId, ); - + // Mettre en cache les résultats ActivityCacheService().setCachedActivities(event.tripId, searchResults); - - emit(ActivitySearchResults( - searchResults: searchResults, - query: event.query, - )); + + 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')); @@ -218,7 +258,7 @@ class ActivityBloc extends Bloc { event.activity.tripId, event.activity.placeId!, ); - + if (existing != null) { emit(const ActivityError('Cette activité a déjà été ajoutée')); return; @@ -226,20 +266,27 @@ class ActivityBloc extends Bloc { } final activityId = await _repository.addActivity(event.activity); - + if (activityId != null) { // Si on est en état de recherche (suggestions Google), préserver cet état if (state is ActivitySearchResults) { - // On ne change rien à l'état de recherche, on le garde tel quel - // La suppression de l'activité des résultats se fait dans _onRemoveFromSearchResults + final currentState = state as ActivitySearchResults; + // On garde l'état de recherche inchangé mais on ajoute l'info de l'activité ajoutée + emit( + currentState.copyWith( + newlyAddedActivity: event.activity.copyWith(id: activityId), + ), + ); return; } - + // Sinon, émettre l'état d'ajout réussi - emit(ActivityAdded( - activity: event.activity.copyWith(id: activityId), - message: 'Activité ajoutée avec succès', - )); + emit( + ActivityAdded( + activity: event.activity.copyWith(id: activityId), + message: 'Activité ajoutée avec succès', + ), + ); // Reload activities while preserving search results add(LoadTripActivitiesPreservingSearch(event.activity.tripId)); } else { @@ -263,7 +310,7 @@ class ActivityBloc extends Bloc { event.activity.tripId, event.activity.placeId!, ); - + if (existing != null) { emit(const ActivityError('Cette activité a déjà été ajoutée')); return; @@ -271,7 +318,7 @@ class ActivityBloc extends Bloc { } final activityId = await _repository.addActivity(event.activity); - + if (activityId != null) { // Si on est en état de recherche (suggestions Google), préserver cet état // en supprimant l'activité des résultats @@ -280,20 +327,24 @@ class ActivityBloc extends Bloc { final updatedResults = currentState.searchResults .where((activity) => activity.id != event.googleActivityId) .toList(); - - emit(ActivitySearchResults( - searchResults: updatedResults, - query: currentState.query, - isLoading: false, - )); + + emit( + ActivitySearchResults( + searchResults: updatedResults, + query: currentState.query, + isLoading: false, + ), + ); return; } - + // Sinon, émettre l'état d'ajout réussi - emit(ActivityAdded( - activity: event.activity.copyWith(id: activityId), - message: 'Activité ajoutée avec succès', - )); + emit( + ActivityAdded( + activity: event.activity.copyWith(id: activityId), + message: 'Activité ajoutée avec succès', + ), + ); // Reload activities while preserving search results add(LoadTripActivitiesPreservingSearch(event.activity.tripId)); } else { @@ -313,35 +364,39 @@ class ActivityBloc extends Bloc { try { // Filter out existing activities final filteredActivities = []; - - emit(ActivityBatchAdding( - activitiesToAdd: event.activities, - progress: 0, - total: event.activities.length, - )); - + + 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, - )); + emit( + ActivityBatchAdding( + activitiesToAdd: event.activities, + progress: i + 1, + total: event.activities.length, + ), + ); } if (filteredActivities.isEmpty) { @@ -350,12 +405,14 @@ class ActivityBloc extends Bloc { } final addedIds = await _repository.addActivitiesBatch(filteredActivities); - + if (addedIds.isNotEmpty) { - emit(ActivityOperationSuccess( - '${addedIds.length} activité(s) ajoutée(s) avec succès', - operationType: 'batch_add', - )); + emit( + ActivityOperationSuccess( + '${addedIds.length} activité(s) ajoutée(s) avec succès', + operationType: 'batch_add', + ), + ); // Reload activities add(LoadActivities(event.activities.first.tripId)); } else { @@ -376,10 +433,12 @@ class ActivityBloc extends Bloc { // Show voting state if (state is ActivityLoaded) { final currentState = state as ActivityLoaded; - emit(ActivityVoting( - activityId: event.activityId, - activities: currentState.activities, - )); + emit( + ActivityVoting( + activityId: event.activityId, + activities: currentState.activities, + ), + ); } final success = await _repository.voteForActivity( @@ -387,31 +446,35 @@ class ActivityBloc extends Bloc { event.userId, event.vote, ); - + if (success) { - emit(ActivityVoteRecorded( - activityId: event.activityId, - vote: event.vote, - userId: event.userId, - )); - + 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, + + 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')); @@ -429,13 +492,15 @@ class ActivityBloc extends Bloc { ) async { try { final success = await _repository.deleteActivity(event.activityId); - + if (success) { - emit(ActivityDeleted( - activityId: event.activityId, - message: 'Activité supprimée avec succès', - )); - + 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; @@ -459,7 +524,7 @@ class ActivityBloc extends Bloc { ) async { if (state is ActivityLoaded) { final currentState = state as ActivityLoaded; - + final filteredActivities = _applyFilters( currentState.activities, event.category, @@ -467,13 +532,15 @@ class ActivityBloc extends Bloc { 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, - )); + + emit( + currentState.copyWith( + filteredActivities: filteredActivities, + activeFilter: event.category, + minRating: event.minRating, + showVotedOnly: event.showVotedOnly ?? false, + ), + ); } } @@ -503,20 +570,24 @@ class ActivityBloc extends Bloc { try { if (state is ActivityLoaded) { final currentState = state as ActivityLoaded; - emit(ActivityUpdating( - activityId: event.activity.id, - activities: currentState.activities, - )); + 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', - )); - + emit( + const ActivityOperationSuccess( + 'Activité mise à jour avec succès', + operationType: 'update', + ), + ); + // Reload activities add(LoadActivities(event.activity.tripId)); } else { @@ -536,11 +607,13 @@ class ActivityBloc extends Bloc { 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, - )); + 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')); @@ -558,7 +631,25 @@ class ActivityBloc extends Bloc { var filtered = activities; if (category != null) { - filtered = filtered.where((a) => a.category == category).toList(); + filtered = filtered.where((a) { + // Check exact match (internal value) + if (a.category == category) return true; + + // Check display name match + // Find the enum that matches the filter category (which is a display name) + try { + final categoryEnum = ActivityCategory.values.firstWhere( + (e) => e.displayName == category, + ); + // Check if activity category matches the enum's google type or display name + return a.category == categoryEnum.googlePlaceType || + a.category == categoryEnum.displayName || + a.category.toLowerCase() == categoryEnum.name.toLowerCase(); + } catch (_) { + // If no matching enum found, fallback to simple string comparison + return a.category.toLowerCase() == category.toLowerCase(); + } + }).toList(); } if (minRating != null) { @@ -580,18 +671,20 @@ class ActivityBloc extends Bloc { // Si on est actuellement dans un état de résultats de recherche if (state is ActivitySearchResults) { final currentState = state as ActivitySearchResults; - + // Filtrer l'activité à retirer final updatedResults = currentState.searchResults .where((activity) => activity.id != event.activityId) .toList(); - + // Émettre le nouvel état avec l'activité retirée - emit(ActivitySearchResults( - searchResults: updatedResults, - query: currentState.query, - isLoading: false, - )); + emit( + ActivitySearchResults( + searchResults: updatedResults, + query: currentState.query, + isLoading: false, + ), + ); } } @@ -600,10 +693,12 @@ class ActivityBloc extends Bloc { RestoreCachedSearchResults event, Emitter emit, ) async { - emit(ActivitySearchResults( - searchResults: event.searchResults, - query: 'cached', - isLoading: false, - )); + emit( + ActivitySearchResults( + searchResults: event.searchResults, + query: 'cached', + isLoading: false, + ), + ); } } diff --git a/lib/blocs/activity/activity_event.dart b/lib/blocs/activity/activity_event.dart index b17f2fb..a3f6f2b 100644 --- a/lib/blocs/activity/activity_event.dart +++ b/lib/blocs/activity/activity_event.dart @@ -50,7 +50,15 @@ class SearchActivities extends ActivityEvent { }); @override - List get props => [tripId, destination, category, maxResults, offset, reset, appendToExisting]; + List get props => [ + tripId, + destination, + category, + maxResults, + offset, + reset, + appendToExisting, + ]; } /// Event to search activities using coordinates directly (bypasses geocoding) @@ -76,7 +84,16 @@ class SearchActivitiesWithCoordinates extends ActivityEvent { }); @override - List get props => [tripId, latitude, longitude, category, maxResults, offset, reset, appendToExisting]; + List get props => [ + tripId, + latitude, + longitude, + category, + maxResults, + offset, + reset, + appendToExisting, + ]; } /// Event to search activities by text query @@ -95,6 +112,21 @@ class SearchActivitiesByText extends ActivityEvent { List get props => [tripId, destination, query]; } +class UpdateActivityDate extends ActivityEvent { + final String tripId; + final String activityId; + final DateTime? date; + + const UpdateActivityDate({ + required this.tripId, + required this.activityId, + this.date, + }); + + @override + List get props => [tripId, activityId, date]; +} + /// Event to add a single activity to the trip class AddActivity extends ActivityEvent { final Activity activity; @@ -147,11 +179,7 @@ class FilterActivities extends ActivityEvent { final double? minRating; final bool? showVotedOnly; - const FilterActivities({ - this.category, - this.minRating, - this.showVotedOnly, - }); + const FilterActivities({this.category, this.minRating, this.showVotedOnly}); @override List get props => [category, minRating, showVotedOnly]; @@ -228,4 +256,4 @@ class AddActivityAndRemoveFromSearch extends ActivityEvent { @override List get props => [activity, googleActivityId]; -} \ No newline at end of file +} diff --git a/lib/blocs/activity/activity_state.dart b/lib/blocs/activity/activity_state.dart index 395191b..8a0ac90 100644 --- a/lib/blocs/activity/activity_state.dart +++ b/lib/blocs/activity/activity_state.dart @@ -68,7 +68,9 @@ class ActivityLoaded extends ActivityState { /// Gets activities by category List getActivitiesByCategory(String category) { - return activities.where((activity) => activity.category == category).toList(); + return activities + .where((activity) => activity.category == category) + .toList(); } /// Gets top rated activities @@ -77,14 +79,14 @@ class ActivityLoaded extends ActivityState { 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(); } } @@ -94,26 +96,35 @@ class ActivitySearchResults extends ActivityState { final List searchResults; final String query; final bool isLoading; + final Activity? newlyAddedActivity; const ActivitySearchResults({ required this.searchResults, required this.query, this.isLoading = false, + this.newlyAddedActivity, }); @override - List get props => [searchResults, query, isLoading]; + List get props => [ + searchResults, + query, + isLoading, + newlyAddedActivity, + ]; /// Creates a copy with optional modifications ActivitySearchResults copyWith({ List? searchResults, String? query, bool? isLoading, + Activity? newlyAddedActivity, }) { return ActivitySearchResults( searchResults: searchResults ?? this.searchResults, query: query ?? this.query, isLoading: isLoading ?? this.isLoading, + newlyAddedActivity: newlyAddedActivity ?? this.newlyAddedActivity, ); } } @@ -123,10 +134,7 @@ class ActivityOperationSuccess extends ActivityState { final String message; final String? operationType; - const ActivityOperationSuccess( - this.message, { - this.operationType, - }); + const ActivityOperationSuccess(this.message, {this.operationType}); @override List get props => [message, operationType]; @@ -138,11 +146,7 @@ class ActivityError extends ActivityState { final String? errorCode; final dynamic error; - const ActivityError( - this.message, { - this.errorCode, - this.error, - }); + const ActivityError(this.message, {this.errorCode, this.error}); @override List get props => [message, errorCode, error]; @@ -153,10 +157,7 @@ class ActivityVoting extends ActivityState { final String activityId; final List activities; - const ActivityVoting({ - required this.activityId, - required this.activities, - }); + const ActivityVoting({required this.activityId, required this.activities}); @override List get props => [activityId, activities]; @@ -167,10 +168,7 @@ class ActivityUpdating extends ActivityState { final String activityId; final List activities; - const ActivityUpdating({ - required this.activityId, - required this.activities, - }); + const ActivityUpdating({required this.activityId, required this.activities}); @override List get props => [activityId, activities]; @@ -200,10 +198,7 @@ class ActivityAdded extends ActivityState { final Activity activity; final String message; - const ActivityAdded({ - required this.activity, - required this.message, - }); + const ActivityAdded({required this.activity, required this.message}); @override List get props => [activity, message]; @@ -214,10 +209,7 @@ class ActivityDeleted extends ActivityState { final String activityId; final String message; - const ActivityDeleted({ - required this.activityId, - required this.message, - }); + const ActivityDeleted({required this.activityId, required this.message}); @override List get props => [activityId, message]; @@ -237,4 +229,4 @@ class ActivityVoteRecorded extends ActivityState { @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 index 3f31a6a..b067f1c 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -6,7 +6,7 @@ import '../../blocs/activity/activity_state.dart'; import '../../models/trip.dart'; import '../../models/activity.dart'; import '../../services/activity_cache_service.dart'; -import '../activities/add_activity_bottom_sheet.dart'; + import '../loading/laoding_content.dart'; class ActivitiesPage extends StatefulWidget { @@ -23,9 +23,6 @@ class _ActivitiesPageState extends State late TabController _tabController; final TextEditingController _searchController = TextEditingController(); final ActivityCacheService _cacheService = ActivityCacheService(); - String _selectedCategory = 'Toutes les catégories'; - String _selectedPrice = 'Prix'; - String _selectedRating = 'Note'; // Cache pour éviter de recharger les données bool _activitiesLoaded = false; @@ -83,7 +80,9 @@ class _ActivitiesPageState extends State // Si on va sur l'onglet suggestions Google et qu'aucune recherche n'a été faite if (_tabController.index == 2 && !_googleSearchPerformed) { // Vérifier si on a des activités en cache - final cachedActivities = _cacheService.getCachedActivities(widget.trip.id!); + final cachedActivities = _cacheService.getCachedActivities( + widget.trip.id!, + ); if (cachedActivities != null && cachedActivities.isNotEmpty) { // Restaurer les activités en cache dans le BLoC context.read().add( @@ -144,13 +143,13 @@ class _ActivitiesPageState extends State if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } - + // Ajouter l'activité à la liste locale des activités du voyage WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { _tripActivities.add(state.activity); }); - + // Afficher un feedback de succès ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -174,10 +173,26 @@ class _ActivitiesPageState extends State // Stocker les activités localement WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { - _tripActivities = state.activities; - _approvedActivities = state.activities - .where((a) => a.totalVotes > 0) + final allActivities = state.filteredActivities; + + _approvedActivities = allActivities + .where( + (a) => a.isApprovedByAllParticipants([ + ...widget.trip.participants, + widget.trip.createdBy, + ]), + ) .toList(); + + _tripActivities = allActivities + .where( + (a) => !a.isApprovedByAllParticipants([ + ...widget.trip.participants, + widget.trip.createdBy, + ]), + ) + .toList(); + _isLoadingTripActivities = false; }); @@ -189,6 +204,37 @@ class _ActivitiesPageState extends State } if (state is ActivitySearchResults) { + // Gérer l'ajout d'activité depuis les résultats de recherche + if (state.newlyAddedActivity != null) { + // Fermer le dialog de loading s'il est ouvert + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _tripActivities.add(state.newlyAddedActivity!); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${state.newlyAddedActivity!.name} ajoutée au voyage !', + ), + duration: const Duration(seconds: 2), + backgroundColor: Colors.green, + action: SnackBarAction( + label: 'Voir', + textColor: Colors.white, + onPressed: () { + _tabController.animateTo(0); + }, + ), + ), + ); + }); + } + // 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) { @@ -220,21 +266,13 @@ class _ActivitiesPageState extends State backgroundColor: theme.colorScheme.surface, elevation: 0, foregroundColor: theme.colorScheme.onSurface, - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: _showAddActivityBottomSheet, - ), - ], + actions: [], ), body: Column( children: [ // Barre de recherche _buildSearchBar(theme), - // Filtres - _buildFilters(theme), - // Onglets de catégories _buildCategoryTabs(theme), @@ -260,7 +298,9 @@ class _ActivitiesPageState extends State padding: const EdgeInsets.all(16), child: Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), borderRadius: BorderRadius.circular(12), ), child: TextField( @@ -268,11 +308,11 @@ class _ActivitiesPageState extends State decoration: InputDecoration( hintText: 'Rechercher restaurants, musées...', hintStyle: TextStyle( - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), prefixIcon: Icon( Icons.search, - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( @@ -280,95 +320,32 @@ class _ActivitiesPageState extends State vertical: 12, ), ), - onChanged: (value) { - // TODO: Implémenter la recherche + onSubmitted: (value) { + if (value.isNotEmpty) { + _performSearch(value); + } }, ), ), ); } - 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.withValues(alpha:0.5)), - borderRadius: BorderRadius.circular(20), - color: theme.colorScheme.surface, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 16, - color: theme.colorScheme.onSurface.withValues(alpha:0.7), - ), - const SizedBox(width: 6), - Text( - text, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.7), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - } - Widget _buildCategoryTabs(ThemeData theme) { return Container( padding: const EdgeInsets.all(16), child: Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), borderRadius: BorderRadius.circular(8), ), child: TabBar( controller: _tabController, labelColor: Colors.white, - unselectedLabelColor: theme.colorScheme.onSurface.withValues(alpha:0.7), + unselectedLabelColor: theme.colorScheme.onSurface.withValues( + alpha: 0.7, + ), indicator: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(6), @@ -376,246 +353,15 @@ class _ActivitiesPageState extends State indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, tabs: const [ - Tab(text: 'Activités du voyage'), - Tab(text: 'Activités approuvées'), - Tab(text: 'Suggestions Google'), + Tab(text: 'Voyage'), + Tab(text: 'Approuvées'), + Tab(text: 'Suggestion'), ], ), ), ); } - void _showCategoryFilter() { - showModalBottomSheet( - context: context, - builder: (context) => _buildCategoryFilterSheet(), - ); - } - - void _showPriceFilter() { - showModalBottomSheet( - context: context, - 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( - padding: const EdgeInsets.all(16), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * 0.7, // Limite à 70% de l'écran - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Catégories', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - 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( - 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( - 'Niveau de prix', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - 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(), - ), - ), - ), - ], - ), - ); - } - - 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) { @@ -814,7 +560,9 @@ class _ActivitiesPageState extends State Text( 'Recherche powered by Google Places', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], @@ -845,7 +593,7 @@ class _ActivitiesPageState extends State Text( subtitle, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -977,7 +725,7 @@ class _ActivitiesPageState extends State Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha:0.1), + color: theme.colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -1015,7 +763,7 @@ class _ActivitiesPageState extends State vertical: 4, ), decoration: BoxDecoration( - color: Colors.amber.withValues(alpha:0.1), + color: Colors.amber.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -1043,7 +791,7 @@ class _ActivitiesPageState extends State Text( activity.description, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.8), + color: theme.colorScheme.onSurface.withValues(alpha: 0.8), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -1056,14 +804,18 @@ class _ActivitiesPageState extends State Icon( Icons.location_on, size: 16, - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), ), const SizedBox(width: 4), Expanded( child: Text( activity.address!, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), ), ), ), @@ -1084,10 +836,10 @@ class _ActivitiesPageState extends State vertical: 8, ), decoration: BoxDecoration( - color: Colors.orange.withValues(alpha:0.1), + color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.orange.withValues(alpha:0.3), + color: Colors.orange.withValues(alpha: 0.3), ), ), child: Row( @@ -1140,7 +892,7 @@ class _ActivitiesPageState extends State vertical: 4, ), decoration: BoxDecoration( - color: Colors.green.withValues(alpha:0.1), + color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -1170,7 +922,7 @@ class _ActivitiesPageState extends State vertical: 4, ), decoration: BoxDecoration( - color: Colors.red.withValues(alpha:0.1), + color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -1248,8 +1000,38 @@ class _ActivitiesPageState extends State void _voteForActivity(String activityId, int vote) { // TODO: Récupérer l'ID utilisateur actuel - // Pour l'instant, on utilise un ID temporaire - final userId = 'current_user_id'; + // Pour l'instant, on utilise l'ID du créateur du voyage pour que le vote compte + final userId = widget.trip.createdBy; + + // Vérifier si l'activité existe dans la liste locale pour vérifier le vote + // (car l'objet activity passé peut venir d'une liste filtrée ou autre) + final currentActivity = _tripActivities.firstWhere( + (a) => a.id == activityId, + orElse: () => _approvedActivities.firstWhere( + (a) => a.id == activityId, + orElse: () => Activity( + id: '', + tripId: '', + name: '', + description: '', + category: '', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ), + ); + + // Si l'activité a été trouvée et que l'utilisateur a déjà voté + if (currentActivity.id.isNotEmpty && currentActivity.hasUserVoted(userId)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vous avez déjà voté pour cette activité'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + return; + } context.read().add( VoteForActivity(activityId: activityId, userId: userId, vote: vote), @@ -1285,12 +1067,7 @@ class _ActivitiesPageState extends State loadingText: 'Ajout de ${activity.name}...', onBackgroundTask: () async { // Ajouter l'activité au voyage - context.read().add( - AddActivityAndRemoveFromSearch( - activity: newActivity, - googleActivityId: activity.id, - ), - ); + context.read().add(AddActivity(newActivity)); // Attendre que l'ajout soit complété await Future.delayed(const Duration(milliseconds: 1000)); }, @@ -1514,4 +1291,19 @@ class _ActivitiesPageState extends State } } } + + void _performSearch(String query) { + // Basculer vers l'onglet suggestions + _tabController.animateTo(2); + + // Déclencher la recherche textuelle + context.read().add( + SearchActivitiesByText( + tripId: widget.trip.id!, + destination: widget.trip.location, + query: query, + ), + ); + _googleSearchPerformed = true; + } } diff --git a/lib/components/home/calendar/calendar_page.dart b/lib/components/home/calendar/calendar_page.dart new file mode 100644 index 0000000..f45513c --- /dev/null +++ b/lib/components/home/calendar/calendar_page.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:intl/intl.dart'; +import '../../../models/trip.dart'; +import '../../../models/activity.dart'; +import '../../../blocs/activity/activity_bloc.dart'; +import '../../../blocs/activity/activity_state.dart'; +import '../../../blocs/activity/activity_event.dart'; + +class CalendarPage extends StatefulWidget { + final Trip trip; + + const CalendarPage({super.key, required this.trip}); + + @override + State createState() => _CalendarPageState(); +} + +class _CalendarPageState extends State { + late DateTime _focusedDay; + DateTime? _selectedDay; + CalendarFormat _calendarFormat = CalendarFormat.month; + + @override + void initState() { + super.initState(); + _focusedDay = widget.trip.startDate; + _selectedDay = _focusedDay; + } + + List _getActivitiesForDay(DateTime day, List activities) { + return activities.where((activity) { + if (activity.date == null) return false; + return isSameDay(activity.date, day); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Calendrier du voyage'), + backgroundColor: theme.colorScheme.surface, + foregroundColor: theme.colorScheme.onSurface, + elevation: 0, + ), + body: BlocBuilder( + builder: (context, state) { + if (state is ActivityLoading) { + return const Center(child: CircularProgressIndicator()); + } + + List allActivities = []; + if (state is ActivityLoaded) { + allActivities = state.activities; + } else if (state is ActivitySearchResults) { + // Fallback if we are in search state, though ideally we should be in loaded state + // This might happen if we navigate back and forth + } + + // Filter approved activities + final approvedActivities = allActivities.where((a) { + return a.isApprovedByAllParticipants([ + ...widget.trip.participants, + widget.trip.createdBy, + ]); + }).toList(); + + final scheduledActivities = approvedActivities + .where((a) => a.date != null) + .toList(); + + final unscheduledActivities = approvedActivities + .where((a) => a.date == null) + .toList(); + + final selectedActivities = _getActivitiesForDay( + _selectedDay ?? _focusedDay, + scheduledActivities, + ); + + return Column( + children: [ + TableCalendar( + firstDay: DateTime.now().subtract(const Duration(days: 365)), + lastDay: DateTime.now().add(const Duration(days: 365)), + focusedDay: _focusedDay, + calendarFormat: _calendarFormat, + selectedDayPredicate: (day) { + return isSameDay(_selectedDay, day); + }, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + }, + onFormatChanged: (format) { + setState(() { + _calendarFormat = format; + }); + }, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + }, + eventLoader: (day) { + return _getActivitiesForDay(day, scheduledActivities); + }, + calendarBuilders: CalendarBuilders( + markerBuilder: (context, day, events) { + if (events.isEmpty) return null; + return Positioned( + bottom: 1, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primary, + ), + ), + ); + }, + ), + calendarStyle: CalendarStyle( + todayDecoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + selectedDecoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + const Divider(), + Expanded( + child: Row( + children: [ + // Scheduled Activities for Selected Day + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Activités du ${DateFormat('dd/MM/yyyy').format(_selectedDay!)}', + style: theme.textTheme.titleMedium, + ), + ), + Expanded( + child: selectedActivities.isEmpty + ? Center( + child: Text( + 'Aucune activité prévue', + style: theme.textTheme.bodyMedium + ?.copyWith( + color: theme.colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + ) + : ListView.builder( + itemCount: selectedActivities.length, + itemBuilder: (context, index) { + final activity = + selectedActivities[index]; + return ListTile( + title: Text(activity.name), + subtitle: Text(activity.category), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + context.read().add( + UpdateActivityDate( + tripId: widget.trip.id!, + activityId: activity.id, + date: null, + ), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ), + const VerticalDivider(), + // Unscheduled Activities + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'À planifier', + style: theme.textTheme.titleMedium, + ), + ), + Expanded( + child: unscheduledActivities.isEmpty + ? Center( + child: Text( + 'Tout est planifié !', + style: theme.textTheme.bodyMedium + ?.copyWith( + color: theme.colorScheme.onSurface + .withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: unscheduledActivities.length, + itemBuilder: (context, index) { + final activity = + unscheduledActivities[index]; + return Draggable( + data: activity, + feedback: Material( + elevation: 4, + child: Container( + padding: const EdgeInsets.all(8), + color: theme.cardColor, + child: Text(activity.name), + ), + ), + child: ListTile( + title: Text( + activity.name, + style: theme.textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (_selectedDay != null) { + context + .read() + .add( + UpdateActivityDate( + tripId: widget.trip.id!, + activityId: activity.id, + date: _selectedDay, + ), + ); + } + }, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/components/home/home_content.dart b/lib/components/home/home_content.dart index d3be150..fe4c8d6 100644 --- a/lib/components/home/home_content.dart +++ b/lib/components/home/home_content.dart @@ -134,17 +134,7 @@ class _HomeContentState extends State : Colors.black, ), ), - const SizedBox(height: 8), - Text( - 'Vos voyages', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white70 - : Colors.grey[600], - ), - ), - const SizedBox(height: 20), + const SizedBox(height: 16), if (tripState is TripLoading || tripState is TripCreated) _buildLoadingState() diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 6a24905..0117620 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -15,6 +15,7 @@ import 'package:travel_mate/repositories/user_repository.dart'; import 'package:travel_mate/repositories/account_repository.dart'; import 'package:travel_mate/models/group_member.dart'; import 'package:travel_mate/components/activities/activities_page.dart'; +import 'package:travel_mate/components/home/calendar/calendar_page.dart'; import 'package:url_launcher/url_launcher.dart'; class ShowTripDetailsContent extends StatefulWidget { @@ -94,7 +95,7 @@ class _ShowTripDetailsContentState extends State { // Méthode pour afficher le dialogue de sélection de carte void _showMapOptions() { final theme = Theme.of(context); - + showDialog( context: context, builder: (BuildContext context) { @@ -193,7 +194,7 @@ class _ShowTripDetailsContentState extends State { // Méthode pour ouvrir Google Maps Future _openGoogleMaps() async { final location = Uri.encodeComponent(widget.trip.location); - + try { // Essayer d'abord l'URL scheme pour l'app mobile final appUrl = 'comgooglemaps://?q=$location'; @@ -202,17 +203,19 @@ class _ShowTripDetailsContentState extends State { await launchUrl(appUri); return; } - + // Fallback vers l'URL web - final webUrl = 'https://www.google.com/maps/search/?api=1&query=$location'; + final webUrl = + 'https://www.google.com/maps/search/?api=1&query=$location'; final webUri = Uri.parse(webUrl); if (await canLaunchUrl(webUri)) { await launchUrl(webUri, mode: LaunchMode.externalApplication); return; } - + _errorService.showError( - message: 'Impossible d\'ouvrir Google Maps. Vérifiez que l\'application est installée.', + message: + 'Impossible d\'ouvrir Google Maps. Vérifiez que l\'application est installée.', ); } catch (e) { _errorService.showError( @@ -224,7 +227,7 @@ class _ShowTripDetailsContentState extends State { // Méthode pour ouvrir Waze Future _openWaze() async { final location = Uri.encodeComponent(widget.trip.location); - + try { // Essayer d'abord l'URL scheme pour l'app mobile final appUrl = 'waze://?q=$location'; @@ -233,7 +236,7 @@ class _ShowTripDetailsContentState extends State { await launchUrl(appUri); return; } - + // Fallback vers l'URL web final webUrl = 'https://waze.com/ul?q=$location'; final webUri = Uri.parse(webUrl); @@ -241,14 +244,13 @@ class _ShowTripDetailsContentState extends State { await launchUrl(webUri, mode: LaunchMode.externalApplication); return; } - + _errorService.showError( - message: 'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.', + message: + 'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.', ); } catch (e) { - _errorService.showError( - message: 'Erreur lors de l\'ouverture de Waze', - ); + _errorService.showError(message: 'Erreur lors de l\'ouverture de Waze'); } } @@ -256,9 +258,11 @@ class _ShowTripDetailsContentState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - + return Scaffold( - backgroundColor: isDarkMode ? theme.scaffoldBackgroundColor : Colors.grey[50], + backgroundColor: isDarkMode + ? theme.scaffoldBackgroundColor + : Colors.grey[50], appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, @@ -292,7 +296,7 @@ class _ShowTripDetailsContentState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha:0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 5), ), @@ -300,16 +304,19 @@ class _ShowTripDetailsContentState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: widget.trip.imageUrl != null && widget.trip.imageUrl!.isNotEmpty + child: + widget.trip.imageUrl != null && + widget.trip.imageUrl!.isNotEmpty ? Image.network( widget.trip.imageUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => _buildPlaceholderImage(), + errorBuilder: (context, error, stackTrace) => + _buildPlaceholderImage(), ) : _buildPlaceholderImage(), ), ), - + // Contenu principal Padding( padding: const EdgeInsets.all(16), @@ -318,21 +325,24 @@ class _ShowTripDetailsContentState extends State { children: [ // Section "Départ dans X jours" Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha:0.1) - : Colors.black.withValues(alpha:0.1), + color: isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha:0.3) - : Colors.black.withValues(alpha:0.1), + color: isDarkMode + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), @@ -343,7 +353,7 @@ class _ShowTripDetailsContentState extends State { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.teal.withValues(alpha:0.1), + color: Colors.teal.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -359,11 +369,15 @@ class _ShowTripDetailsContentState extends State { Text( 'Départ dans', style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), ), ), Text( - daysUntilTrip > 0 ? '$daysUntilTrip Jours' : 'Voyage terminé', + daysUntilTrip > 0 + ? '$daysUntilTrip Jours' + : 'Voyage terminé', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, @@ -372,7 +386,9 @@ class _ShowTripDetailsContentState extends State { Text( widget.trip.formattedDates, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha:0.6), + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), ), ), ], @@ -380,9 +396,9 @@ class _ShowTripDetailsContentState extends State { ], ), ), - + const SizedBox(height: 24), - + // Section Participants Text( 'Participants', @@ -392,12 +408,12 @@ class _ShowTripDetailsContentState extends State { ), ), const SizedBox(height: 12), - + // Afficher les participants avec leurs images _buildParticipantsSection(), - + const SizedBox(height: 32), - + // Grille d'actions GridView.count( shrinkWrap: true, @@ -411,7 +427,13 @@ class _ShowTripDetailsContentState extends State { icon: Icons.calendar_today, title: 'Calendrier', color: Colors.blue, - onTap: () => _showComingSoon('Calendrier'), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + CalendarPage(trip: widget.trip), + ), + ), ), _buildActionButton( icon: Icons.local_activity, @@ -449,18 +471,11 @@ class _ShowTripDetailsContentState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.location_city, - size: 48, - color: Colors.grey, - ), + Icon(Icons.location_city, size: 48, color: Colors.grey), SizedBox(height: 8), Text( 'Aucune image', - style: TextStyle( - color: Colors.grey, - fontSize: 14, - ), + style: TextStyle(color: Colors.grey, fontSize: 14), ), ], ), @@ -476,7 +491,7 @@ class _ShowTripDetailsContentState extends State { }) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), @@ -486,16 +501,16 @@ class _ShowTripDetailsContentState extends State { color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isDarkMode - ? Colors.white.withValues(alpha:0.1) - : Colors.black.withValues(alpha:0.1), + color: isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( - color: isDarkMode - ? Colors.black.withValues(alpha:0.3) - : Colors.black.withValues(alpha:0.1), + color: isDarkMode + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), @@ -510,11 +525,7 @@ class _ShowTripDetailsContentState extends State { color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - icon, - color: color, - size: 24, - ), + child: Icon(icon, color: color, size: 24), ), const SizedBox(height: 8), Text( @@ -542,7 +553,7 @@ class _ShowTripDetailsContentState extends State { void _showOptionsMenu() { final theme = Theme.of(context); - + showModalBottomSheet( context: context, backgroundColor: theme.bottomSheetTheme.backgroundColor, @@ -594,7 +605,7 @@ class _ShowTripDetailsContentState extends State { void _showDeleteConfirmation() { final theme = Theme.of(context); - + showDialog( context: context, builder: (context) => AlertDialog( @@ -627,10 +638,7 @@ class _ShowTripDetailsContentState extends State { Navigator.pop(context); Navigator.pop(context, true); }, - child: const Text( - 'Supprimer', - style: TextStyle(color: Colors.red), - ), + child: const Text('Supprimer', style: TextStyle(color: Colors.red)), ), ], ), @@ -678,16 +686,13 @@ class _ShowTripDetailsContentState extends State { scrollDirection: Axis.horizontal, child: Row( children: [ - ...List.generate( - members.length, - (index) { - final member = members[index]; - return Padding( - padding: const EdgeInsets.only(right: 12), - child: _buildParticipantAvatar(member), - ); - }, - ), + ...List.generate(members.length, (index) { + final member = members[index]; + return Padding( + padding: const EdgeInsets.only(right: 12), + child: _buildParticipantAvatar(member), + ); + }), // Bouton "+" pour ajouter un participant Padding( padding: const EdgeInsets.only(right: 12), @@ -705,7 +710,9 @@ class _ShowTripDetailsContentState extends State { final theme = Theme.of(context); final initials = member.pseudo.isNotEmpty ? member.pseudo[0].toUpperCase() - : (member.firstName.isNotEmpty ? member.firstName[0].toUpperCase() : '?'); + : (member.firstName.isNotEmpty + ? member.firstName[0].toUpperCase() + : '?'); final name = member.pseudo.isNotEmpty ? member.pseudo : member.firstName; @@ -729,11 +736,14 @@ class _ShowTripDetailsContentState extends State { child: CircleAvatar( radius: 28, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), - backgroundImage: (member.profilePictureUrl != null && - member.profilePictureUrl!.isNotEmpty) + backgroundImage: + (member.profilePictureUrl != null && + member.profilePictureUrl!.isNotEmpty) ? NetworkImage(member.profilePictureUrl!) : null, - child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty) + child: + (member.profilePictureUrl == null || + member.profilePictureUrl!.isEmpty) ? Text( initials, style: TextStyle( @@ -774,11 +784,7 @@ class _ShowTripDetailsContentState extends State { child: CircleAvatar( radius: 28, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1), - child: Icon( - Icons.add, - color: theme.colorScheme.primary, - size: 28, - ), + child: Icon(Icons.add, color: theme.colorScheme.primary, size: 28), ), ), ), @@ -869,7 +875,7 @@ class _ShowTripDetailsContentState extends State { try { // Chercher l'utilisateur par email final user = await _userRepository.getUserByEmail(email); - + if (user == null) { _errorService.showError( message: 'Utilisateur non trouvé avec cet email', @@ -878,9 +884,7 @@ class _ShowTripDetailsContentState extends State { } if (user.id == null) { - _errorService.showError( - message: 'ID utilisateur invalide', - ); + _errorService.showError(message: 'ID utilisateur invalide'); return; } @@ -901,20 +905,19 @@ class _ShowTripDetailsContentState extends State { await _groupRepository.addMember(group.id, newMember); // Ajouter le membre au compte - final account = await _accountRepository.getAccountByTripId(widget.trip.id!); + final account = await _accountRepository.getAccountByTripId( + widget.trip.id!, + ); if (account != null) { await _accountRepository.addMemberToAccount(account.id, newMember); } // Mettre à jour la liste des participants du voyage - final newParticipants = [ - ...widget.trip.participants, - user.id!, - ]; + final newParticipants = [...widget.trip.participants, user.id!]; final updatedTrip = widget.trip.copyWith( participants: newParticipants, ); - + if (mounted) { context.read().add( TripUpdateRequested(trip: updatedTrip), diff --git a/lib/models/activity.dart b/lib/models/activity.dart index d908e2c..e335898 100644 --- a/lib/models/activity.dart +++ b/lib/models/activity.dart @@ -20,6 +20,7 @@ class Activity { final Map votes; // userId -> vote (1 pour pour, -1 pour contre) final DateTime createdAt; final DateTime updatedAt; + final DateTime? date; // Date prévue pour l'activité Activity({ required this.id, @@ -40,6 +41,7 @@ class Activity { this.votes = const {}, required this.createdAt, required this.updatedAt, + this.date, }); /// Calcule le score total des votes @@ -104,6 +106,8 @@ class Activity { Map? votes, DateTime? createdAt, DateTime? updatedAt, + DateTime? date, + bool clearDate = false, }) { return Activity( id: id ?? this.id, @@ -124,6 +128,7 @@ class Activity { votes: votes ?? this.votes, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + date: clearDate ? null : (date ?? this.date), ); } @@ -148,6 +153,7 @@ class Activity { 'votes': votes, 'createdAt': Timestamp.fromDate(createdAt), 'updatedAt': Timestamp.fromDate(updatedAt), + 'date': date != null ? Timestamp.fromDate(date!) : null, }; } @@ -172,6 +178,7 @@ class Activity { votes: Map.from(map['votes'] ?? {}), createdAt: (map['createdAt'] as Timestamp).toDate(), updatedAt: (map['updatedAt'] as Timestamp).toDate(), + date: map['date'] != null ? (map['date'] as Timestamp).toDate() : null, ); } diff --git a/lib/repositories/activity_repository.dart b/lib/repositories/activity_repository.dart index 8cff2ff..f4c035d 100644 --- a/lib/repositories/activity_repository.dart +++ b/lib/repositories/activity_repository.dart @@ -17,17 +17,22 @@ class ActivityRepository { Future addActivity(Activity activity) async { try { print('ActivityRepository: Ajout d\'une activité: ${activity.name}'); - - final docRef = await _firestore.collection(_collection).add(activity.toMap()); - + + 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'); + _errorService.logError( + 'activity_repository', + 'Erreur ajout activité: $e', + ); return null; } } @@ -35,8 +40,10 @@ class ActivityRepository { /// 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'); - + 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 @@ -55,24 +62,38 @@ class ActivityRepository { 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'); + _errorService.logError( + 'activity_repository', + 'Erreur récupération activités: $e', + ); return []; } } + /// Récupère une activité par son ID (alias pour getActivityById pour compatibilité) + Future getActivity(String tripId, String activityId) async { + return getActivityById(activityId); + } + /// Récupère une activité par son ID Future getActivityById(String activityId) async { try { - final doc = await _firestore.collection(_collection).doc(activityId).get(); - + 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'); + _errorService.logError( + 'activity_repository', + 'Erreur récupération activité: $e', + ); return null; } } @@ -81,17 +102,20 @@ class ActivityRepository { 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'); + _errorService.logError( + 'activity_repository', + 'Erreur mise à jour activité: $e', + ); return false; } } @@ -100,49 +124,62 @@ class ActivityRepository { 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'); + _errorService.logError( + 'activity_repository', + 'Erreur suppression activité: $e', + ); return false; } } /// Vote pour une activité - Future voteForActivity(String activityId, String userId, int vote) async { + 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'); + _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'); + _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 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); @@ -150,13 +187,13 @@ class ActivityRepository { // 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) { @@ -174,18 +211,21 @@ class ActivityRepository { .where('tripId', isEqualTo: tripId) .snapshots() .map((snapshot) { - 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; - }); + 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'); - _errorService.logError('activity_repository', 'Erreur stream activités: $e'); + _errorService.logError( + 'activity_repository', + 'Erreur stream activités: $e', + ); return Stream.value([]); } } @@ -193,20 +233,22 @@ class ActivityRepository { /// Ajoute plusieurs activités en lot Future> addActivitiesBatch(List activities) async { try { - print('ActivityRepository: Ajout en lot de ${activities.length} activités'); - + 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) { @@ -217,10 +259,15 @@ class ActivityRepository { } /// Recherche des activités par catégorie - Future> getActivitiesByCategory(String tripId, String category) async { + Future> getActivitiesByCategory( + String tripId, + String category, + ) async { try { - print('ActivityRepository: Recherche par catégorie: $category pour le voyage: $tripId'); - + 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) @@ -238,28 +285,34 @@ class ActivityRepository { return activities; } catch (e) { print('ActivityRepository: Erreur recherche par catégorie: $e'); - _errorService.logError('activity_repository', '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 { + 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'); @@ -281,11 +334,11 @@ class ActivityRepository { 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/pubspec.lock b/pubspec.lock index b646938..01a2430 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1024,6 +1024,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -1125,6 +1133,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1f090b9..4dc65af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: location: ^8.0.1 flutter_bloc : ^9.1.1 equatable: ^2.0.5 + table_calendar: ^3.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.