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

View File

@@ -50,7 +50,15 @@ class SearchActivities extends ActivityEvent {
});
@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)
@@ -76,7 +84,16 @@ class SearchActivitiesWithCoordinates extends ActivityEvent {
});
@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
@@ -95,6 +112,21 @@ class SearchActivitiesByText extends ActivityEvent {
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
class AddActivity extends ActivityEvent {
final Activity activity;
@@ -147,11 +179,7 @@ class FilterActivities extends ActivityEvent {
final double? minRating;
final bool? showVotedOnly;
const FilterActivities({
this.category,
this.minRating,
this.showVotedOnly,
});
const FilterActivities({this.category, this.minRating, this.showVotedOnly});
@override
List<Object?> get props => [category, minRating, showVotedOnly];
@@ -228,4 +256,4 @@ class AddActivityAndRemoveFromSearch extends ActivityEvent {
@override
List<Object> get props => [activity, googleActivityId];
}
}

View File

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