feat: Add calendar page, enhance activity search and approval logic, and refactor activity filtering UI.

This commit is contained in:
Van Leemput Dayron
2025-11-26 12:15:13 +01:00
parent 258f10b42b
commit f7eeb7c6f1
11 changed files with 952 additions and 700 deletions

View File

@@ -17,13 +17,14 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
required ActivityRepository repository, required ActivityRepository repository,
required ActivityPlacesService placesService, required ActivityPlacesService placesService,
required ErrorService errorService, required ErrorService errorService,
}) : _repository = repository, }) : _repository = repository,
_placesService = placesService, _placesService = placesService,
_errorService = errorService, _errorService = errorService,
super(const ActivityInitial()) { super(const ActivityInitial()) {
on<LoadActivities>(_onLoadActivities); on<LoadActivities>(_onLoadActivities);
on<LoadTripActivitiesPreservingSearch>(_onLoadTripActivitiesPreservingSearch); on<LoadTripActivitiesPreservingSearch>(
_onLoadTripActivitiesPreservingSearch,
);
on<SearchActivities>(_onSearchActivities); on<SearchActivities>(_onSearchActivities);
on<SearchActivitiesWithCoordinates>(_onSearchActivitiesWithCoordinates); on<SearchActivitiesWithCoordinates>(_onSearchActivitiesWithCoordinates);
on<SearchActivitiesByText>(_onSearchActivitiesByText); on<SearchActivitiesByText>(_onSearchActivitiesByText);
@@ -39,6 +40,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
on<RestoreCachedSearchResults>(_onRestoreCachedSearchResults); on<RestoreCachedSearchResults>(_onRestoreCachedSearchResults);
on<RemoveFromSearchResults>(_onRemoveFromSearchResults); on<RemoveFromSearchResults>(_onRemoveFromSearchResults);
on<AddActivityAndRemoveFromSearch>(_onAddActivityAndRemoveFromSearch); on<AddActivityAndRemoveFromSearch>(_onAddActivityAndRemoveFromSearch);
on<UpdateActivityDate>(_onUpdateActivityDate);
} }
/// Handles loading activities for a trip /// Handles loading activities for a trip
@@ -48,15 +50,17 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
) async { ) async {
try { try {
emit(const ActivityLoading()); emit(const ActivityLoading());
final activities = await _repository.getActivitiesByTrip(event.tripId); final activities = await _repository.getActivitiesByTrip(event.tripId);
emit(ActivityLoaded( emit(
activities: activities, ActivityLoaded(activities: activities, filteredActivities: activities),
filteredActivities: activities, );
));
} catch (e) { } 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')); emit(const ActivityError('Impossible de charger les activités'));
} }
} }
@@ -68,24 +72,52 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
) async { ) async {
try { try {
final activities = await _repository.getActivitiesByTrip(event.tripId); final activities = await _repository.getActivitiesByTrip(event.tripId);
// Si on a un état de recherche actif, on le préserve // Si on a un état de recherche actif, on le préserve
if (state is ActivitySearchResults) { if (state is ActivitySearchResults) {
// On garde l'état de recherche inchangé, pas besoin d'émettre // On garde l'état de recherche inchangé, pas besoin d'émettre
return; return;
} }
// Sinon, on charge normalement // Sinon, on charge normalement
emit(ActivityLoaded( emit(
activities: activities, ActivityLoaded(activities: activities, filteredActivities: activities),
filteredActivities: activities, );
));
} catch (e) { } 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')); emit(const ActivityError('Impossible de charger les activités'));
} }
} }
Future<void> _onUpdateActivityDate(
UpdateActivityDate event,
Emitter<ActivityState> 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 /// Handles searching activities using Google Places API
Future<void> _onSearchActivities( Future<void> _onSearchActivities(
SearchActivities event, SearchActivities event,
@@ -99,17 +131,19 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} else { } else {
emit(const ActivitySearching()); emit(const ActivitySearching());
} }
final searchResults = await _placesService.searchActivities( final searchResults = await _placesService.searchActivities(
destination: event.destination, destination: event.destination,
tripId: event.tripId, tripId: event.tripId,
category: event.category, 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 offset: event.offset ?? 0, // Par défaut 0
); );
List<Activity> finalResults; List<Activity> finalResults;
// Si on doit ajouter aux résultats existants // Si on doit ajouter aux résultats existants
if (event.appendToExisting && state is ActivitySearchResults) { if (event.appendToExisting && state is ActivitySearchResults) {
final currentState = state as ActivitySearchResults; final currentState = state as ActivitySearchResults;
@@ -117,15 +151,17 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} else { } else {
finalResults = searchResults; finalResults = searchResults;
} }
// Mettre en cache les résultats // Mettre en cache les résultats
ActivityCacheService().setCachedActivities(event.tripId, finalResults); ActivityCacheService().setCachedActivities(event.tripId, finalResults);
emit(ActivitySearchResults( emit(
searchResults: finalResults, ActivitySearchResults(
query: event.category?.displayName ?? 'Toutes les activités', searchResults: finalResults,
isLoading: false, query: event.category?.displayName ?? 'Toutes les activités',
)); isLoading: false,
),
);
} catch (e) { } catch (e) {
_errorService.logError('activity_bloc', 'Erreur recherche activités: $e'); _errorService.logError('activity_bloc', 'Erreur recherche activités: $e');
emit(const ActivityError('Impossible de rechercher les activités')); emit(const ActivityError('Impossible de rechercher les activités'));
@@ -145,7 +181,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} else { } else {
emit(const ActivitySearching()); emit(const ActivitySearching());
} }
final searchResults = await _placesService.searchActivitiesPaginated( final searchResults = await _placesService.searchActivitiesPaginated(
latitude: event.latitude, latitude: event.latitude,
longitude: event.longitude, longitude: event.longitude,
@@ -153,10 +189,10 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
category: event.category, category: event.category,
pageSize: event.maxResults ?? 20, pageSize: event.maxResults ?? 20,
); );
final activities = searchResults['activities'] as List<Activity>; final activities = searchResults['activities'] as List<Activity>;
List<Activity> finalResults; List<Activity> finalResults;
// Si on doit ajouter aux résultats existants // Si on doit ajouter aux résultats existants
if (event.appendToExisting && state is ActivitySearchResults) { if (event.appendToExisting && state is ActivitySearchResults) {
final currentState = state as ActivitySearchResults; final currentState = state as ActivitySearchResults;
@@ -164,17 +200,22 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} else { } else {
finalResults = activities; finalResults = activities;
} }
// Mettre en cache les résultats // Mettre en cache les résultats
ActivityCacheService().setCachedActivities(event.tripId, finalResults); ActivityCacheService().setCachedActivities(event.tripId, finalResults);
emit(ActivitySearchResults( emit(
searchResults: finalResults, ActivitySearchResults(
query: event.category?.displayName ?? 'Toutes les activités', searchResults: finalResults,
isLoading: false, query: event.category?.displayName ?? 'Toutes les activités',
)); isLoading: false,
),
);
} catch (e) { } 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')); emit(const ActivityError('Impossible de rechercher les activités'));
} }
} }
@@ -186,20 +227,19 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
) async { ) async {
try { try {
emit(const ActivitySearching()); emit(const ActivitySearching());
final searchResults = await _placesService.searchActivitiesByText( final searchResults = await _placesService.searchActivitiesByText(
query: event.query, query: event.query,
destination: event.destination, destination: event.destination,
tripId: event.tripId, tripId: event.tripId,
); );
// Mettre en cache les résultats // Mettre en cache les résultats
ActivityCacheService().setCachedActivities(event.tripId, searchResults); ActivityCacheService().setCachedActivities(event.tripId, searchResults);
emit(ActivitySearchResults( emit(
searchResults: searchResults, ActivitySearchResults(searchResults: searchResults, query: event.query),
query: event.query, );
));
} catch (e) { } catch (e) {
_errorService.logError('activity_bloc', 'Erreur recherche textuelle: $e'); _errorService.logError('activity_bloc', 'Erreur recherche textuelle: $e');
emit(const ActivityError('Impossible de rechercher les activités')); emit(const ActivityError('Impossible de rechercher les activités'));
@@ -218,7 +258,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
event.activity.tripId, event.activity.tripId,
event.activity.placeId!, event.activity.placeId!,
); );
if (existing != null) { if (existing != null) {
emit(const ActivityError('Cette activité a déjà été ajoutée')); emit(const ActivityError('Cette activité a déjà été ajoutée'));
return; return;
@@ -226,20 +266,27 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} }
final activityId = await _repository.addActivity(event.activity); final activityId = await _repository.addActivity(event.activity);
if (activityId != null) { if (activityId != null) {
// Si on est en état de recherche (suggestions Google), préserver cet état // Si on est en état de recherche (suggestions Google), préserver cet état
if (state is ActivitySearchResults) { if (state is ActivitySearchResults) {
// On ne change rien à l'état de recherche, on le garde tel quel final currentState = state as ActivitySearchResults;
// La suppression de l'activité des résultats se fait dans _onRemoveFromSearchResults // 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; return;
} }
// Sinon, émettre l'état d'ajout réussi // Sinon, émettre l'état d'ajout réussi
emit(ActivityAdded( emit(
activity: event.activity.copyWith(id: activityId), ActivityAdded(
message: 'Activité ajoutée avec succès', activity: event.activity.copyWith(id: activityId),
)); message: 'Activité ajoutée avec succès',
),
);
// Reload activities while preserving search results // Reload activities while preserving search results
add(LoadTripActivitiesPreservingSearch(event.activity.tripId)); add(LoadTripActivitiesPreservingSearch(event.activity.tripId));
} else { } else {
@@ -263,7 +310,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
event.activity.tripId, event.activity.tripId,
event.activity.placeId!, event.activity.placeId!,
); );
if (existing != null) { if (existing != null) {
emit(const ActivityError('Cette activité a déjà été ajoutée')); emit(const ActivityError('Cette activité a déjà été ajoutée'));
return; return;
@@ -271,7 +318,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} }
final activityId = await _repository.addActivity(event.activity); final activityId = await _repository.addActivity(event.activity);
if (activityId != null) { if (activityId != null) {
// Si on est en état de recherche (suggestions Google), préserver cet état // Si on est en état de recherche (suggestions Google), préserver cet état
// en supprimant l'activité des résultats // en supprimant l'activité des résultats
@@ -280,20 +327,24 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
final updatedResults = currentState.searchResults final updatedResults = currentState.searchResults
.where((activity) => activity.id != event.googleActivityId) .where((activity) => activity.id != event.googleActivityId)
.toList(); .toList();
emit(ActivitySearchResults( emit(
searchResults: updatedResults, ActivitySearchResults(
query: currentState.query, searchResults: updatedResults,
isLoading: false, query: currentState.query,
)); isLoading: false,
),
);
return; return;
} }
// Sinon, émettre l'état d'ajout réussi // Sinon, émettre l'état d'ajout réussi
emit(ActivityAdded( emit(
activity: event.activity.copyWith(id: activityId), ActivityAdded(
message: 'Activité ajoutée avec succès', activity: event.activity.copyWith(id: activityId),
)); message: 'Activité ajoutée avec succès',
),
);
// Reload activities while preserving search results // Reload activities while preserving search results
add(LoadTripActivitiesPreservingSearch(event.activity.tripId)); add(LoadTripActivitiesPreservingSearch(event.activity.tripId));
} else { } else {
@@ -313,35 +364,39 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
try { try {
// Filter out existing activities // Filter out existing activities
final filteredActivities = <Activity>[]; final filteredActivities = <Activity>[];
emit(ActivityBatchAdding( emit(
activitiesToAdd: event.activities, ActivityBatchAdding(
progress: 0, activitiesToAdd: event.activities,
total: event.activities.length, progress: 0,
)); total: event.activities.length,
),
);
for (int i = 0; i < event.activities.length; i++) { for (int i = 0; i < event.activities.length; i++) {
final activity = event.activities[i]; final activity = event.activities[i];
if (activity.placeId != null) { if (activity.placeId != null) {
final existing = await _repository.findExistingActivity( final existing = await _repository.findExistingActivity(
activity.tripId, activity.tripId,
activity.placeId!, activity.placeId!,
); );
if (existing == null) { if (existing == null) {
filteredActivities.add(activity); filteredActivities.add(activity);
} }
} else { } else {
filteredActivities.add(activity); filteredActivities.add(activity);
} }
// Update progress // Update progress
emit(ActivityBatchAdding( emit(
activitiesToAdd: event.activities, ActivityBatchAdding(
progress: i + 1, activitiesToAdd: event.activities,
total: event.activities.length, progress: i + 1,
)); total: event.activities.length,
),
);
} }
if (filteredActivities.isEmpty) { if (filteredActivities.isEmpty) {
@@ -350,12 +405,14 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
} }
final addedIds = await _repository.addActivitiesBatch(filteredActivities); final addedIds = await _repository.addActivitiesBatch(filteredActivities);
if (addedIds.isNotEmpty) { if (addedIds.isNotEmpty) {
emit(ActivityOperationSuccess( emit(
'${addedIds.length} activité(s) ajoutée(s) avec succès', ActivityOperationSuccess(
operationType: 'batch_add', '${addedIds.length} activité(s) ajoutée(s) avec succès',
)); operationType: 'batch_add',
),
);
// Reload activities // Reload activities
add(LoadActivities(event.activities.first.tripId)); add(LoadActivities(event.activities.first.tripId));
} else { } else {
@@ -376,10 +433,12 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
// Show voting state // Show voting state
if (state is ActivityLoaded) { if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded; final currentState = state as ActivityLoaded;
emit(ActivityVoting( emit(
activityId: event.activityId, ActivityVoting(
activities: currentState.activities, activityId: event.activityId,
)); activities: currentState.activities,
),
);
} }
final success = await _repository.voteForActivity( final success = await _repository.voteForActivity(
@@ -387,31 +446,35 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
event.userId, event.userId,
event.vote, event.vote,
); );
if (success) { if (success) {
emit(ActivityVoteRecorded( emit(
activityId: event.activityId, ActivityVoteRecorded(
vote: event.vote, activityId: event.activityId,
userId: event.userId, vote: event.vote,
)); userId: event.userId,
),
);
// Reload activities to reflect the new vote // Reload activities to reflect the new vote
if (state is ActivityLoaded) { if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded; final currentState = state as ActivityLoaded;
final activities = await _repository.getActivitiesByTrip( final activities = await _repository.getActivitiesByTrip(
currentState.activities.first.tripId, currentState.activities.first.tripId,
); );
emit(currentState.copyWith( emit(
activities: activities, currentState.copyWith(
filteredActivities: _applyFilters( activities: activities,
activities, filteredActivities: _applyFilters(
currentState.activeFilter, activities,
currentState.minRating, currentState.activeFilter,
currentState.showVotedOnly, currentState.minRating,
event.userId, currentState.showVotedOnly,
event.userId,
),
), ),
)); );
} }
} else { } else {
emit(const ActivityError('Impossible d\'enregistrer le vote')); emit(const ActivityError('Impossible d\'enregistrer le vote'));
@@ -429,13 +492,15 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
) async { ) async {
try { try {
final success = await _repository.deleteActivity(event.activityId); final success = await _repository.deleteActivity(event.activityId);
if (success) { if (success) {
emit(ActivityDeleted( emit(
activityId: event.activityId, ActivityDeleted(
message: 'Activité supprimée avec succès', activityId: event.activityId,
)); message: 'Activité supprimée avec succès',
),
);
// Reload if we're on the activity list // Reload if we're on the activity list
if (state is ActivityLoaded) { if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded; final currentState = state as ActivityLoaded;
@@ -459,7 +524,7 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
) async { ) async {
if (state is ActivityLoaded) { if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded; final currentState = state as ActivityLoaded;
final filteredActivities = _applyFilters( final filteredActivities = _applyFilters(
currentState.activities, currentState.activities,
event.category, event.category,
@@ -467,13 +532,15 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
event.showVotedOnly ?? false, event.showVotedOnly ?? false,
'', // UserId would be needed for showVotedOnly filter '', // UserId would be needed for showVotedOnly filter
); );
emit(currentState.copyWith( emit(
filteredActivities: filteredActivities, currentState.copyWith(
activeFilter: event.category, filteredActivities: filteredActivities,
minRating: event.minRating, activeFilter: event.category,
showVotedOnly: event.showVotedOnly ?? false, minRating: event.minRating,
)); showVotedOnly: event.showVotedOnly ?? false,
),
);
} }
} }
@@ -503,20 +570,24 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
try { try {
if (state is ActivityLoaded) { if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded; final currentState = state as ActivityLoaded;
emit(ActivityUpdating( emit(
activityId: event.activity.id, ActivityUpdating(
activities: currentState.activities, activityId: event.activity.id,
)); activities: currentState.activities,
),
);
} }
final success = await _repository.updateActivity(event.activity); final success = await _repository.updateActivity(event.activity);
if (success) { if (success) {
emit(const ActivityOperationSuccess( emit(
'Activité mise à jour avec succès', const ActivityOperationSuccess(
operationType: 'update', 'Activité mise à jour avec succès',
)); operationType: 'update',
),
);
// Reload activities // Reload activities
add(LoadActivities(event.activity.tripId)); add(LoadActivities(event.activity.tripId));
} else { } else {
@@ -536,11 +607,13 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
try { try {
// This would require extending the Activity model to include favorites // This would require extending the Activity model to include favorites
// For now, we'll use the voting system as a favorite system // For now, we'll use the voting system as a favorite system
add(VoteForActivity( add(
activityId: event.activityId, VoteForActivity(
userId: event.userId, activityId: event.activityId,
vote: 1, userId: event.userId,
)); vote: 1,
),
);
} catch (e) { } catch (e) {
_errorService.logError('activity_bloc', 'Erreur favori: $e'); _errorService.logError('activity_bloc', 'Erreur favori: $e');
emit(const ActivityError('Impossible de modifier les favoris')); emit(const ActivityError('Impossible de modifier les favoris'));
@@ -558,7 +631,25 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
var filtered = activities; var filtered = activities;
if (category != null) { 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) { if (minRating != null) {
@@ -580,18 +671,20 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
// Si on est actuellement dans un état de résultats de recherche // Si on est actuellement dans un état de résultats de recherche
if (state is ActivitySearchResults) { if (state is ActivitySearchResults) {
final currentState = state as ActivitySearchResults; final currentState = state as ActivitySearchResults;
// Filtrer l'activité à retirer // Filtrer l'activité à retirer
final updatedResults = currentState.searchResults final updatedResults = currentState.searchResults
.where((activity) => activity.id != event.activityId) .where((activity) => activity.id != event.activityId)
.toList(); .toList();
// Émettre le nouvel état avec l'activité retirée // Émettre le nouvel état avec l'activité retirée
emit(ActivitySearchResults( emit(
searchResults: updatedResults, ActivitySearchResults(
query: currentState.query, searchResults: updatedResults,
isLoading: false, query: currentState.query,
)); isLoading: false,
),
);
} }
} }
@@ -600,10 +693,12 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
RestoreCachedSearchResults event, RestoreCachedSearchResults event,
Emitter<ActivityState> emit, Emitter<ActivityState> emit,
) async { ) async {
emit(ActivitySearchResults( emit(
searchResults: event.searchResults, ActivitySearchResults(
query: 'cached', searchResults: event.searchResults,
isLoading: false, query: 'cached',
)); isLoading: false,
),
);
} }
} }

View File

@@ -50,7 +50,15 @@ class SearchActivities extends ActivityEvent {
}); });
@override @override
List<Object?> get props => [tripId, destination, category, maxResults, offset, reset, appendToExisting]; List<Object?> get props => [
tripId,
destination,
category,
maxResults,
offset,
reset,
appendToExisting,
];
} }
/// Event to search activities using coordinates directly (bypasses geocoding) /// Event to search activities using coordinates directly (bypasses geocoding)
@@ -76,7 +84,16 @@ class SearchActivitiesWithCoordinates extends ActivityEvent {
}); });
@override @override
List<Object?> get props => [tripId, latitude, longitude, category, maxResults, offset, reset, appendToExisting]; List<Object?> get props => [
tripId,
latitude,
longitude,
category,
maxResults,
offset,
reset,
appendToExisting,
];
} }
/// Event to search activities by text query /// Event to search activities by text query
@@ -95,6 +112,21 @@ class SearchActivitiesByText extends ActivityEvent {
List<Object> get props => [tripId, destination, query]; List<Object> 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<Object?> get props => [tripId, activityId, date];
}
/// Event to add a single activity to the trip /// Event to add a single activity to the trip
class AddActivity extends ActivityEvent { class AddActivity extends ActivityEvent {
final Activity activity; final Activity activity;
@@ -147,11 +179,7 @@ class FilterActivities extends ActivityEvent {
final double? minRating; final double? minRating;
final bool? showVotedOnly; final bool? showVotedOnly;
const FilterActivities({ const FilterActivities({this.category, this.minRating, this.showVotedOnly});
this.category,
this.minRating,
this.showVotedOnly,
});
@override @override
List<Object?> get props => [category, minRating, showVotedOnly]; List<Object?> get props => [category, minRating, showVotedOnly];
@@ -228,4 +256,4 @@ class AddActivityAndRemoveFromSearch extends ActivityEvent {
@override @override
List<Object> get props => [activity, googleActivityId]; List<Object> get props => [activity, googleActivityId];
} }

View File

@@ -68,7 +68,9 @@ class ActivityLoaded extends ActivityState {
/// Gets activities by category /// Gets activities by category
List<Activity> getActivitiesByCategory(String category) { List<Activity> getActivitiesByCategory(String category) {
return activities.where((activity) => activity.category == category).toList(); return activities
.where((activity) => activity.category == category)
.toList();
} }
/// Gets top rated activities /// Gets top rated activities
@@ -77,14 +79,14 @@ class ActivityLoaded extends ActivityState {
sorted.sort((a, b) { sorted.sort((a, b) {
final aScore = a.totalVotes; final aScore = a.totalVotes;
final bScore = b.totalVotes; final bScore = b.totalVotes;
if (aScore != bScore) { if (aScore != bScore) {
return bScore.compareTo(aScore); return bScore.compareTo(aScore);
} }
return (b.rating ?? 0).compareTo(a.rating ?? 0); return (b.rating ?? 0).compareTo(a.rating ?? 0);
}); });
return sorted.take(limit).toList(); return sorted.take(limit).toList();
} }
} }
@@ -94,26 +96,35 @@ class ActivitySearchResults extends ActivityState {
final List<Activity> searchResults; final List<Activity> searchResults;
final String query; final String query;
final bool isLoading; final bool isLoading;
final Activity? newlyAddedActivity;
const ActivitySearchResults({ const ActivitySearchResults({
required this.searchResults, required this.searchResults,
required this.query, required this.query,
this.isLoading = false, this.isLoading = false,
this.newlyAddedActivity,
}); });
@override @override
List<Object> get props => [searchResults, query, isLoading]; List<Object?> get props => [
searchResults,
query,
isLoading,
newlyAddedActivity,
];
/// Creates a copy with optional modifications /// Creates a copy with optional modifications
ActivitySearchResults copyWith({ ActivitySearchResults copyWith({
List<Activity>? searchResults, List<Activity>? searchResults,
String? query, String? query,
bool? isLoading, bool? isLoading,
Activity? newlyAddedActivity,
}) { }) {
return ActivitySearchResults( return ActivitySearchResults(
searchResults: searchResults ?? this.searchResults, searchResults: searchResults ?? this.searchResults,
query: query ?? this.query, query: query ?? this.query,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
newlyAddedActivity: newlyAddedActivity ?? this.newlyAddedActivity,
); );
} }
} }
@@ -123,10 +134,7 @@ class ActivityOperationSuccess extends ActivityState {
final String message; final String message;
final String? operationType; final String? operationType;
const ActivityOperationSuccess( const ActivityOperationSuccess(this.message, {this.operationType});
this.message, {
this.operationType,
});
@override @override
List<Object?> get props => [message, operationType]; List<Object?> get props => [message, operationType];
@@ -138,11 +146,7 @@ class ActivityError extends ActivityState {
final String? errorCode; final String? errorCode;
final dynamic error; final dynamic error;
const ActivityError( const ActivityError(this.message, {this.errorCode, this.error});
this.message, {
this.errorCode,
this.error,
});
@override @override
List<Object?> get props => [message, errorCode, error]; List<Object?> get props => [message, errorCode, error];
@@ -153,10 +157,7 @@ class ActivityVoting extends ActivityState {
final String activityId; final String activityId;
final List<Activity> activities; final List<Activity> activities;
const ActivityVoting({ const ActivityVoting({required this.activityId, required this.activities});
required this.activityId,
required this.activities,
});
@override @override
List<Object> get props => [activityId, activities]; List<Object> get props => [activityId, activities];
@@ -167,10 +168,7 @@ class ActivityUpdating extends ActivityState {
final String activityId; final String activityId;
final List<Activity> activities; final List<Activity> activities;
const ActivityUpdating({ const ActivityUpdating({required this.activityId, required this.activities});
required this.activityId,
required this.activities,
});
@override @override
List<Object> get props => [activityId, activities]; List<Object> get props => [activityId, activities];
@@ -200,10 +198,7 @@ class ActivityAdded extends ActivityState {
final Activity activity; final Activity activity;
final String message; final String message;
const ActivityAdded({ const ActivityAdded({required this.activity, required this.message});
required this.activity,
required this.message,
});
@override @override
List<Object> get props => [activity, message]; List<Object> get props => [activity, message];
@@ -214,10 +209,7 @@ class ActivityDeleted extends ActivityState {
final String activityId; final String activityId;
final String message; final String message;
const ActivityDeleted({ const ActivityDeleted({required this.activityId, required this.message});
required this.activityId,
required this.message,
});
@override @override
List<Object> get props => [activityId, message]; List<Object> get props => [activityId, message];
@@ -237,4 +229,4 @@ class ActivityVoteRecorded extends ActivityState {
@override @override
List<Object> get props => [activityId, vote, userId]; List<Object> get props => [activityId, vote, userId];
} }

View File

@@ -6,7 +6,7 @@ import '../../blocs/activity/activity_state.dart';
import '../../models/trip.dart'; import '../../models/trip.dart';
import '../../models/activity.dart'; import '../../models/activity.dart';
import '../../services/activity_cache_service.dart'; import '../../services/activity_cache_service.dart';
import '../activities/add_activity_bottom_sheet.dart';
import '../loading/laoding_content.dart'; import '../loading/laoding_content.dart';
class ActivitiesPage extends StatefulWidget { class ActivitiesPage extends StatefulWidget {
@@ -23,9 +23,6 @@ class _ActivitiesPageState extends State<ActivitiesPage>
late TabController _tabController; late TabController _tabController;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ActivityCacheService _cacheService = ActivityCacheService(); final ActivityCacheService _cacheService = ActivityCacheService();
String _selectedCategory = 'Toutes les catégories';
String _selectedPrice = 'Prix';
String _selectedRating = 'Note';
// Cache pour éviter de recharger les données // Cache pour éviter de recharger les données
bool _activitiesLoaded = false; bool _activitiesLoaded = false;
@@ -83,7 +80,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
// Si on va sur l'onglet suggestions Google et qu'aucune recherche n'a été faite // Si on va sur l'onglet suggestions Google et qu'aucune recherche n'a été faite
if (_tabController.index == 2 && !_googleSearchPerformed) { if (_tabController.index == 2 && !_googleSearchPerformed) {
// Vérifier si on a des activités en cache // 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) { if (cachedActivities != null && cachedActivities.isNotEmpty) {
// Restaurer les activités en cache dans le BLoC // Restaurer les activités en cache dans le BLoC
context.read<ActivityBloc>().add( context.read<ActivityBloc>().add(
@@ -144,13 +143,13 @@ class _ActivitiesPageState extends State<ActivitiesPage>
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
// Ajouter l'activité à la liste locale des activités du voyage // Ajouter l'activité à la liste locale des activités du voyage
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { setState(() {
_tripActivities.add(state.activity); _tripActivities.add(state.activity);
}); });
// Afficher un feedback de succès // Afficher un feedback de succès
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -174,10 +173,26 @@ class _ActivitiesPageState extends State<ActivitiesPage>
// Stocker les activités localement // Stocker les activités localement
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { setState(() {
_tripActivities = state.activities; final allActivities = state.filteredActivities;
_approvedActivities = state.activities
.where((a) => a.totalVotes > 0) _approvedActivities = allActivities
.where(
(a) => a.isApprovedByAllParticipants([
...widget.trip.participants,
widget.trip.createdBy,
]),
)
.toList(); .toList();
_tripActivities = allActivities
.where(
(a) => !a.isApprovedByAllParticipants([
...widget.trip.participants,
widget.trip.createdBy,
]),
)
.toList();
_isLoadingTripActivities = false; _isLoadingTripActivities = false;
}); });
@@ -189,6 +204,37 @@ class _ActivitiesPageState extends State<ActivitiesPage>
} }
if (state is ActivitySearchResults) { 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) // Déclencher l'auto-reload uniquement pour la recherche initiale (6 résultats)
// et pas pour les rechargements automatiques // et pas pour les rechargements automatiques
if (state.searchResults.length <= 6 && !_autoReloadInProgress) { if (state.searchResults.length <= 6 && !_autoReloadInProgress) {
@@ -220,21 +266,13 @@ class _ActivitiesPageState extends State<ActivitiesPage>
backgroundColor: theme.colorScheme.surface, backgroundColor: theme.colorScheme.surface,
elevation: 0, elevation: 0,
foregroundColor: theme.colorScheme.onSurface, foregroundColor: theme.colorScheme.onSurface,
actions: [ actions: [],
IconButton(
icon: const Icon(Icons.add),
onPressed: _showAddActivityBottomSheet,
),
],
), ),
body: Column( body: Column(
children: [ children: [
// Barre de recherche // Barre de recherche
_buildSearchBar(theme), _buildSearchBar(theme),
// Filtres
_buildFilters(theme),
// Onglets de catégories // Onglets de catégories
_buildCategoryTabs(theme), _buildCategoryTabs(theme),
@@ -260,7 +298,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3), color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: TextField( child: TextField(
@@ -268,11 +308,11 @@ class _ActivitiesPageState extends State<ActivitiesPage>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Rechercher restaurants, musées...', hintText: 'Rechercher restaurants, musées...',
hintStyle: TextStyle( hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha:0.6), color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
), ),
prefixIcon: Icon( prefixIcon: Icon(
Icons.search, Icons.search,
color: theme.colorScheme.onSurface.withValues(alpha:0.6), color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
), ),
border: InputBorder.none, border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -280,95 +320,32 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 12, vertical: 12,
), ),
), ),
onChanged: (value) { onSubmitted: (value) {
// TODO: Implémenter la recherche 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) { Widget _buildCategoryTabs(ThemeData theme) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3), color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: TabBar( child: TabBar(
controller: _tabController, controller: _tabController,
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: theme.colorScheme.onSurface.withValues(alpha:0.7), unselectedLabelColor: theme.colorScheme.onSurface.withValues(
alpha: 0.7,
),
indicator: BoxDecoration( indicator: BoxDecoration(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@@ -376,246 +353,15 @@ class _ActivitiesPageState extends State<ActivitiesPage>
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
tabs: const [ tabs: const [
Tab(text: 'Activités du voyage'), Tab(text: 'Voyage'),
Tab(text: 'Activités approuvées'), Tab(text: 'Approuvées'),
Tab(text: 'Suggestions Google'), 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<ActivityBloc>().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() { Widget _buildTripActivitiesTab() {
// Utiliser les données locales au lieu du BLoC // Utiliser les données locales au lieu du BLoC
if (_isLoadingTripActivities) { if (_isLoadingTripActivities) {
@@ -814,7 +560,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Text( Text(
'Recherche powered by Google Places', 'Recherche powered by Google Places',
style: Theme.of(context).textTheme.bodySmall?.copyWith( 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<ActivitiesPage>
Text( Text(
subtitle, subtitle,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.7), color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -977,7 +725,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha:0.1), color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon( child: Icon(
@@ -1015,7 +763,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withValues(alpha:0.1), color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -1043,7 +791,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Text( Text(
activity.description, activity.description,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.8), color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -1056,14 +804,18 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Icon( Icon(
Icons.location_on, Icons.location_on,
size: 16, size: 16,
color: theme.colorScheme.onSurface.withValues(alpha:0.6), color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
activity.address!, activity.address!,
style: theme.textTheme.bodySmall?.copyWith( 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<ActivitiesPage>
vertical: 8, vertical: 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha:0.1), color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Colors.orange.withValues(alpha:0.3), color: Colors.orange.withValues(alpha: 0.3),
), ),
), ),
child: Row( child: Row(
@@ -1140,7 +892,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha:0.1), color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -1170,7 +922,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withValues(alpha:0.1), color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -1248,8 +1000,38 @@ class _ActivitiesPageState extends State<ActivitiesPage>
void _voteForActivity(String activityId, int vote) { void _voteForActivity(String activityId, int vote) {
// TODO: Récupérer l'ID utilisateur actuel // TODO: Récupérer l'ID utilisateur actuel
// Pour l'instant, on utilise un ID temporaire // Pour l'instant, on utilise l'ID du créateur du voyage pour que le vote compte
final userId = 'current_user_id'; 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<ActivityBloc>().add( context.read<ActivityBloc>().add(
VoteForActivity(activityId: activityId, userId: userId, vote: vote), VoteForActivity(activityId: activityId, userId: userId, vote: vote),
@@ -1285,12 +1067,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
loadingText: 'Ajout de ${activity.name}...', loadingText: 'Ajout de ${activity.name}...',
onBackgroundTask: () async { onBackgroundTask: () async {
// Ajouter l'activité au voyage // Ajouter l'activité au voyage
context.read<ActivityBloc>().add( context.read<ActivityBloc>().add(AddActivity(newActivity));
AddActivityAndRemoveFromSearch(
activity: newActivity,
googleActivityId: activity.id,
),
);
// Attendre que l'ajout soit complété // Attendre que l'ajout soit complété
await Future.delayed(const Duration(milliseconds: 1000)); await Future.delayed(const Duration(milliseconds: 1000));
}, },
@@ -1514,4 +1291,19 @@ class _ActivitiesPageState extends State<ActivitiesPage>
} }
} }
} }
void _performSearch(String query) {
// Basculer vers l'onglet suggestions
_tabController.animateTo(2);
// Déclencher la recherche textuelle
context.read<ActivityBloc>().add(
SearchActivitiesByText(
tripId: widget.trip.id!,
destination: widget.trip.location,
query: query,
),
);
_googleSearchPerformed = true;
}
} }

View File

@@ -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<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
late DateTime _focusedDay;
DateTime? _selectedDay;
CalendarFormat _calendarFormat = CalendarFormat.month;
@override
void initState() {
super.initState();
_focusedDay = widget.trip.startDate;
_selectedDay = _focusedDay;
}
List<Activity> _getActivitiesForDay(DateTime day, List<Activity> 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<ActivityBloc, ActivityState>(
builder: (context, state) {
if (state is ActivityLoading) {
return const Center(child: CircularProgressIndicator());
}
List<Activity> 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<ActivityBloc>().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<Activity>(
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<ActivityBloc>()
.add(
UpdateActivityDate(
tripId: widget.trip.id!,
activityId: activity.id,
date: _selectedDay,
),
);
}
},
),
),
);
},
),
),
],
),
),
],
),
),
],
);
},
),
);
}
}

View File

@@ -134,17 +134,7 @@ class _HomeContentState extends State<HomeContent>
: Colors.black, : Colors.black,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Text(
'Vos voyages',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
),
),
const SizedBox(height: 20),
if (tripState is TripLoading || tripState is TripCreated) if (tripState is TripLoading || tripState is TripCreated)
_buildLoadingState() _buildLoadingState()

View File

@@ -15,6 +15,7 @@ import 'package:travel_mate/repositories/user_repository.dart';
import 'package:travel_mate/repositories/account_repository.dart'; import 'package:travel_mate/repositories/account_repository.dart';
import 'package:travel_mate/models/group_member.dart'; import 'package:travel_mate/models/group_member.dart';
import 'package:travel_mate/components/activities/activities_page.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'; import 'package:url_launcher/url_launcher.dart';
class ShowTripDetailsContent extends StatefulWidget { class ShowTripDetailsContent extends StatefulWidget {
@@ -94,7 +95,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour afficher le dialogue de sélection de carte // Méthode pour afficher le dialogue de sélection de carte
void _showMapOptions() { void _showMapOptions() {
final theme = Theme.of(context); final theme = Theme.of(context);
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
@@ -193,7 +194,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour ouvrir Google Maps // Méthode pour ouvrir Google Maps
Future<void> _openGoogleMaps() async { Future<void> _openGoogleMaps() async {
final location = Uri.encodeComponent(widget.trip.location); final location = Uri.encodeComponent(widget.trip.location);
try { try {
// Essayer d'abord l'URL scheme pour l'app mobile // Essayer d'abord l'URL scheme pour l'app mobile
final appUrl = 'comgooglemaps://?q=$location'; final appUrl = 'comgooglemaps://?q=$location';
@@ -202,17 +203,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(appUri); await launchUrl(appUri);
return; return;
} }
// Fallback vers l'URL web // 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); final webUri = Uri.parse(webUrl);
if (await canLaunchUrl(webUri)) { if (await canLaunchUrl(webUri)) {
await launchUrl(webUri, mode: LaunchMode.externalApplication); await launchUrl(webUri, mode: LaunchMode.externalApplication);
return; return;
} }
_errorService.showError( _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) { } catch (e) {
_errorService.showError( _errorService.showError(
@@ -224,7 +227,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour ouvrir Waze // Méthode pour ouvrir Waze
Future<void> _openWaze() async { Future<void> _openWaze() async {
final location = Uri.encodeComponent(widget.trip.location); final location = Uri.encodeComponent(widget.trip.location);
try { try {
// Essayer d'abord l'URL scheme pour l'app mobile // Essayer d'abord l'URL scheme pour l'app mobile
final appUrl = 'waze://?q=$location'; final appUrl = 'waze://?q=$location';
@@ -233,7 +236,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(appUri); await launchUrl(appUri);
return; return;
} }
// Fallback vers l'URL web // Fallback vers l'URL web
final webUrl = 'https://waze.com/ul?q=$location'; final webUrl = 'https://waze.com/ul?q=$location';
final webUri = Uri.parse(webUrl); final webUri = Uri.parse(webUrl);
@@ -241,14 +244,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(webUri, mode: LaunchMode.externalApplication); await launchUrl(webUri, mode: LaunchMode.externalApplication);
return; return;
} }
_errorService.showError( _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) { } catch (e) {
_errorService.showError( _errorService.showError(message: 'Erreur lors de l\'ouverture de Waze');
message: 'Erreur lors de l\'ouverture de Waze',
);
} }
} }
@@ -256,9 +258,11 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark; final isDarkMode = theme.brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: isDarkMode ? theme.scaffoldBackgroundColor : Colors.grey[50], backgroundColor: isDarkMode
? theme.scaffoldBackgroundColor
: Colors.grey[50],
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
@@ -292,7 +296,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha:0.1), color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 5), offset: const Offset(0, 5),
), ),
@@ -300,16 +304,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: widget.trip.imageUrl != null && widget.trip.imageUrl!.isNotEmpty child:
widget.trip.imageUrl != null &&
widget.trip.imageUrl!.isNotEmpty
? Image.network( ? Image.network(
widget.trip.imageUrl!, widget.trip.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildPlaceholderImage(), errorBuilder: (context, error, stackTrace) =>
_buildPlaceholderImage(),
) )
: _buildPlaceholderImage(), : _buildPlaceholderImage(),
), ),
), ),
// Contenu principal // Contenu principal
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -318,21 +325,24 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
children: [ children: [
// Section "Départ dans X jours" // Section "Départ dans X jours"
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.cardColor, color: theme.cardColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isDarkMode color: isDarkMode
? Colors.white.withValues(alpha:0.1) ? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha:0.1), : Colors.black.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: isDarkMode color: isDarkMode
? Colors.black.withValues(alpha:0.3) ? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha:0.1), : Colors.black.withValues(alpha: 0.1),
blurRadius: isDarkMode ? 8 : 5, blurRadius: isDarkMode ? 8 : 5,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@@ -343,7 +353,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.teal.withValues(alpha:0.1), color: Colors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon( child: Icon(
@@ -359,11 +369,15 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Text( Text(
'Départ dans', 'Départ dans',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.6), color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
), ),
), ),
Text( Text(
daysUntilTrip > 0 ? '$daysUntilTrip Jours' : 'Voyage terminé', daysUntilTrip > 0
? '$daysUntilTrip Jours'
: 'Voyage terminé',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
@@ -372,7 +386,9 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Text( Text(
widget.trip.formattedDates, widget.trip.formattedDates,
style: theme.textTheme.bodySmall?.copyWith( 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<ShowTripDetailsContent> {
], ],
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Section Participants // Section Participants
Text( Text(
'Participants', 'Participants',
@@ -392,12 +408,12 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Afficher les participants avec leurs images // Afficher les participants avec leurs images
_buildParticipantsSection(), _buildParticipantsSection(),
const SizedBox(height: 32), const SizedBox(height: 32),
// Grille d'actions // Grille d'actions
GridView.count( GridView.count(
shrinkWrap: true, shrinkWrap: true,
@@ -411,7 +427,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
icon: Icons.calendar_today, icon: Icons.calendar_today,
title: 'Calendrier', title: 'Calendrier',
color: Colors.blue, color: Colors.blue,
onTap: () => _showComingSoon('Calendrier'), onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
CalendarPage(trip: widget.trip),
),
),
), ),
_buildActionButton( _buildActionButton(
icon: Icons.local_activity, icon: Icons.local_activity,
@@ -449,18 +471,11 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.location_city, size: 48, color: Colors.grey),
Icons.location_city,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8), SizedBox(height: 8),
Text( Text(
'Aucune image', 'Aucune image',
style: TextStyle( style: TextStyle(color: Colors.grey, fontSize: 14),
color: Colors.grey,
fontSize: 14,
),
), ),
], ],
), ),
@@ -476,7 +491,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark; final isDarkMode = theme.brightness == Brightness.dark;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -486,16 +501,16 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
color: theme.cardColor, color: theme.cardColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isDarkMode color: isDarkMode
? Colors.white.withValues(alpha:0.1) ? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha:0.1), : Colors.black.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: isDarkMode color: isDarkMode
? Colors.black.withValues(alpha:0.3) ? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha:0.1), : Colors.black.withValues(alpha: 0.1),
blurRadius: isDarkMode ? 8 : 5, blurRadius: isDarkMode ? 8 : 5,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@@ -510,11 +525,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
color: color.withValues(alpha: 0.1), color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon( child: Icon(icon, color: color, size: 24),
icon,
color: color,
size: 24,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -542,7 +553,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showOptionsMenu() { void _showOptionsMenu() {
final theme = Theme.of(context); final theme = Theme.of(context);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: theme.bottomSheetTheme.backgroundColor, backgroundColor: theme.bottomSheetTheme.backgroundColor,
@@ -594,7 +605,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showDeleteConfirmation() { void _showDeleteConfirmation() {
final theme = Theme.of(context); final theme = Theme.of(context);
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -627,10 +638,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Navigator.pop(context); Navigator.pop(context);
Navigator.pop(context, true); Navigator.pop(context, true);
}, },
child: const Text( child: const Text('Supprimer', style: TextStyle(color: Colors.red)),
'Supprimer',
style: TextStyle(color: Colors.red),
),
), ),
], ],
), ),
@@ -678,16 +686,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
...List.generate( ...List.generate(members.length, (index) {
members.length, final member = members[index];
(index) { return Padding(
final member = members[index]; padding: const EdgeInsets.only(right: 12),
return Padding( child: _buildParticipantAvatar(member),
padding: const EdgeInsets.only(right: 12), );
child: _buildParticipantAvatar(member), }),
);
},
),
// Bouton "+" pour ajouter un participant // Bouton "+" pour ajouter un participant
Padding( Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
@@ -705,7 +710,9 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
final theme = Theme.of(context); final theme = Theme.of(context);
final initials = member.pseudo.isNotEmpty final initials = member.pseudo.isNotEmpty
? member.pseudo[0].toUpperCase() ? 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; final name = member.pseudo.isNotEmpty ? member.pseudo : member.firstName;
@@ -729,11 +736,14 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: CircleAvatar( child: CircleAvatar(
radius: 28, radius: 28,
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2),
backgroundImage: (member.profilePictureUrl != null && backgroundImage:
member.profilePictureUrl!.isNotEmpty) (member.profilePictureUrl != null &&
member.profilePictureUrl!.isNotEmpty)
? NetworkImage(member.profilePictureUrl!) ? NetworkImage(member.profilePictureUrl!)
: null, : null,
child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty) child:
(member.profilePictureUrl == null ||
member.profilePictureUrl!.isEmpty)
? Text( ? Text(
initials, initials,
style: TextStyle( style: TextStyle(
@@ -774,11 +784,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: CircleAvatar( child: CircleAvatar(
radius: 28, radius: 28,
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1), backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
child: Icon( child: Icon(Icons.add, color: theme.colorScheme.primary, size: 28),
Icons.add,
color: theme.colorScheme.primary,
size: 28,
),
), ),
), ),
), ),
@@ -869,7 +875,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
try { try {
// Chercher l'utilisateur par email // Chercher l'utilisateur par email
final user = await _userRepository.getUserByEmail(email); final user = await _userRepository.getUserByEmail(email);
if (user == null) { if (user == null) {
_errorService.showError( _errorService.showError(
message: 'Utilisateur non trouvé avec cet email', message: 'Utilisateur non trouvé avec cet email',
@@ -878,9 +884,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
} }
if (user.id == null) { if (user.id == null) {
_errorService.showError( _errorService.showError(message: 'ID utilisateur invalide');
message: 'ID utilisateur invalide',
);
return; return;
} }
@@ -901,20 +905,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await _groupRepository.addMember(group.id, newMember); await _groupRepository.addMember(group.id, newMember);
// Ajouter le membre au compte // Ajouter le membre au compte
final account = await _accountRepository.getAccountByTripId(widget.trip.id!); final account = await _accountRepository.getAccountByTripId(
widget.trip.id!,
);
if (account != null) { if (account != null) {
await _accountRepository.addMemberToAccount(account.id, newMember); await _accountRepository.addMemberToAccount(account.id, newMember);
} }
// Mettre à jour la liste des participants du voyage // Mettre à jour la liste des participants du voyage
final newParticipants = [ final newParticipants = [...widget.trip.participants, user.id!];
...widget.trip.participants,
user.id!,
];
final updatedTrip = widget.trip.copyWith( final updatedTrip = widget.trip.copyWith(
participants: newParticipants, participants: newParticipants,
); );
if (mounted) { if (mounted) {
context.read<TripBloc>().add( context.read<TripBloc>().add(
TripUpdateRequested(trip: updatedTrip), TripUpdateRequested(trip: updatedTrip),

View File

@@ -20,6 +20,7 @@ class Activity {
final Map<String, int> votes; // userId -> vote (1 pour pour, -1 pour contre) final Map<String, int> votes; // userId -> vote (1 pour pour, -1 pour contre)
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final DateTime? date; // Date prévue pour l'activité
Activity({ Activity({
required this.id, required this.id,
@@ -40,6 +41,7 @@ class Activity {
this.votes = const {}, this.votes = const {},
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.date,
}); });
/// Calcule le score total des votes /// Calcule le score total des votes
@@ -104,6 +106,8 @@ class Activity {
Map<String, int>? votes, Map<String, int>? votes,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? date,
bool clearDate = false,
}) { }) {
return Activity( return Activity(
id: id ?? this.id, id: id ?? this.id,
@@ -124,6 +128,7 @@ class Activity {
votes: votes ?? this.votes, votes: votes ?? this.votes,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
date: clearDate ? null : (date ?? this.date),
); );
} }
@@ -148,6 +153,7 @@ class Activity {
'votes': votes, 'votes': votes,
'createdAt': Timestamp.fromDate(createdAt), 'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt), 'updatedAt': Timestamp.fromDate(updatedAt),
'date': date != null ? Timestamp.fromDate(date!) : null,
}; };
} }
@@ -172,6 +178,7 @@ class Activity {
votes: Map<String, int>.from(map['votes'] ?? {}), votes: Map<String, int>.from(map['votes'] ?? {}),
createdAt: (map['createdAt'] as Timestamp).toDate(), createdAt: (map['createdAt'] as Timestamp).toDate(),
updatedAt: (map['updatedAt'] as Timestamp).toDate(), updatedAt: (map['updatedAt'] as Timestamp).toDate(),
date: map['date'] != null ? (map['date'] as Timestamp).toDate() : null,
); );
} }

View File

@@ -17,17 +17,22 @@ class ActivityRepository {
Future<String?> addActivity(Activity activity) async { Future<String?> addActivity(Activity activity) async {
try { try {
print('ActivityRepository: Ajout d\'une activité: ${activity.name}'); 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é // Mettre à jour l'activité avec l'ID généré
await docRef.update({'id': docRef.id}); await docRef.update({'id': docRef.id});
print('ActivityRepository: Activité ajoutée avec ID: ${docRef.id}'); print('ActivityRepository: Activité ajoutée avec ID: ${docRef.id}');
return docRef.id; return docRef.id;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur lors de l\'ajout: $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; return null;
} }
} }
@@ -35,8 +40,10 @@ class ActivityRepository {
/// Récupère toutes les activités d'un voyage /// Récupère toutes les activités d'un voyage
Future<List<Activity>> getActivitiesByTrip(String tripId) async { Future<List<Activity>> getActivitiesByTrip(String tripId) async {
try { 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 // Modifié pour éviter l'erreur d'index composite
// On récupère d'abord par tripId, puis on trie en mémoire // On récupère d'abord par tripId, puis on trie en mémoire
final querySnapshot = await _firestore final querySnapshot = await _firestore
@@ -55,24 +62,38 @@ class ActivityRepository {
return activities; return activities;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur lors de la récupération: $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 []; return [];
} }
} }
/// Récupère une activité par son ID (alias pour getActivityById pour compatibilité)
Future<Activity?> getActivity(String tripId, String activityId) async {
return getActivityById(activityId);
}
/// Récupère une activité par son ID /// Récupère une activité par son ID
Future<Activity?> getActivityById(String activityId) async { Future<Activity?> getActivityById(String activityId) async {
try { try {
final doc = await _firestore.collection(_collection).doc(activityId).get(); final doc = await _firestore
.collection(_collection)
.doc(activityId)
.get();
if (doc.exists) { if (doc.exists) {
return Activity.fromSnapshot(doc); return Activity.fromSnapshot(doc);
} }
return null; return null;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur récupération activité: $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; return null;
} }
} }
@@ -81,17 +102,20 @@ class ActivityRepository {
Future<bool> updateActivity(Activity activity) async { Future<bool> updateActivity(Activity activity) async {
try { try {
print('ActivityRepository: Mise à jour de l\'activité: ${activity.id}'); print('ActivityRepository: Mise à jour de l\'activité: ${activity.id}');
await _firestore await _firestore
.collection(_collection) .collection(_collection)
.doc(activity.id) .doc(activity.id)
.update(activity.copyWith(updatedAt: DateTime.now()).toMap()); .update(activity.copyWith(updatedAt: DateTime.now()).toMap());
print('ActivityRepository: Activité mise à jour avec succès'); print('ActivityRepository: Activité mise à jour avec succès');
return true; return true;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur lors de la mise à jour: $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; return false;
} }
} }
@@ -100,49 +124,62 @@ class ActivityRepository {
Future<bool> deleteActivity(String activityId) async { Future<bool> deleteActivity(String activityId) async {
try { try {
print('ActivityRepository: Suppression de l\'activité: $activityId'); print('ActivityRepository: Suppression de l\'activité: $activityId');
await _firestore.collection(_collection).doc(activityId).delete(); await _firestore.collection(_collection).doc(activityId).delete();
print('ActivityRepository: Activité supprimée avec succès'); print('ActivityRepository: Activité supprimée avec succès');
return true; return true;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur lors de la suppression: $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; return false;
} }
} }
/// Vote pour une activité /// Vote pour une activité
Future<bool> voteForActivity(String activityId, String userId, int vote) async { Future<bool> voteForActivity(
String activityId,
String userId,
int vote,
) async {
try { try {
// Validation des paramètres // Validation des paramètres
if (activityId.isEmpty) { if (activityId.isEmpty) {
print('ActivityRepository: ID d\'activité vide'); 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; return false;
} }
if (userId.isEmpty) { if (userId.isEmpty) {
print('ActivityRepository: ID d\'utilisateur vide'); 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; return false;
} }
print('ActivityRepository: Vote pour l\'activité $activityId: $vote'); print('ActivityRepository: Vote pour l\'activité $activityId: $vote');
// vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote // vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote
final activityRef = _firestore.collection(_collection).doc(activityId); final activityRef = _firestore.collection(_collection).doc(activityId);
await _firestore.runTransaction((transaction) async { await _firestore.runTransaction((transaction) async {
final snapshot = await transaction.get(activityRef); final snapshot = await transaction.get(activityRef);
if (!snapshot.exists) { if (!snapshot.exists) {
throw Exception('Activité non trouvée'); throw Exception('Activité non trouvée');
} }
final activity = Activity.fromSnapshot(snapshot); final activity = Activity.fromSnapshot(snapshot);
final newVotes = Map<String, int>.from(activity.votes); final newVotes = Map<String, int>.from(activity.votes);
if (vote == 0) { if (vote == 0) {
// Supprimer le vote // Supprimer le vote
newVotes.remove(userId); newVotes.remove(userId);
@@ -150,13 +187,13 @@ class ActivityRepository {
// Ajouter ou modifier le vote // Ajouter ou modifier le vote
newVotes[userId] = vote; newVotes[userId] = vote;
} }
transaction.update(activityRef, { transaction.update(activityRef, {
'votes': newVotes, 'votes': newVotes,
'updatedAt': Timestamp.fromDate(DateTime.now()), 'updatedAt': Timestamp.fromDate(DateTime.now()),
}); });
}); });
print('ActivityRepository: Vote enregistré avec succès'); print('ActivityRepository: Vote enregistré avec succès');
return true; return true;
} catch (e) { } catch (e) {
@@ -174,18 +211,21 @@ class ActivityRepository {
.where('tripId', isEqualTo: tripId) .where('tripId', isEqualTo: tripId)
.snapshots() .snapshots()
.map((snapshot) { .map((snapshot) {
final activities = snapshot.docs final activities = snapshot.docs
.map((doc) => Activity.fromSnapshot(doc)) .map((doc) => Activity.fromSnapshot(doc))
.toList(); .toList();
// Tri en mémoire par date de mise à jour (plus récent en premier) // Tri en mémoire par date de mise à jour (plus récent en premier)
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return activities; return activities;
}); });
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur stream activités: $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([]); return Stream.value([]);
} }
} }
@@ -193,20 +233,22 @@ class ActivityRepository {
/// Ajoute plusieurs activités en lot /// Ajoute plusieurs activités en lot
Future<List<String>> addActivitiesBatch(List<Activity> activities) async { Future<List<String>> addActivitiesBatch(List<Activity> activities) async {
try { 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 batch = _firestore.batch();
final addedIds = <String>[]; final addedIds = <String>[];
for (final activity in activities) { for (final activity in activities) {
final docRef = _firestore.collection(_collection).doc(); final docRef = _firestore.collection(_collection).doc();
final activityWithId = activity.copyWith(id: docRef.id); final activityWithId = activity.copyWith(id: docRef.id);
batch.set(docRef, activityWithId.toMap()); batch.set(docRef, activityWithId.toMap());
addedIds.add(docRef.id); addedIds.add(docRef.id);
} }
await batch.commit(); await batch.commit();
print('ActivityRepository: ${addedIds.length} activités ajoutées en lot'); print('ActivityRepository: ${addedIds.length} activités ajoutées en lot');
return addedIds; return addedIds;
} catch (e) { } catch (e) {
@@ -217,10 +259,15 @@ class ActivityRepository {
} }
/// Recherche des activités par catégorie /// Recherche des activités par catégorie
Future<List<Activity>> getActivitiesByCategory(String tripId, String category) async { Future<List<Activity>> getActivitiesByCategory(
String tripId,
String category,
) async {
try { 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 // Récupérer toutes les activités du voyage puis filtrer en mémoire
final querySnapshot = await _firestore final querySnapshot = await _firestore
.collection(_collection) .collection(_collection)
@@ -238,28 +285,34 @@ class ActivityRepository {
return activities; return activities;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur recherche par catégorie: $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 []; return [];
} }
} }
/// Récupère les activités les mieux notées d'un voyage /// Récupère les activités les mieux notées d'un voyage
Future<List<Activity>> getTopRatedActivities(String tripId, {int limit = 10}) async { Future<List<Activity>> getTopRatedActivities(
String tripId, {
int limit = 10,
}) async {
try { try {
final activities = await getActivitiesByTrip(tripId); final activities = await getActivitiesByTrip(tripId);
// Trier par nombre de votes positifs puis par note Google // Trier par nombre de votes positifs puis par note Google
activities.sort((a, b) { activities.sort((a, b) {
final aScore = a.totalVotes; final aScore = a.totalVotes;
final bScore = b.totalVotes; final bScore = b.totalVotes;
if (aScore != bScore) { if (aScore != bScore) {
return bScore.compareTo(aScore); return bScore.compareTo(aScore);
} }
return (b.rating ?? 0).compareTo(a.rating ?? 0); return (b.rating ?? 0).compareTo(a.rating ?? 0);
}); });
return activities.take(limit).toList(); return activities.take(limit).toList();
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur activités top rated: $e'); print('ActivityRepository: Erreur activités top rated: $e');
@@ -281,11 +334,11 @@ class ActivityRepository {
if (querySnapshot.docs.isNotEmpty) { if (querySnapshot.docs.isNotEmpty) {
return Activity.fromSnapshot(querySnapshot.docs.first); return Activity.fromSnapshot(querySnapshot.docs.first);
} }
return null; return null;
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur recherche activité existante: $e'); print('ActivityRepository: Erreur recherche activité existante: $e');
return null; return null;
} }
} }
} }

View File

@@ -1024,6 +1024,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1125,6 +1133,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:

View File

@@ -37,6 +37,7 @@ dependencies:
location: ^8.0.1 location: ^8.0.1
flutter_bloc : ^9.1.1 flutter_bloc : ^9.1.1
equatable: ^2.0.5 equatable: ^2.0.5
table_calendar: ^3.1.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.