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:
409
lib/blocs/activity/activity_bloc.dart
Normal file
409
lib/blocs/activity/activity_bloc.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
153
lib/blocs/activity/activity_event.dart
Normal file
153
lib/blocs/activity/activity_event.dart
Normal 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];
|
||||
}
|
||||
240
lib/blocs/activity/activity_state.dart
Normal file
240
lib/blocs/activity/activity_state.dart
Normal 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];
|
||||
}
|
||||
742
lib/components/activities/activities_page.dart
Normal file
742
lib/components/activities/activities_page.dart
Normal file
@@ -0,0 +1,742 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/activity/activity_bloc.dart';
|
||||
import '../../blocs/activity/activity_event.dart';
|
||||
import '../../blocs/activity/activity_state.dart';
|
||||
import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart' as user_state;
|
||||
import '../../models/activity.dart';
|
||||
import '../../models/trip.dart';
|
||||
import '../../services/error_service.dart';
|
||||
import 'activity_card.dart';
|
||||
import 'add_activity_bottom_sheet.dart';
|
||||
|
||||
/// Page principale des activités pour un voyage
|
||||
class ActivitiesPage extends StatefulWidget {
|
||||
final Trip trip;
|
||||
|
||||
const ActivitiesPage({
|
||||
Key? key,
|
||||
required this.trip,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ActivitiesPage> createState() => _ActivitiesPageState();
|
||||
}
|
||||
|
||||
class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final ErrorService _errorService = ErrorService();
|
||||
String? _selectedCategory;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
|
||||
// Charger les activités
|
||||
if (widget.trip.id != null) {
|
||||
context.read<ActivityBloc>().add(LoadActivities(widget.trip.id!));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDarkMode = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
body: BlocListener<ActivityBloc, ActivityState>(
|
||||
listener: (context, state) {
|
||||
if (state is ActivityOperationSuccess) {
|
||||
_errorService.showSnackbar(message: state.message, isError: false);
|
||||
} else if (state is ActivityError) {
|
||||
_errorService.showSnackbar(message: state.message, isError: true);
|
||||
}
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// AppBar personnalisée
|
||||
SliverAppBar(
|
||||
expandedHeight: 120,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.person_add, color: theme.colorScheme.onSurface),
|
||||
onPressed: () {
|
||||
// TODO: Ajouter participants
|
||||
},
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.only(left: 16, bottom: 16),
|
||||
title: Text(
|
||||
'Voyage à ${widget.trip.location}',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Barre de recherche
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDarkMode
|
||||
? Colors.white.withOpacity(0.1)
|
||||
: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher restaurants, musées...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
onSubmitted: (query) {
|
||||
if (query.isNotEmpty) {
|
||||
context.read<ActivityBloc>().add(
|
||||
SearchActivitiesByText(
|
||||
tripId: widget.trip.id!,
|
||||
destination: widget.trip.location,
|
||||
query: query,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Filtres par catégorie
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Bouton suggestions Google
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _searchGoogleActivities,
|
||||
icon: const Icon(Icons.place),
|
||||
label: const Text('Découvrir des activités avec Google'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Catégories populaires
|
||||
Text(
|
||||
'Catégories populaires',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildCategoryButton(ActivityCategory.attraction, 'Attractions'),
|
||||
const SizedBox(width: 8),
|
||||
_buildCategoryButton(ActivityCategory.restaurant, 'Restaurants'),
|
||||
const SizedBox(width: 8),
|
||||
_buildCategoryButton(ActivityCategory.museum, 'Musées'),
|
||||
const SizedBox(width: 8),
|
||||
_buildCategoryButton(ActivityCategory.nature, 'Nature'),
|
||||
const SizedBox(width: 8),
|
||||
_buildCategoryButton(ActivityCategory.culture, 'Culture'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres
|
||||
Row(
|
||||
children: [
|
||||
_buildFilterChip(
|
||||
label: 'Catégorie',
|
||||
icon: Icons.filter_list,
|
||||
isActive: _selectedCategory != null,
|
||||
onTap: _showCategoryFilter,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip(
|
||||
label: 'Prix',
|
||||
icon: Icons.euro,
|
||||
isActive: false,
|
||||
onTap: _showPriceFilter,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip(
|
||||
label: 'Heure',
|
||||
icon: Icons.access_time,
|
||||
isActive: false,
|
||||
onTap: () {
|
||||
// TODO: Filtre par heure
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Onglets
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicator: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: const [
|
||||
Tab(text: 'Suggestions'),
|
||||
Tab(text: 'Activités votées'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu des onglets
|
||||
SliverFillRemaining(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSuggestionsTab(),
|
||||
_buildVotedActivitiesTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton flottant pour ajouter une activité
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddActivitySheet(),
|
||||
backgroundColor: Colors.blue,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required bool isActive,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.blue : theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isActive
|
||||
? Colors.blue
|
||||
: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionsTab() {
|
||||
return BlocBuilder<ActivityBloc, ActivityState>(
|
||||
builder: (context, state) {
|
||||
if (state is ActivityLoading || state is ActivitySearching) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is ActivitySearchResults) {
|
||||
return _buildActivityList(state.searchResults, isSearchResults: true);
|
||||
}
|
||||
|
||||
if (state is ActivityLoaded) {
|
||||
return _buildActivityList(state.filteredActivities);
|
||||
}
|
||||
|
||||
// État initial - montrer les suggestions par défaut
|
||||
return _buildInitialSuggestions();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVotedActivitiesTab() {
|
||||
return BlocBuilder<ActivityBloc, ActivityState>(
|
||||
builder: (context, state) {
|
||||
if (state is ActivityLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is ActivityLoaded) {
|
||||
// Filtrer les activités avec des votes
|
||||
final votedActivities = state.activities
|
||||
.where((activity) => activity.votes.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// Trier par score de vote
|
||||
votedActivities.sort((a, b) => b.totalVotes.compareTo(a.totalVotes));
|
||||
|
||||
return _buildActivityList(votedActivities);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Text('Aucune activité votée pour le moment'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityList(List<Activity> activities, {bool isSearchResults = false}) {
|
||||
if (activities.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isSearchResults
|
||||
? 'Aucun résultat trouvé'
|
||||
: 'Aucune activité pour le moment',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isSearchResults
|
||||
? 'Essayez une autre recherche ou explorez les catégories'
|
||||
: 'Utilisez le bouton "Découvrir des activités avec Google" pour commencer',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isSearchResults) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<ActivityBloc>().add(LoadActivities(widget.trip.id!));
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Retour aux suggestions'),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _searchGoogleActivities,
|
||||
icon: const Icon(Icons.place),
|
||||
label: const Text('Découvrir des activités'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
final currentUserId = userState is user_state.UserLoaded
|
||||
? userState.user.id
|
||||
: '';
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: activities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final activity = activities[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ActivityCard(
|
||||
activity: activity,
|
||||
currentUserId: currentUserId,
|
||||
onVote: (vote) => _handleVote(activity.id, currentUserId, vote),
|
||||
onAddToTrip: isSearchResults
|
||||
? () => _addActivityToTrip(activity)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitialSuggestions() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Découvrez ${widget.trip.location}',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Utilisez le bouton "Découvrir des activités avec Google" ci-dessus\npour explorer les meilleures attractions de la région',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Conseil : Explorez par catégorie ci-dessous',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ActivityCategory.values.take(6).map((category) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () => _searchByCategory(category),
|
||||
icon: Icon(_getCategoryIcon(category), size: 18),
|
||||
label: Text(category.displayName),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
elevation: 2,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _searchByCategory(ActivityCategory category) {
|
||||
context.read<ActivityBloc>().add(
|
||||
SearchActivities(
|
||||
tripId: widget.trip.id!,
|
||||
destination: widget.trip.location,
|
||||
category: category,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleVote(String activityId, String userId, int vote) {
|
||||
if (userId.isEmpty) {
|
||||
_errorService.showSnackbar(
|
||||
message: 'Vous devez être connecté pour voter',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<ActivityBloc>().add(
|
||||
VoteForActivity(
|
||||
activityId: activityId,
|
||||
userId: userId,
|
||||
vote: vote,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addActivityToTrip(Activity activity) {
|
||||
context.read<ActivityBloc>().add(AddActivity(activity));
|
||||
}
|
||||
|
||||
void _searchGoogleActivities() {
|
||||
// Rechercher toutes les catégories d'activités
|
||||
context.read<ActivityBloc>().add(
|
||||
SearchActivities(
|
||||
tripId: widget.trip.id!,
|
||||
destination: widget.trip.location,
|
||||
category: null, // Null pour rechercher toutes les catégories
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryButton(ActivityCategory category, String label) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () => _searchByCategory(category),
|
||||
icon: Icon(_getCategoryIcon(category), size: 18),
|
||||
label: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddActivitySheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AddActivityBottomSheet(
|
||||
trip: widget.trip,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCategoryFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _buildCategoryFilterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPriceFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _buildPriceFilterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryFilterSheet() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Filtrer par catégorie',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
...ActivityCategory.values.map((category) {
|
||||
final isSelected = _selectedCategory == category.displayName;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(_getCategoryIcon(category)),
|
||||
title: Text(category.displayName),
|
||||
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedCategory = isSelected ? null : category.displayName;
|
||||
});
|
||||
|
||||
context.read<ActivityBloc>().add(
|
||||
FilterActivities(category: _selectedCategory),
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceFilterSheet() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Filtrer par prix',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
...PriceLevel.values.map((priceLevel) {
|
||||
return ListTile(
|
||||
leading: Icon(Icons.euro),
|
||||
title: Text(priceLevel.displayName),
|
||||
onTap: () {
|
||||
// TODO: Implémenter le filtre par prix
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(ActivityCategory category) {
|
||||
switch (category) {
|
||||
case ActivityCategory.museum:
|
||||
return Icons.museum;
|
||||
case ActivityCategory.restaurant:
|
||||
return Icons.restaurant;
|
||||
case ActivityCategory.attraction:
|
||||
return Icons.place;
|
||||
case ActivityCategory.entertainment:
|
||||
return Icons.sports_esports;
|
||||
case ActivityCategory.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case ActivityCategory.nature:
|
||||
return Icons.nature;
|
||||
case ActivityCategory.culture:
|
||||
return Icons.palette;
|
||||
case ActivityCategory.nightlife:
|
||||
return Icons.nightlife;
|
||||
case ActivityCategory.sports:
|
||||
return Icons.sports;
|
||||
case ActivityCategory.relaxation:
|
||||
return Icons.spa;
|
||||
}
|
||||
}
|
||||
}
|
||||
374
lib/components/activities/activity_card.dart
Normal file
374
lib/components/activities/activity_card.dart
Normal file
@@ -0,0 +1,374 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/activity.dart';
|
||||
|
||||
/// Widget représentant une carte d'activité avec système de vote
|
||||
class ActivityCard extends StatelessWidget {
|
||||
final Activity activity;
|
||||
final String currentUserId;
|
||||
final Function(int) onVote;
|
||||
final VoidCallback? onAddToTrip;
|
||||
|
||||
const ActivityCard({
|
||||
Key? key,
|
||||
required this.activity,
|
||||
required this.currentUserId,
|
||||
required this.onVote,
|
||||
this.onAddToTrip,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDarkMode = theme.brightness == Brightness.dark;
|
||||
final userVote = activity.getUserVote(currentUserId);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image de l'activité
|
||||
if (activity.imageUrl != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
activity.imageUrl!,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Badge catégorie
|
||||
if (activity.category.isNotEmpty)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(activity.category),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
activity.category,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Contenu de la carte
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre et rating
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (activity.rating != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.star, size: 14, color: Colors.amber),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
activity.rating!.toStringAsFixed(1),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.amber[800],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
activity.description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations supplémentaires
|
||||
if (activity.priceLevel != null || activity.address != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
if (activity.priceLevel != null) ...[
|
||||
Icon(
|
||||
Icons.euro,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
activity.priceLevel!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
if (activity.address != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.address!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
] else if (activity.address != null) ...[
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.address!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Section vote et actions
|
||||
Row(
|
||||
children: [
|
||||
// Boutons de vote
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Vote positif
|
||||
_buildVoteButton(
|
||||
icon: Icons.thumb_up,
|
||||
count: activity.positiveVotes,
|
||||
isActive: userVote == 1,
|
||||
onTap: () => onVote(userVote == 1 ? 0 : 1),
|
||||
activeColor: Colors.blue,
|
||||
),
|
||||
|
||||
Container(
|
||||
width: 1,
|
||||
height: 24,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
),
|
||||
|
||||
// Vote négatif
|
||||
_buildVoteButton(
|
||||
icon: Icons.thumb_down,
|
||||
count: activity.negativeVotes,
|
||||
isActive: userVote == -1,
|
||||
onTap: () => onVote(userVote == -1 ? 0 : -1),
|
||||
activeColor: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Bouton d'action
|
||||
if (onAddToTrip != null)
|
||||
ElevatedButton(
|
||||
onPressed: onAddToTrip,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('Voter'),
|
||||
)
|
||||
else
|
||||
// Score total
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getScoreColor(activity.totalVotes).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getScoreColor(activity.totalVotes),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'vote${activity.votes.length > 1 ? 's' : ''}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _getScoreColor(activity.totalVotes),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVoteButton({
|
||||
required IconData icon,
|
||||
required int count,
|
||||
required bool isActive,
|
||||
required VoidCallback onTap,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: isActive ? activeColor : Colors.grey,
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isActive ? activeColor : Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getCategoryColor(String category) {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'musée':
|
||||
return Colors.purple;
|
||||
case 'restaurant':
|
||||
return Colors.orange;
|
||||
case 'attraction':
|
||||
return Colors.blue;
|
||||
case 'divertissement':
|
||||
return Colors.pink;
|
||||
case 'shopping':
|
||||
return Colors.green;
|
||||
case 'nature':
|
||||
return Colors.teal;
|
||||
case 'culture':
|
||||
return Colors.indigo;
|
||||
case 'vie nocturne':
|
||||
return Colors.deepPurple;
|
||||
case 'sports':
|
||||
return Colors.red;
|
||||
case 'détente':
|
||||
return Colors.cyan;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getScoreColor(int score) {
|
||||
if (score > 0) return Colors.green;
|
||||
if (score < 0) return Colors.red;
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
402
lib/components/activities/add_activity_bottom_sheet.dart
Normal file
402
lib/components/activities/add_activity_bottom_sheet.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/activity/activity_bloc.dart';
|
||||
import '../../blocs/activity/activity_event.dart';
|
||||
import '../../models/activity.dart';
|
||||
import '../../models/trip.dart';
|
||||
import '../../services/error_service.dart';
|
||||
|
||||
/// Bottom sheet pour ajouter une activité personnalisée
|
||||
class AddActivityBottomSheet extends StatefulWidget {
|
||||
final Trip trip;
|
||||
|
||||
const AddActivityBottomSheet({
|
||||
Key? key,
|
||||
required this.trip,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddActivityBottomSheet> createState() => _AddActivityBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _addressController = TextEditingController();
|
||||
final ErrorService _errorService = ErrorService();
|
||||
|
||||
ActivityCategory _selectedCategory = ActivityCategory.attraction;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_addressController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Container(
|
||||
height: mediaQuery.size.height * 0.85,
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Ajouter une activité',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Formulaire
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom de l'activité
|
||||
_buildSectionTitle('Nom de l\'activité'),
|
||||
const SizedBox(height: 8),
|
||||
_buildTextField(
|
||||
controller: _nameController,
|
||||
hintText: 'Ex: Visite du Louvre',
|
||||
icon: Icons.event,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Nom requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Description
|
||||
_buildSectionTitle('Description'),
|
||||
const SizedBox(height: 8),
|
||||
_buildTextField(
|
||||
controller: _descriptionController,
|
||||
hintText: 'Décrivez cette activité...',
|
||||
icon: Icons.description,
|
||||
maxLines: 3,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Description requise';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Catégorie
|
||||
_buildSectionTitle('Catégorie'),
|
||||
const SizedBox(height: 8),
|
||||
_buildCategorySelector(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Adresse (optionnel)
|
||||
_buildSectionTitle('Adresse (optionnel)'),
|
||||
const SizedBox(height: 8),
|
||||
_buildTextField(
|
||||
controller: _addressController,
|
||||
hintText: 'Adresse ou lieu',
|
||||
icon: Icons.location_on,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Boutons d'action
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: theme.colorScheme.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _addActivity,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Ajouter'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required IconData icon,
|
||||
int maxLines = 1,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final isDarkMode = theme.brightness == Brightness.dark;
|
||||
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: Icon(icon),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: isDarkMode
|
||||
? Colors.white.withOpacity(0.2)
|
||||
: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: isDarkMode
|
||||
? Colors.white.withOpacity(0.2)
|
||||
: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.blue, width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySelector() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sélectionnez une catégorie',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ActivityCategory.values.map((category) {
|
||||
final isSelected = _selectedCategory == category;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedCategory = category;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.blue : theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getCategoryIcon(category),
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
category.displayName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addActivity() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.trip.id == null) {
|
||||
_errorService.showSnackbar(
|
||||
message: 'Erreur: ID du voyage manquant',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final activity = Activity(
|
||||
id: '', // Sera généré par Firestore
|
||||
tripId: widget.trip.id!,
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim(),
|
||||
category: _selectedCategory.displayName,
|
||||
address: _addressController.text.trim().isNotEmpty
|
||||
? _addressController.text.trim()
|
||||
: null,
|
||||
votes: {},
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
context.read<ActivityBloc>().add(AddActivity(activity));
|
||||
|
||||
// Fermer le bottom sheet
|
||||
Navigator.pop(context);
|
||||
|
||||
} catch (e) {
|
||||
_errorService.showSnackbar(
|
||||
message: 'Erreur lors de l\'ajout de l\'activité',
|
||||
isError: true,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(ActivityCategory category) {
|
||||
switch (category) {
|
||||
case ActivityCategory.museum:
|
||||
return Icons.museum;
|
||||
case ActivityCategory.restaurant:
|
||||
return Icons.restaurant;
|
||||
case ActivityCategory.attraction:
|
||||
return Icons.place;
|
||||
case ActivityCategory.entertainment:
|
||||
return Icons.sports_esports;
|
||||
case ActivityCategory.shopping:
|
||||
return Icons.shopping_bag;
|
||||
case ActivityCategory.nature:
|
||||
return Icons.nature;
|
||||
case ActivityCategory.culture:
|
||||
return Icons.palette;
|
||||
case ActivityCategory.nightlife:
|
||||
return Icons.nightlife;
|
||||
case ActivityCategory.sports:
|
||||
return Icons.sports;
|
||||
case ActivityCategory.relaxation:
|
||||
return Icons.spa;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:travel_mate/components/home/create_trip_content.dart';
|
||||
import 'package:travel_mate/models/trip.dart';
|
||||
import 'package:travel_mate/components/map/map_content.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
import 'package:travel_mate/components/activities/activities_page.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ShowTripDetailsContent extends StatefulWidget {
|
||||
@@ -402,7 +403,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
||||
icon: Icons.local_activity,
|
||||
title: 'Activités',
|
||||
color: Colors.green,
|
||||
onTap: () => _showComingSoon('Activités'),
|
||||
onTap: () => _navigateToActivities(),
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.account_balance_wallet,
|
||||
@@ -624,4 +625,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToActivities() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ActivitiesPage(trip: widget.trip),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:travel_mate/blocs/balance/balance_bloc.dart';
|
||||
import 'package:travel_mate/blocs/expense/expense_bloc.dart';
|
||||
import 'package:travel_mate/blocs/message/message_bloc.dart';
|
||||
import 'package:travel_mate/blocs/activity/activity_bloc.dart';
|
||||
import 'package:travel_mate/firebase_options.dart';
|
||||
import 'package:travel_mate/services/balance_service.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
import 'package:travel_mate/services/activity_places_service.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:travel_mate/services/expense_service.dart';
|
||||
import 'blocs/auth/auth_bloc.dart';
|
||||
@@ -26,6 +28,7 @@ import 'repositories/message_repository.dart';
|
||||
import 'repositories/account_repository.dart';
|
||||
import 'repositories/expense_repository.dart';
|
||||
import 'repositories/balance_repository.dart';
|
||||
import 'repositories/activity_repository.dart';
|
||||
import 'pages/login.dart';
|
||||
import 'pages/home.dart';
|
||||
import 'pages/signup.dart';
|
||||
@@ -99,6 +102,14 @@ class MyApp extends StatelessWidget {
|
||||
expenseRepository: context.read<ExpenseRepository>(),
|
||||
),
|
||||
),
|
||||
// Activity repository for managing trip activities
|
||||
RepositoryProvider<ActivityRepository>(
|
||||
create: (context) => ActivityRepository(),
|
||||
),
|
||||
// Activity places service for Google Places API integration
|
||||
RepositoryProvider<ActivityPlacesService>(
|
||||
create: (context) => ActivityPlacesService(),
|
||||
),
|
||||
// Balance service for business logic related to balances
|
||||
RepositoryProvider<BalanceService>(
|
||||
create: (context) => BalanceService(
|
||||
@@ -151,6 +162,14 @@ class MyApp extends StatelessWidget {
|
||||
expenseRepository: context.read<ExpenseRepository>(),
|
||||
),
|
||||
),
|
||||
// Activity BLoC for managing trip activities
|
||||
BlocProvider<ActivityBloc>(
|
||||
create: (context) => ActivityBloc(
|
||||
repository: context.read<ActivityRepository>(),
|
||||
placesService: context.read<ActivityPlacesService>(),
|
||||
errorService: ErrorService(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<ThemeBloc, ThemeState>(
|
||||
builder: (context, themeState) {
|
||||
|
||||
231
lib/models/activity.dart
Normal file
231
lib/models/activity.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Modèle représentant une activité touristique liée à un voyage
|
||||
class Activity {
|
||||
final String id;
|
||||
final String tripId;
|
||||
final String name;
|
||||
final String description;
|
||||
final String category;
|
||||
final String? imageUrl;
|
||||
final double? rating;
|
||||
final String? priceLevel;
|
||||
final String? address;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? placeId; // Google Places ID
|
||||
final String? website;
|
||||
final String? phoneNumber;
|
||||
final List<String> openingHours;
|
||||
final Map<String, int> votes; // userId -> vote (1 pour pour, -1 pour contre)
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
Activity({
|
||||
required this.id,
|
||||
required this.tripId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.category,
|
||||
this.imageUrl,
|
||||
this.rating,
|
||||
this.priceLevel,
|
||||
this.address,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.placeId,
|
||||
this.website,
|
||||
this.phoneNumber,
|
||||
this.openingHours = const [],
|
||||
this.votes = const {},
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Calcule le score total des votes
|
||||
int get totalVotes {
|
||||
return votes.values.fold(0, (sum, vote) => sum + vote);
|
||||
}
|
||||
|
||||
/// Calcule le nombre de votes positifs
|
||||
int get positiveVotes {
|
||||
return votes.values.where((vote) => vote > 0).length;
|
||||
}
|
||||
|
||||
/// Calcule le nombre de votes négatifs
|
||||
int get negativeVotes {
|
||||
return votes.values.where((vote) => vote < 0).length;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a voté
|
||||
bool hasUserVoted(String userId) {
|
||||
return votes.containsKey(userId);
|
||||
}
|
||||
|
||||
/// Récupère le vote d'un utilisateur (-1, 0, 1)
|
||||
int getUserVote(String userId) {
|
||||
return votes[userId] ?? 0;
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
Activity copyWith({
|
||||
String? id,
|
||||
String? tripId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? category,
|
||||
String? imageUrl,
|
||||
double? rating,
|
||||
String? priceLevel,
|
||||
String? address,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? placeId,
|
||||
String? website,
|
||||
String? phoneNumber,
|
||||
List<String>? openingHours,
|
||||
Map<String, int>? votes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Activity(
|
||||
id: id ?? this.id,
|
||||
tripId: tripId ?? this.tripId,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
category: category ?? this.category,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
rating: rating ?? this.rating,
|
||||
priceLevel: priceLevel ?? this.priceLevel,
|
||||
address: address ?? this.address,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
placeId: placeId ?? this.placeId,
|
||||
website: website ?? this.website,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
openingHours: openingHours ?? this.openingHours,
|
||||
votes: votes ?? this.votes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Conversion vers Map pour Firestore
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'tripId': tripId,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'imageUrl': imageUrl,
|
||||
'rating': rating,
|
||||
'priceLevel': priceLevel,
|
||||
'address': address,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'placeId': placeId,
|
||||
'website': website,
|
||||
'phoneNumber': phoneNumber,
|
||||
'openingHours': openingHours,
|
||||
'votes': votes,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map Firestore
|
||||
factory Activity.fromMap(Map<String, dynamic> map) {
|
||||
return Activity(
|
||||
id: map['id'] ?? '',
|
||||
tripId: map['tripId'] ?? '',
|
||||
name: map['name'] ?? '',
|
||||
description: map['description'] ?? '',
|
||||
category: map['category'] ?? '',
|
||||
imageUrl: map['imageUrl'],
|
||||
rating: map['rating']?.toDouble(),
|
||||
priceLevel: map['priceLevel'],
|
||||
address: map['address'],
|
||||
latitude: map['latitude']?.toDouble(),
|
||||
longitude: map['longitude']?.toDouble(),
|
||||
placeId: map['placeId'],
|
||||
website: map['website'],
|
||||
phoneNumber: map['phoneNumber'],
|
||||
openingHours: List<String>.from(map['openingHours'] ?? []),
|
||||
votes: Map<String, int>.from(map['votes'] ?? {}),
|
||||
createdAt: (map['createdAt'] as Timestamp).toDate(),
|
||||
updatedAt: (map['updatedAt'] as Timestamp).toDate(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Création depuis snapshot Firestore
|
||||
factory Activity.fromSnapshot(DocumentSnapshot snapshot) {
|
||||
final data = snapshot.data() as Map<String, dynamic>;
|
||||
return Activity.fromMap({...data, 'id': snapshot.id});
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Activity(id: $id, name: $name, category: $category, votes: ${totalVotes})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Activity && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Énumération des catégories d'activités
|
||||
enum ActivityCategory {
|
||||
museum('Musée', 'museum'),
|
||||
restaurant('Restaurant', 'restaurant'),
|
||||
attraction('Attraction', 'tourist_attraction'),
|
||||
entertainment('Divertissement', 'amusement_park'),
|
||||
shopping('Shopping', 'shopping_mall'),
|
||||
nature('Nature', 'park'),
|
||||
culture('Culture', 'establishment'),
|
||||
nightlife('Vie nocturne', 'night_club'),
|
||||
sports('Sports', 'gym'),
|
||||
relaxation('Détente', 'spa');
|
||||
|
||||
const ActivityCategory(this.displayName, this.googlePlaceType);
|
||||
|
||||
final String displayName;
|
||||
final String googlePlaceType;
|
||||
|
||||
static ActivityCategory? fromGoogleType(String type) {
|
||||
for (final category in ActivityCategory.values) {
|
||||
if (category.googlePlaceType == type) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Énumération des niveaux de prix
|
||||
enum PriceLevel {
|
||||
free('Gratuit', 0),
|
||||
inexpensive('Bon marché', 1),
|
||||
moderate('Modéré', 2),
|
||||
expensive('Cher', 3),
|
||||
veryExpensive('Très cher', 4);
|
||||
|
||||
const PriceLevel(this.displayName, this.level);
|
||||
|
||||
final String displayName;
|
||||
final int level;
|
||||
|
||||
static PriceLevel? fromLevel(int level) {
|
||||
for (final price in PriceLevel.values) {
|
||||
if (price.level == level) {
|
||||
return price;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
263
lib/repositories/activity_repository.dart
Normal file
263
lib/repositories/activity_repository.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../models/activity.dart';
|
||||
import '../services/error_service.dart';
|
||||
|
||||
/// Repository pour la gestion des activités dans Firestore
|
||||
class ActivityRepository {
|
||||
static final ActivityRepository _instance = ActivityRepository._internal();
|
||||
factory ActivityRepository() => _instance;
|
||||
ActivityRepository._internal();
|
||||
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final ErrorService _errorService = ErrorService();
|
||||
|
||||
static const String _collection = 'activities';
|
||||
|
||||
/// Ajoute une nouvelle activité
|
||||
Future<String?> addActivity(Activity activity) async {
|
||||
try {
|
||||
print('ActivityRepository: Ajout d\'une activité: ${activity.name}');
|
||||
|
||||
final docRef = await _firestore.collection(_collection).add(activity.toMap());
|
||||
|
||||
// Mettre à jour l'activité avec l'ID généré
|
||||
await docRef.update({'id': docRef.id});
|
||||
|
||||
print('ActivityRepository: Activité ajoutée avec ID: ${docRef.id}');
|
||||
return docRef.id;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur lors de l\'ajout: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur ajout activité: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère toutes les activités d'un voyage
|
||||
Future<List<Activity>> getActivitiesByTrip(String tripId) async {
|
||||
try {
|
||||
print('ActivityRepository: Récupération des activités pour le voyage: $tripId');
|
||||
|
||||
final querySnapshot = await _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.get();
|
||||
|
||||
final activities = querySnapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.toList();
|
||||
|
||||
print('ActivityRepository: ${activities.length} activités trouvées');
|
||||
return activities;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur lors de la récupération: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur récupération activités: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère une activité par son ID
|
||||
Future<Activity?> getActivityById(String activityId) async {
|
||||
try {
|
||||
final doc = await _firestore.collection(_collection).doc(activityId).get();
|
||||
|
||||
if (doc.exists) {
|
||||
return Activity.fromSnapshot(doc);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur récupération activité: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur récupération activité: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour une activité
|
||||
Future<bool> updateActivity(Activity activity) async {
|
||||
try {
|
||||
print('ActivityRepository: Mise à jour de l\'activité: ${activity.id}');
|
||||
|
||||
await _firestore
|
||||
.collection(_collection)
|
||||
.doc(activity.id)
|
||||
.update(activity.copyWith(updatedAt: DateTime.now()).toMap());
|
||||
|
||||
print('ActivityRepository: Activité mise à jour avec succès');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur lors de la mise à jour: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur mise à jour activité: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une activité
|
||||
Future<bool> deleteActivity(String activityId) async {
|
||||
try {
|
||||
print('ActivityRepository: Suppression de l\'activité: $activityId');
|
||||
|
||||
await _firestore.collection(_collection).doc(activityId).delete();
|
||||
|
||||
print('ActivityRepository: Activité supprimée avec succès');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur lors de la suppression: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur suppression activité: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vote pour une activité
|
||||
Future<bool> voteForActivity(String activityId, String userId, int vote) async {
|
||||
try {
|
||||
print('ActivityRepository: Vote pour l\'activité $activityId: $vote');
|
||||
|
||||
// vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote
|
||||
final activityRef = _firestore.collection(_collection).doc(activityId);
|
||||
|
||||
await _firestore.runTransaction((transaction) async {
|
||||
final snapshot = await transaction.get(activityRef);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
throw Exception('Activité non trouvée');
|
||||
}
|
||||
|
||||
final activity = Activity.fromSnapshot(snapshot);
|
||||
final newVotes = Map<String, int>.from(activity.votes);
|
||||
|
||||
if (vote == 0) {
|
||||
// Supprimer le vote
|
||||
newVotes.remove(userId);
|
||||
} else {
|
||||
// Ajouter ou modifier le vote
|
||||
newVotes[userId] = vote;
|
||||
}
|
||||
|
||||
transaction.update(activityRef, {
|
||||
'votes': newVotes,
|
||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
||||
});
|
||||
});
|
||||
|
||||
print('ActivityRepository: Vote enregistré avec succès');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur lors du vote: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur vote: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les activités avec un stream pour les mises à jour en temps réel
|
||||
Stream<List<Activity>> getActivitiesStream(String tripId) {
|
||||
try {
|
||||
return _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur stream activités: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur stream activités: $e');
|
||||
return Stream.value([]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute plusieurs activités en lot
|
||||
Future<List<String>> addActivitiesBatch(List<Activity> activities) async {
|
||||
try {
|
||||
print('ActivityRepository: Ajout en lot de ${activities.length} activités');
|
||||
|
||||
final batch = _firestore.batch();
|
||||
final addedIds = <String>[];
|
||||
|
||||
for (final activity in activities) {
|
||||
final docRef = _firestore.collection(_collection).doc();
|
||||
final activityWithId = activity.copyWith(id: docRef.id);
|
||||
batch.set(docRef, activityWithId.toMap());
|
||||
addedIds.add(docRef.id);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
print('ActivityRepository: ${addedIds.length} activités ajoutées en lot');
|
||||
return addedIds;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur ajout en lot: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur ajout en lot: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche des activités par catégorie
|
||||
Future<List<Activity>> getActivitiesByCategory(String tripId, String category) async {
|
||||
try {
|
||||
final querySnapshot = await _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.get();
|
||||
|
||||
return querySnapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur recherche par catégorie: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les activités les mieux notées d'un voyage
|
||||
Future<List<Activity>> getTopRatedActivities(String tripId, {int limit = 10}) async {
|
||||
try {
|
||||
final activities = await getActivitiesByTrip(tripId);
|
||||
|
||||
// Trier par nombre de votes positifs puis par note Google
|
||||
activities.sort((a, b) {
|
||||
final aScore = a.totalVotes;
|
||||
final bScore = b.totalVotes;
|
||||
|
||||
if (aScore != bScore) {
|
||||
return bScore.compareTo(aScore);
|
||||
}
|
||||
|
||||
return (b.rating ?? 0).compareTo(a.rating ?? 0);
|
||||
});
|
||||
|
||||
return activities.take(limit).toList();
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur activités top rated: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur top rated: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une activité existe déjà pour un voyage (basé sur placeId)
|
||||
Future<Activity?> findExistingActivity(String tripId, String placeId) async {
|
||||
try {
|
||||
final querySnapshot = await _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.where('placeId', isEqualTo: placeId)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (querySnapshot.docs.isNotEmpty) {
|
||||
return Activity.fromSnapshot(querySnapshot.docs.first);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur recherche activité existante: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
341
lib/services/activity_places_service.dart
Normal file
341
lib/services/activity_places_service.dart
Normal file
@@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/activity.dart';
|
||||
import '../services/error_service.dart';
|
||||
|
||||
/// Service pour rechercher des activités touristiques via Google Places API
|
||||
class ActivityPlacesService {
|
||||
static final ActivityPlacesService _instance = ActivityPlacesService._internal();
|
||||
factory ActivityPlacesService() => _instance;
|
||||
ActivityPlacesService._internal();
|
||||
|
||||
final ErrorService _errorService = ErrorService();
|
||||
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
|
||||
|
||||
/// Recherche des activités près d'une destination
|
||||
Future<List<Activity>> searchActivities({
|
||||
required String destination,
|
||||
required String tripId,
|
||||
ActivityCategory? category,
|
||||
int radius = 5000,
|
||||
}) async {
|
||||
try {
|
||||
print('ActivityPlacesService: Recherche d\'activités pour: $destination');
|
||||
|
||||
// 1. Géocoder la destination
|
||||
final coordinates = await _geocodeDestination(destination);
|
||||
if (coordinates == null) {
|
||||
throw Exception('Impossible de localiser la destination: $destination');
|
||||
}
|
||||
|
||||
// 2. Rechercher les activités par catégorie ou toutes les catégories
|
||||
List<Activity> allActivities = [];
|
||||
|
||||
if (category != null) {
|
||||
final activities = await _searchByCategory(
|
||||
coordinates['lat']!,
|
||||
coordinates['lng']!,
|
||||
category,
|
||||
tripId,
|
||||
radius,
|
||||
);
|
||||
allActivities.addAll(activities);
|
||||
} else {
|
||||
// Rechercher dans toutes les catégories principales
|
||||
final mainCategories = [
|
||||
ActivityCategory.attraction,
|
||||
ActivityCategory.museum,
|
||||
ActivityCategory.restaurant,
|
||||
ActivityCategory.culture,
|
||||
ActivityCategory.nature,
|
||||
];
|
||||
|
||||
for (final cat in mainCategories) {
|
||||
final activities = await _searchByCategory(
|
||||
coordinates['lat']!,
|
||||
coordinates['lng']!,
|
||||
cat,
|
||||
tripId,
|
||||
radius,
|
||||
);
|
||||
allActivities.addAll(activities);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Supprimer les doublons et trier par note
|
||||
final uniqueActivities = _removeDuplicates(allActivities);
|
||||
uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0));
|
||||
|
||||
print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées');
|
||||
return uniqueActivities.take(50).toList(); // Limiter à 50 résultats
|
||||
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur lors de la recherche: $e');
|
||||
_errorService.logError('activity_places_service', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Géocode une destination pour obtenir les coordonnées
|
||||
Future<Map<String, double>?> _geocodeDestination(String destination) async {
|
||||
try {
|
||||
final encodedDestination = Uri.encodeComponent(destination);
|
||||
final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
||||
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
|
||||
final location = data['results'][0]['geometry']['location'];
|
||||
return {
|
||||
'lat': location['lat'].toDouble(),
|
||||
'lng': location['lng'].toDouble(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur géocodage: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche des activités par catégorie
|
||||
Future<List<Activity>> _searchByCategory(
|
||||
double lat,
|
||||
double lng,
|
||||
ActivityCategory category,
|
||||
String tripId,
|
||||
int radius,
|
||||
) async {
|
||||
try {
|
||||
final url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json'
|
||||
'?location=$lat,$lng'
|
||||
'&radius=$radius'
|
||||
'&type=${category.googlePlaceType}'
|
||||
'&key=$_apiKey';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
||||
if (data['status'] == 'OK') {
|
||||
final List<Activity> activities = [];
|
||||
|
||||
for (final place in data['results']) {
|
||||
try {
|
||||
final activity = await _convertPlaceToActivity(place, tripId, category);
|
||||
if (activity != null) {
|
||||
activities.add(activity);
|
||||
}
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur conversion place: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur recherche par catégorie: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un résultat Google Places en Activity
|
||||
Future<Activity?> _convertPlaceToActivity(
|
||||
Map<String, dynamic> place,
|
||||
String tripId,
|
||||
ActivityCategory category,
|
||||
) async {
|
||||
try {
|
||||
final placeId = place['place_id'];
|
||||
if (placeId == null) return null;
|
||||
|
||||
// Récupérer les détails supplémentaires
|
||||
final details = await _getPlaceDetails(placeId);
|
||||
|
||||
final geometry = place['geometry']?['location'];
|
||||
final photos = place['photos'] as List?;
|
||||
|
||||
// Obtenir une image de qualité
|
||||
String? imageUrl;
|
||||
if (photos != null && photos.isNotEmpty) {
|
||||
final photoReference = photos.first['photo_reference'];
|
||||
imageUrl = 'https://maps.googleapis.com/maps/api/place/photo'
|
||||
'?maxwidth=800'
|
||||
'&photoreference=$photoReference'
|
||||
'&key=$_apiKey';
|
||||
}
|
||||
|
||||
return Activity(
|
||||
id: '', // Sera généré lors de la sauvegarde
|
||||
tripId: tripId,
|
||||
name: place['name'] ?? 'Activité inconnue',
|
||||
description: details?['editorial_summary']?['overview'] ??
|
||||
details?['formatted_address'] ??
|
||||
'Découvrez cette activité incontournable !',
|
||||
category: category.displayName,
|
||||
imageUrl: imageUrl,
|
||||
rating: place['rating']?.toDouble(),
|
||||
priceLevel: _getPriceLevelString(place['price_level']),
|
||||
address: details?['formatted_address'] ?? place['vicinity'],
|
||||
latitude: geometry?['lat']?.toDouble(),
|
||||
longitude: geometry?['lng']?.toDouble(),
|
||||
placeId: placeId,
|
||||
website: details?['website'],
|
||||
phoneNumber: details?['formatted_phone_number'],
|
||||
openingHours: _parseOpeningHours(details?['opening_hours']),
|
||||
votes: {},
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur conversion place: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails d'un lieu
|
||||
Future<Map<String, dynamic>?> _getPlaceDetails(String placeId) async {
|
||||
try {
|
||||
final url = 'https://maps.googleapis.com/maps/api/place/details/json'
|
||||
'?place_id=$placeId'
|
||||
'&fields=formatted_address,formatted_phone_number,website,opening_hours,editorial_summary'
|
||||
'&key=$_apiKey';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == 'OK') {
|
||||
return data['result'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur récupération détails: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit le niveau de prix en string
|
||||
String? _getPriceLevelString(int? priceLevel) {
|
||||
if (priceLevel == null) return null;
|
||||
final level = PriceLevel.fromLevel(priceLevel);
|
||||
return level?.displayName;
|
||||
}
|
||||
|
||||
/// Parse les heures d'ouverture
|
||||
List<String> _parseOpeningHours(Map<String, dynamic>? openingHours) {
|
||||
if (openingHours == null) return [];
|
||||
|
||||
final weekdayText = openingHours['weekday_text'] as List?;
|
||||
if (weekdayText == null) return [];
|
||||
|
||||
return weekdayText.cast<String>();
|
||||
}
|
||||
|
||||
/// Supprime les doublons basés sur le placeId
|
||||
List<Activity> _removeDuplicates(List<Activity> activities) {
|
||||
final seen = <String>{};
|
||||
return activities.where((activity) {
|
||||
if (activity.placeId == null) return true;
|
||||
if (seen.contains(activity.placeId)) return false;
|
||||
seen.add(activity.placeId!);
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Recherche d'activités par texte libre
|
||||
Future<List<Activity>> searchActivitiesByText({
|
||||
required String query,
|
||||
required String destination,
|
||||
required String tripId,
|
||||
int radius = 5000,
|
||||
}) async {
|
||||
try {
|
||||
print('ActivityPlacesService: Recherche textuelle: $query à $destination');
|
||||
|
||||
// Géocoder la destination
|
||||
final coordinates = await _geocodeDestination(destination);
|
||||
if (coordinates == null) {
|
||||
throw Exception('Impossible de localiser la destination');
|
||||
}
|
||||
|
||||
final encodedQuery = Uri.encodeComponent(query);
|
||||
final url = 'https://maps.googleapis.com/maps/api/place/textsearch/json'
|
||||
'?query=$encodedQuery in $destination'
|
||||
'&location=${coordinates['lat']},${coordinates['lng']}'
|
||||
'&radius=$radius'
|
||||
'&key=$_apiKey';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
|
||||
if (data['status'] == 'OK') {
|
||||
final List<Activity> activities = [];
|
||||
|
||||
for (final place in data['results']) {
|
||||
try {
|
||||
// Déterminer la catégorie basée sur les types du lieu
|
||||
final types = List<String>.from(place['types'] ?? []);
|
||||
final category = _determineCategoryFromTypes(types);
|
||||
|
||||
final activity = await _convertPlaceToActivity(place, tripId, category);
|
||||
if (activity != null) {
|
||||
activities.add(activity);
|
||||
}
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur conversion place: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur recherche textuelle: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Détermine la catégorie d'activité basée sur les types Google Places
|
||||
ActivityCategory _determineCategoryFromTypes(List<String> types) {
|
||||
for (final type in types) {
|
||||
for (final category in ActivityCategory.values) {
|
||||
if (category.googlePlaceType == type) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Catégories par défaut basées sur des types communs
|
||||
if (types.contains('restaurant') || types.contains('food')) {
|
||||
return ActivityCategory.restaurant;
|
||||
} else if (types.contains('museum')) {
|
||||
return ActivityCategory.museum;
|
||||
} else if (types.contains('park')) {
|
||||
return ActivityCategory.nature;
|
||||
} else if (types.contains('shopping_mall') || types.contains('store')) {
|
||||
return ActivityCategory.shopping;
|
||||
} else if (types.contains('night_club') || types.contains('bar')) {
|
||||
return ActivityCategory.nightlife;
|
||||
}
|
||||
|
||||
return ActivityCategory.attraction; // Par défaut
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user