feat: Implement activity management feature with Firestore integration

- Added AddActivityBottomSheet for adding custom activities to trips.
- Created Activity model to represent tourist activities.
- Developed ActivityRepository for managing activities in Firestore.
- Integrated ActivityPlacesService for searching activities via Google Places API.
- Updated ShowTripDetailsContent to navigate to activities page.
- Enhanced main.dart to include ActivityBloc and necessary repositories.
This commit is contained in:
Dayron
2025-11-03 16:40:33 +01:00
parent 64fcc88984
commit 8ff9e12fd4
11 changed files with 3185 additions and 1 deletions

View File

@@ -0,0 +1,409 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/activity.dart';
import '../../repositories/activity_repository.dart';
import '../../services/activity_places_service.dart';
import '../../services/error_service.dart';
import 'activity_event.dart';
import 'activity_state.dart';
/// BLoC for managing activity-related state and operations
class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
final ActivityRepository _repository;
final ActivityPlacesService _placesService;
final ErrorService _errorService;
ActivityBloc({
required ActivityRepository repository,
required ActivityPlacesService placesService,
required ErrorService errorService,
}) : _repository = repository,
_placesService = placesService,
_errorService = errorService,
super(const ActivityInitial()) {
on<LoadActivities>(_onLoadActivities);
on<SearchActivities>(_onSearchActivities);
on<SearchActivitiesByText>(_onSearchActivitiesByText);
on<AddActivity>(_onAddActivity);
on<AddActivitiesBatch>(_onAddActivitiesBatch);
on<VoteForActivity>(_onVoteForActivity);
on<DeleteActivity>(_onDeleteActivity);
on<FilterActivities>(_onFilterActivities);
on<RefreshActivities>(_onRefreshActivities);
on<ClearSearchResults>(_onClearSearchResults);
on<UpdateActivity>(_onUpdateActivity);
on<ToggleActivityFavorite>(_onToggleActivityFavorite);
}
/// Handles loading activities for a trip
Future<void> _onLoadActivities(
LoadActivities event,
Emitter<ActivityState> emit,
) async {
try {
emit(const ActivityLoading());
final activities = await _repository.getActivitiesByTrip(event.tripId);
emit(ActivityLoaded(
activities: activities,
filteredActivities: activities,
));
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur chargement activités: $e');
emit(const ActivityError('Impossible de charger les activités'));
}
}
/// Handles searching activities using Google Places API
Future<void> _onSearchActivities(
SearchActivities event,
Emitter<ActivityState> emit,
) async {
try {
emit(const ActivitySearching());
final searchResults = await _placesService.searchActivities(
destination: event.destination,
tripId: event.tripId,
category: event.category,
);
emit(ActivitySearchResults(
searchResults: searchResults,
query: event.category?.displayName ?? 'Toutes les activités',
));
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur recherche activités: $e');
emit(const ActivityError('Impossible de rechercher les activités'));
}
}
/// Handles text-based activity search
Future<void> _onSearchActivitiesByText(
SearchActivitiesByText event,
Emitter<ActivityState> emit,
) async {
try {
emit(const ActivitySearching());
final searchResults = await _placesService.searchActivitiesByText(
query: event.query,
destination: event.destination,
tripId: event.tripId,
);
emit(ActivitySearchResults(
searchResults: searchResults,
query: event.query,
));
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur recherche textuelle: $e');
emit(const ActivityError('Impossible de rechercher les activités'));
}
}
/// Handles adding a single activity
Future<void> _onAddActivity(
AddActivity event,
Emitter<ActivityState> emit,
) async {
try {
// Check if activity already exists
if (event.activity.placeId != null) {
final existing = await _repository.findExistingActivity(
event.activity.tripId,
event.activity.placeId!,
);
if (existing != null) {
emit(const ActivityError('Cette activité a déjà été ajoutée'));
return;
}
}
final activityId = await _repository.addActivity(event.activity);
if (activityId != null) {
emit(ActivityAdded(
activity: event.activity.copyWith(id: activityId),
message: 'Activité ajoutée avec succès',
));
// Reload activities
add(LoadActivities(event.activity.tripId));
} else {
emit(const ActivityError('Impossible d\'ajouter l\'activité'));
}
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur ajout activité: $e');
emit(const ActivityError('Impossible d\'ajouter l\'activité'));
}
}
/// Handles adding multiple activities in batch
Future<void> _onAddActivitiesBatch(
AddActivitiesBatch event,
Emitter<ActivityState> emit,
) async {
try {
// Filter out existing activities
final filteredActivities = <Activity>[];
emit(ActivityBatchAdding(
activitiesToAdd: event.activities,
progress: 0,
total: event.activities.length,
));
for (int i = 0; i < event.activities.length; i++) {
final activity = event.activities[i];
if (activity.placeId != null) {
final existing = await _repository.findExistingActivity(
activity.tripId,
activity.placeId!,
);
if (existing == null) {
filteredActivities.add(activity);
}
} else {
filteredActivities.add(activity);
}
// Update progress
emit(ActivityBatchAdding(
activitiesToAdd: event.activities,
progress: i + 1,
total: event.activities.length,
));
}
if (filteredActivities.isEmpty) {
emit(const ActivityError('Toutes les activités ont déjà été ajoutées'));
return;
}
final addedIds = await _repository.addActivitiesBatch(filteredActivities);
if (addedIds.isNotEmpty) {
emit(ActivityOperationSuccess(
'${addedIds.length} activité(s) ajoutée(s) avec succès',
operationType: 'batch_add',
));
// Reload activities
add(LoadActivities(event.activities.first.tripId));
} else {
emit(const ActivityError('Impossible d\'ajouter les activités'));
}
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur ajout en lot: $e');
emit(const ActivityError('Impossible d\'ajouter les activités'));
}
}
/// Handles voting for an activity
Future<void> _onVoteForActivity(
VoteForActivity event,
Emitter<ActivityState> emit,
) async {
try {
// Show voting state
if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded;
emit(ActivityVoting(
activityId: event.activityId,
activities: currentState.activities,
));
}
final success = await _repository.voteForActivity(
event.activityId,
event.userId,
event.vote,
);
if (success) {
emit(ActivityVoteRecorded(
activityId: event.activityId,
vote: event.vote,
userId: event.userId,
));
// Reload activities to reflect the new vote
if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded;
final activities = await _repository.getActivitiesByTrip(
currentState.activities.first.tripId,
);
emit(currentState.copyWith(
activities: activities,
filteredActivities: _applyFilters(
activities,
currentState.activeFilter,
currentState.minRating,
currentState.showVotedOnly,
event.userId,
),
));
}
} else {
emit(const ActivityError('Impossible d\'enregistrer le vote'));
}
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur vote: $e');
emit(const ActivityError('Impossible d\'enregistrer le vote'));
}
}
/// Handles deleting an activity
Future<void> _onDeleteActivity(
DeleteActivity event,
Emitter<ActivityState> emit,
) async {
try {
final success = await _repository.deleteActivity(event.activityId);
if (success) {
emit(ActivityDeleted(
activityId: event.activityId,
message: 'Activité supprimée avec succès',
));
// Reload if we're on the activity list
if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded;
if (currentState.activities.isNotEmpty) {
add(LoadActivities(currentState.activities.first.tripId));
}
}
} else {
emit(const ActivityError('Impossible de supprimer l\'activité'));
}
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur suppression: $e');
emit(const ActivityError('Impossible de supprimer l\'activité'));
}
}
/// Handles filtering activities
Future<void> _onFilterActivities(
FilterActivities event,
Emitter<ActivityState> emit,
) async {
if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded;
final filteredActivities = _applyFilters(
currentState.activities,
event.category,
event.minRating,
event.showVotedOnly ?? false,
'', // UserId would be needed for showVotedOnly filter
);
emit(currentState.copyWith(
filteredActivities: filteredActivities,
activeFilter: event.category,
minRating: event.minRating,
showVotedOnly: event.showVotedOnly ?? false,
));
}
}
/// Handles refreshing activities
Future<void> _onRefreshActivities(
RefreshActivities event,
Emitter<ActivityState> emit,
) async {
add(LoadActivities(event.tripId));
}
/// Handles clearing search results
Future<void> _onClearSearchResults(
ClearSearchResults event,
Emitter<ActivityState> emit,
) async {
if (state is ActivitySearchResults) {
emit(const ActivityInitial());
}
}
/// Handles updating an activity
Future<void> _onUpdateActivity(
UpdateActivity event,
Emitter<ActivityState> emit,
) async {
try {
if (state is ActivityLoaded) {
final currentState = state as ActivityLoaded;
emit(ActivityUpdating(
activityId: event.activity.id,
activities: currentState.activities,
));
}
final success = await _repository.updateActivity(event.activity);
if (success) {
emit(const ActivityOperationSuccess(
'Activité mise à jour avec succès',
operationType: 'update',
));
// Reload activities
add(LoadActivities(event.activity.tripId));
} else {
emit(const ActivityError('Impossible de mettre à jour l\'activité'));
}
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur mise à jour: $e');
emit(const ActivityError('Impossible de mettre à jour l\'activité'));
}
}
/// Handles toggling activity favorite status
Future<void> _onToggleActivityFavorite(
ToggleActivityFavorite event,
Emitter<ActivityState> emit,
) async {
try {
// This would require extending the Activity model to include favorites
// For now, we'll use the voting system as a favorite system
add(VoteForActivity(
activityId: event.activityId,
userId: event.userId,
vote: 1,
));
} catch (e) {
_errorService.logError('activity_bloc', 'Erreur favori: $e');
emit(const ActivityError('Impossible de modifier les favoris'));
}
}
/// Applies filters to the activities list
List<Activity> _applyFilters(
List<Activity> activities,
String? category,
double? minRating,
bool showVotedOnly,
String userId,
) {
var filtered = activities;
if (category != null) {
filtered = filtered.where((a) => a.category == category).toList();
}
if (minRating != null) {
filtered = filtered.where((a) => (a.rating ?? 0) >= minRating).toList();
}
if (showVotedOnly && userId.isNotEmpty) {
filtered = filtered.where((a) => a.hasUserVoted(userId)).toList();
}
return filtered;
}
}

View File

@@ -0,0 +1,153 @@
import 'package:equatable/equatable.dart';
import '../../models/activity.dart';
/// Base class for all activity-related events
abstract class ActivityEvent extends Equatable {
const ActivityEvent();
@override
List<Object?> get props => [];
}
/// Event to load activities for a specific trip
class LoadActivities extends ActivityEvent {
final String tripId;
const LoadActivities(this.tripId);
@override
List<Object> get props => [tripId];
}
/// Event to search activities using Google Places API
class SearchActivities extends ActivityEvent {
final String tripId;
final String destination;
final ActivityCategory? category;
const SearchActivities({
required this.tripId,
required this.destination,
this.category,
});
@override
List<Object?> get props => [tripId, destination, category];
}
/// Event to search activities by text query
class SearchActivitiesByText extends ActivityEvent {
final String tripId;
final String destination;
final String query;
const SearchActivitiesByText({
required this.tripId,
required this.destination,
required this.query,
});
@override
List<Object> get props => [tripId, destination, query];
}
/// Event to add a single activity to the trip
class AddActivity extends ActivityEvent {
final Activity activity;
const AddActivity(this.activity);
@override
List<Object> get props => [activity];
}
/// Event to add multiple activities at once
class AddActivitiesBatch extends ActivityEvent {
final List<Activity> activities;
const AddActivitiesBatch(this.activities);
@override
List<Object> get props => [activities];
}
/// Event to vote for an activity
class VoteForActivity extends ActivityEvent {
final String activityId;
final String userId;
final int vote; // 1 for positive, -1 for negative, 0 to remove vote
const VoteForActivity({
required this.activityId,
required this.userId,
required this.vote,
});
@override
List<Object> get props => [activityId, userId, vote];
}
/// Event to delete an activity
class DeleteActivity extends ActivityEvent {
final String activityId;
const DeleteActivity(this.activityId);
@override
List<Object> get props => [activityId];
}
/// Event to filter activities
class FilterActivities extends ActivityEvent {
final String? category;
final double? minRating;
final bool? showVotedOnly;
const FilterActivities({
this.category,
this.minRating,
this.showVotedOnly,
});
@override
List<Object?> get props => [category, minRating, showVotedOnly];
}
/// Event to refresh activities
class RefreshActivities extends ActivityEvent {
final String tripId;
const RefreshActivities(this.tripId);
@override
List<Object> get props => [tripId];
}
/// Event to clear search results
class ClearSearchResults extends ActivityEvent {
const ClearSearchResults();
}
/// Event to update activity details
class UpdateActivity extends ActivityEvent {
final Activity activity;
const UpdateActivity(this.activity);
@override
List<Object> get props => [activity];
}
/// Event to toggle favorite status
class ToggleActivityFavorite extends ActivityEvent {
final String activityId;
final String userId;
const ToggleActivityFavorite({
required this.activityId,
required this.userId,
});
@override
List<Object> get props => [activityId, userId];
}

View File

@@ -0,0 +1,240 @@
import 'package:equatable/equatable.dart';
import '../../models/activity.dart';
/// Base class for all activity-related states
abstract class ActivityState extends Equatable {
const ActivityState();
@override
List<Object?> get props => [];
}
/// Initial state when no activities have been loaded
class ActivityInitial extends ActivityState {
const ActivityInitial();
}
/// State when activities are being loaded
class ActivityLoading extends ActivityState {
const ActivityLoading();
}
/// State when activities are being searched
class ActivitySearching extends ActivityState {
const ActivitySearching();
}
/// State when activities have been loaded successfully
class ActivityLoaded extends ActivityState {
final List<Activity> activities;
final List<Activity> filteredActivities;
final String? activeFilter;
final double? minRating;
final bool showVotedOnly;
const ActivityLoaded({
required this.activities,
required this.filteredActivities,
this.activeFilter,
this.minRating,
this.showVotedOnly = false,
});
@override
List<Object?> get props => [
activities,
filteredActivities,
activeFilter,
minRating,
showVotedOnly,
];
/// Creates a copy of the current state with optional modifications
ActivityLoaded copyWith({
List<Activity>? activities,
List<Activity>? filteredActivities,
String? activeFilter,
double? minRating,
bool? showVotedOnly,
}) {
return ActivityLoaded(
activities: activities ?? this.activities,
filteredActivities: filteredActivities ?? this.filteredActivities,
activeFilter: activeFilter ?? this.activeFilter,
minRating: minRating ?? this.minRating,
showVotedOnly: showVotedOnly ?? this.showVotedOnly,
);
}
/// Gets activities by category
List<Activity> getActivitiesByCategory(String category) {
return activities.where((activity) => activity.category == category).toList();
}
/// Gets top rated activities
List<Activity> getTopRatedActivities({int limit = 10}) {
final sorted = List<Activity>.from(activities);
sorted.sort((a, b) {
final aScore = a.totalVotes;
final bScore = b.totalVotes;
if (aScore != bScore) {
return bScore.compareTo(aScore);
}
return (b.rating ?? 0).compareTo(a.rating ?? 0);
});
return sorted.take(limit).toList();
}
}
/// State when search results are available
class ActivitySearchResults extends ActivityState {
final List<Activity> searchResults;
final String query;
final bool isLoading;
const ActivitySearchResults({
required this.searchResults,
required this.query,
this.isLoading = false,
});
@override
List<Object> get props => [searchResults, query, isLoading];
/// Creates a copy with optional modifications
ActivitySearchResults copyWith({
List<Activity>? searchResults,
String? query,
bool? isLoading,
}) {
return ActivitySearchResults(
searchResults: searchResults ?? this.searchResults,
query: query ?? this.query,
isLoading: isLoading ?? this.isLoading,
);
}
}
/// State when an operation has completed successfully
class ActivityOperationSuccess extends ActivityState {
final String message;
final String? operationType;
const ActivityOperationSuccess(
this.message, {
this.operationType,
});
@override
List<Object?> get props => [message, operationType];
}
/// State when an error has occurred
class ActivityError extends ActivityState {
final String message;
final String? errorCode;
final dynamic error;
const ActivityError(
this.message, {
this.errorCode,
this.error,
});
@override
List<Object?> get props => [message, errorCode, error];
}
/// State when voting is in progress
class ActivityVoting extends ActivityState {
final String activityId;
final List<Activity> activities;
const ActivityVoting({
required this.activityId,
required this.activities,
});
@override
List<Object> get props => [activityId, activities];
}
/// State when activity is being updated
class ActivityUpdating extends ActivityState {
final String activityId;
final List<Activity> activities;
const ActivityUpdating({
required this.activityId,
required this.activities,
});
@override
List<Object> get props => [activityId, activities];
}
/// State when activities are being added in batch
class ActivityBatchAdding extends ActivityState {
final List<Activity> activitiesToAdd;
final int progress;
final int total;
const ActivityBatchAdding({
required this.activitiesToAdd,
required this.progress,
required this.total,
});
@override
List<Object> get props => [activitiesToAdd, progress, total];
/// Gets the progress as a percentage
double get progressPercentage => total > 0 ? progress / total : 0.0;
}
/// State when an activity has been successfully added
class ActivityAdded extends ActivityState {
final Activity activity;
final String message;
const ActivityAdded({
required this.activity,
required this.message,
});
@override
List<Object> get props => [activity, message];
}
/// State when an activity has been successfully deleted
class ActivityDeleted extends ActivityState {
final String activityId;
final String message;
const ActivityDeleted({
required this.activityId,
required this.message,
});
@override
List<Object> get props => [activityId, message];
}
/// State when vote has been successfully recorded
class ActivityVoteRecorded extends ActivityState {
final String activityId;
final int vote;
final String userId;
const ActivityVoteRecorded({
required this.activityId,
required this.vote,
required this.userId,
});
@override
List<Object> get props => [activityId, vote, userId];
}