Refactor ActivityCard UI and improve voting functionality
- Updated ActivityCard layout for better visual consistency and responsiveness. - Simplified the category badge and adjusted styles for better readability. - Enhanced the voting section with a progress bar and improved button designs. - Added a new method in Activity model to check if all trip participants approved an activity. - Improved error handling and validation in ActivityRepository for voting and fetching activities. - Implemented pagination in ActivityPlacesService for activity searches. - Removed outdated scripts for cleaning up duplicate images.
This commit is contained in:
@@ -61,17 +61,36 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
|
||||
Emitter<ActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ActivitySearching());
|
||||
// Si c'est un append (charger plus), on garde l'état actuel et on met isLoading à true
|
||||
if (event.appendToExisting && state is ActivitySearchResults) {
|
||||
final currentState = state as ActivitySearchResults;
|
||||
emit(currentState.copyWith(isLoading: true));
|
||||
} else {
|
||||
emit(const ActivitySearching());
|
||||
}
|
||||
|
||||
final searchResults = await _placesService.searchActivities(
|
||||
destination: event.destination,
|
||||
tripId: event.tripId,
|
||||
category: event.category,
|
||||
maxResults: event.maxResults ?? 20, // Par défaut 20, ou utiliser la valeur spécifiée
|
||||
offset: event.offset ?? 0, // Par défaut 0
|
||||
);
|
||||
|
||||
List<Activity> finalResults;
|
||||
|
||||
// Si on doit ajouter aux résultats existants
|
||||
if (event.appendToExisting && state is ActivitySearchResults) {
|
||||
final currentState = state as ActivitySearchResults;
|
||||
finalResults = [...currentState.searchResults, ...searchResults];
|
||||
} else {
|
||||
finalResults = searchResults;
|
||||
}
|
||||
|
||||
emit(ActivitySearchResults(
|
||||
searchResults: searchResults,
|
||||
searchResults: finalResults,
|
||||
query: event.category?.displayName ?? 'Toutes les activités',
|
||||
isLoading: false,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('activity_bloc', 'Erreur recherche activités: $e');
|
||||
|
||||
@@ -24,15 +24,23 @@ class SearchActivities extends ActivityEvent {
|
||||
final String tripId;
|
||||
final String destination;
|
||||
final ActivityCategory? category;
|
||||
final int? maxResults;
|
||||
final int? offset;
|
||||
final bool reset;
|
||||
final bool appendToExisting;
|
||||
|
||||
const SearchActivities({
|
||||
required this.tripId,
|
||||
required this.destination,
|
||||
this.category,
|
||||
this.maxResults,
|
||||
this.offset,
|
||||
this.reset = false,
|
||||
this.appendToExisting = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, destination, category];
|
||||
List<Object?> get props => [tripId, destination, category, maxResults, offset, reset, appendToExisting];
|
||||
}
|
||||
|
||||
/// Event to search activities by text query
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../models/activity.dart';
|
||||
import '../../../repositories/activity_repository.dart';
|
||||
import '../../../services/error_service.dart';
|
||||
import 'approved_activity_event.dart';
|
||||
import 'approved_activity_state.dart';
|
||||
|
||||
/// BLoC pour gérer les activités approuvées par tous les participants
|
||||
class ApprovedActivityBloc extends Bloc<ApprovedActivityEvent, ApprovedActivityState> {
|
||||
final ActivityRepository _repository;
|
||||
final ErrorService _errorService;
|
||||
|
||||
ApprovedActivityBloc({
|
||||
required ActivityRepository repository,
|
||||
required ErrorService errorService,
|
||||
}) : _repository = repository,
|
||||
_errorService = errorService,
|
||||
super(const ApprovedActivityInitial()) {
|
||||
|
||||
on<LoadApprovedActivities>(_onLoadApprovedActivities);
|
||||
on<SearchApprovedActivities>(_onSearchApprovedActivities);
|
||||
on<FilterApprovedActivities>(_onFilterApprovedActivities);
|
||||
on<RefreshApprovedActivities>(_onRefreshApprovedActivities);
|
||||
on<ClearApprovedSearchResults>(_onClearApprovedSearchResults);
|
||||
}
|
||||
|
||||
/// Charger les activités approuvées par tous les participants
|
||||
Future<void> _onLoadApprovedActivities(
|
||||
LoadApprovedActivities event,
|
||||
Emitter<ApprovedActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ApprovedActivityLoading());
|
||||
|
||||
final allActivities = await _repository.getActivitiesByTrip(event.tripId);
|
||||
|
||||
// Filtrer les activités qui ont reçu des votes positifs de TOUS les participants
|
||||
final approvedActivities = allActivities.where((activity) {
|
||||
// Une activité est approuvée si tous les participants ont voté positivement
|
||||
final positiveVoters = activity.votes.entries
|
||||
.where((entry) => entry.value > 0)
|
||||
.map((entry) => entry.key)
|
||||
.toSet();
|
||||
|
||||
// Vérifier que tous les participants ont voté positivement
|
||||
return event.tripParticipants.every((participant) =>
|
||||
positiveVoters.contains(participant));
|
||||
}).toList();
|
||||
|
||||
// Trier par nombre total de votes puis par rating
|
||||
approvedActivities.sort((a, b) {
|
||||
final aVotes = a.totalVotes;
|
||||
final bVotes = b.totalVotes;
|
||||
if (aVotes != bVotes) {
|
||||
return bVotes.compareTo(aVotes);
|
||||
}
|
||||
return (b.rating ?? 0).compareTo(a.rating ?? 0);
|
||||
});
|
||||
|
||||
emit(ApprovedActivityLoaded(
|
||||
approvedActivities: approvedActivities,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('approved_activity_bloc', 'Erreur chargement activités approuvées: $e');
|
||||
emit(const ApprovedActivityError('Impossible de charger les activités approuvées'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechercher dans les activités approuvées
|
||||
Future<void> _onSearchApprovedActivities(
|
||||
SearchApprovedActivities event,
|
||||
Emitter<ApprovedActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ApprovedActivitySearching());
|
||||
|
||||
// Charger d'abord toutes les activités approuvées
|
||||
final allActivities = await _repository.getActivitiesByTrip(event.tripId);
|
||||
|
||||
// Filtrer les approuvées puis rechercher
|
||||
final approvedActivities = allActivities.where((activity) {
|
||||
final positiveVoters = activity.votes.entries
|
||||
.where((entry) => entry.value > 0)
|
||||
.map((entry) => entry.key)
|
||||
.toSet();
|
||||
|
||||
return event.tripParticipants.every((participant) =>
|
||||
positiveVoters.contains(participant));
|
||||
}).toList();
|
||||
|
||||
// Rechercher dans les activités approuvées
|
||||
final results = approvedActivities
|
||||
.where((activity) =>
|
||||
activity.name.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
activity.description.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
activity.category.toLowerCase().contains(event.query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
emit(ApprovedActivitySearchResults(
|
||||
results: results,
|
||||
query: event.query,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('approved_activity_bloc', 'Erreur recherche approuvées: $e');
|
||||
emit(const ApprovedActivityError('Erreur lors de la recherche'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtrer les activités approuvées
|
||||
Future<void> _onFilterApprovedActivities(
|
||||
FilterApprovedActivities event,
|
||||
Emitter<ApprovedActivityState> emit,
|
||||
) async {
|
||||
if (state is! ApprovedActivityLoaded) return;
|
||||
|
||||
final currentState = state as ApprovedActivityLoaded;
|
||||
List<Activity> filteredActivities = List.from(currentState.approvedActivities);
|
||||
|
||||
// Filtrer par catégorie
|
||||
if (event.category != null && event.category!.isNotEmpty) {
|
||||
filteredActivities = filteredActivities
|
||||
.where((activity) => activity.category == event.category)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par rating minimum
|
||||
if (event.minRating != null) {
|
||||
filteredActivities = filteredActivities
|
||||
.where((activity) => (activity.rating ?? 0) >= event.minRating!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(approvedActivities: filteredActivities));
|
||||
}
|
||||
|
||||
/// Rafraîchir les activités approuvées
|
||||
Future<void> _onRefreshApprovedActivities(
|
||||
RefreshApprovedActivities event,
|
||||
Emitter<ApprovedActivityState> emit,
|
||||
) async {
|
||||
add(LoadApprovedActivities(
|
||||
tripId: event.tripId,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
}
|
||||
|
||||
/// Effacer les résultats de recherche
|
||||
Future<void> _onClearApprovedSearchResults(
|
||||
ClearApprovedSearchResults event,
|
||||
Emitter<ApprovedActivityState> emit,
|
||||
) async {
|
||||
if (state is ApprovedActivitySearchResults) {
|
||||
emit(const ApprovedActivityInitial());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Events pour les activités approuvées par tous les participants
|
||||
abstract class ApprovedActivityEvent extends Equatable {
|
||||
const ApprovedActivityEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger les activités approuvées par tous
|
||||
class LoadApprovedActivities extends ApprovedActivityEvent {
|
||||
final String tripId;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const LoadApprovedActivities({
|
||||
required this.tripId,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, tripParticipants];
|
||||
}
|
||||
|
||||
/// Rechercher dans les activités approuvées
|
||||
class SearchApprovedActivities extends ApprovedActivityEvent {
|
||||
final String tripId;
|
||||
final String query;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const SearchApprovedActivities({
|
||||
required this.tripId,
|
||||
required this.query,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, query, tripParticipants];
|
||||
}
|
||||
|
||||
/// Filtrer les activités approuvées
|
||||
class FilterApprovedActivities extends ApprovedActivityEvent {
|
||||
final String? category;
|
||||
final double? minRating;
|
||||
|
||||
const FilterApprovedActivities({
|
||||
this.category,
|
||||
this.minRating,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [category, minRating];
|
||||
}
|
||||
|
||||
/// Rafraîchir les activités approuvées
|
||||
class RefreshApprovedActivities extends ApprovedActivityEvent {
|
||||
final String tripId;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const RefreshApprovedActivities({
|
||||
required this.tripId,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, tripParticipants];
|
||||
}
|
||||
|
||||
/// Effacer les résultats de recherche approuvées
|
||||
class ClearApprovedSearchResults extends ApprovedActivityEvent {
|
||||
const ClearApprovedSearchResults();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../models/activity.dart';
|
||||
|
||||
/// States pour les activités approuvées par tous les participants
|
||||
abstract class ApprovedActivityState extends Equatable {
|
||||
const ApprovedActivityState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class ApprovedActivityInitial extends ApprovedActivityState {
|
||||
const ApprovedActivityInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class ApprovedActivityLoading extends ApprovedActivityState {
|
||||
const ApprovedActivityLoading();
|
||||
}
|
||||
|
||||
/// État de recherche
|
||||
class ApprovedActivitySearching extends ApprovedActivityState {
|
||||
const ApprovedActivitySearching();
|
||||
}
|
||||
|
||||
/// État avec les activités approuvées chargées
|
||||
class ApprovedActivityLoaded extends ApprovedActivityState {
|
||||
final List<Activity> approvedActivities;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const ApprovedActivityLoaded({
|
||||
required this.approvedActivities,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [approvedActivities, tripParticipants];
|
||||
|
||||
/// Créer une copie avec des modifications
|
||||
ApprovedActivityLoaded copyWith({
|
||||
List<Activity>? approvedActivities,
|
||||
List<String>? tripParticipants,
|
||||
}) {
|
||||
return ApprovedActivityLoaded(
|
||||
approvedActivities: approvedActivities ?? this.approvedActivities,
|
||||
tripParticipants: tripParticipants ?? this.tripParticipants,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// État avec résultats de recherche approuvées
|
||||
class ApprovedActivitySearchResults extends ApprovedActivityState {
|
||||
final List<Activity> results;
|
||||
final String query;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const ApprovedActivitySearchResults({
|
||||
required this.results,
|
||||
required this.query,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [results, query, tripParticipants];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class ApprovedActivityError extends ApprovedActivityState {
|
||||
final String message;
|
||||
|
||||
const ApprovedActivityError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
267
lib/blocs/activity/db_activities/db_activity_bloc.dart
Normal file
267
lib/blocs/activity/db_activities/db_activity_bloc.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../models/activity.dart';
|
||||
import '../../../repositories/activity_repository.dart';
|
||||
import '../../../services/error_service.dart';
|
||||
import 'db_activity_event.dart';
|
||||
import 'db_activity_state.dart';
|
||||
|
||||
/// BLoC pour gérer les activités de la base de données
|
||||
class DbActivityBloc extends Bloc<DbActivityEvent, DbActivityState> {
|
||||
final ActivityRepository _repository;
|
||||
final ErrorService _errorService;
|
||||
|
||||
DbActivityBloc({
|
||||
required ActivityRepository repository,
|
||||
required ErrorService errorService,
|
||||
}) : _repository = repository,
|
||||
_errorService = errorService,
|
||||
super(const DbActivityInitial()) {
|
||||
|
||||
on<LoadDbActivities>(_onLoadDbActivities);
|
||||
on<SearchDbActivities>(_onSearchDbActivities);
|
||||
on<AddDbActivity>(_onAddDbActivity);
|
||||
on<AddDbActivitiesBatch>(_onAddDbActivitiesBatch);
|
||||
on<VoteForDbActivity>(_onVoteForDbActivity);
|
||||
on<DeleteDbActivity>(_onDeleteDbActivity);
|
||||
on<FilterDbActivities>(_onFilterDbActivities);
|
||||
on<RefreshDbActivities>(_onRefreshDbActivities);
|
||||
on<UpdateDbActivity>(_onUpdateDbActivity);
|
||||
on<ToggleDbActivityFavorite>(_onToggleDbActivityFavorite);
|
||||
on<ClearDbSearchResults>(_onClearDbSearchResults);
|
||||
}
|
||||
|
||||
/// Charger les activités d'un voyage
|
||||
Future<void> _onLoadDbActivities(
|
||||
LoadDbActivities event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const DbActivityLoading());
|
||||
|
||||
final activities = await _repository.getActivitiesByTrip(event.tripId);
|
||||
|
||||
emit(DbActivityLoaded(
|
||||
activities: activities,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur chargement activités DB: $e');
|
||||
emit(const DbActivityError('Impossible de charger les activités du voyage'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechercher des activités
|
||||
Future<void> _onSearchDbActivities(
|
||||
SearchDbActivities event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const DbActivitySearching());
|
||||
|
||||
// Pour l'instant, on fait une recherche simple en filtrant les activités existantes
|
||||
final allActivities = await _repository.getActivitiesByTrip(event.tripId);
|
||||
final results = allActivities
|
||||
.where((activity) =>
|
||||
activity.name.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
activity.description.toLowerCase().contains(event.query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
emit(DbActivitySearchResults(
|
||||
results: results,
|
||||
query: event.query,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur recherche activités: $e');
|
||||
emit(const DbActivityError('Erreur lors de la recherche'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter une activité
|
||||
Future<void> _onAddDbActivity(
|
||||
AddDbActivity event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.addActivity(event.activity);
|
||||
|
||||
// Recharger les activités si on est dans l'état chargé
|
||||
if (state is DbActivityLoaded) {
|
||||
final currentState = state as DbActivityLoaded;
|
||||
add(LoadDbActivities(
|
||||
tripId: event.activity.tripId,
|
||||
tripParticipants: currentState.tripParticipants,
|
||||
));
|
||||
}
|
||||
|
||||
emit(const DbActivityOperationSuccess('Activité ajoutée avec succès'));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur ajout activité: $e');
|
||||
emit(const DbActivityError('Impossible d\'ajouter l\'activité'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter plusieurs activités
|
||||
Future<void> _onAddDbActivitiesBatch(
|
||||
AddDbActivitiesBatch event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.addActivitiesBatch(event.activities);
|
||||
|
||||
// Recharger les activités si on est dans l'état chargé
|
||||
if (state is DbActivityLoaded && event.activities.isNotEmpty) {
|
||||
final currentState = state as DbActivityLoaded;
|
||||
add(LoadDbActivities(
|
||||
tripId: event.activities.first.tripId,
|
||||
tripParticipants: currentState.tripParticipants,
|
||||
));
|
||||
}
|
||||
|
||||
emit(DbActivityOperationSuccess('${event.activities.length} activités ajoutées'));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur ajout batch: $e');
|
||||
emit(const DbActivityError('Impossible d\'ajouter les activités'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Voter pour une activité
|
||||
Future<void> _onVoteForDbActivity(
|
||||
VoteForDbActivity event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.voteForActivity(
|
||||
event.activityId,
|
||||
event.userId,
|
||||
event.isUpvote ? 1 : -1,
|
||||
);
|
||||
|
||||
// Recharger les activités pour refléter le nouveau vote
|
||||
if (state is DbActivityLoaded) {
|
||||
final currentState = state as DbActivityLoaded;
|
||||
final activity = currentState.activities.firstWhere(
|
||||
(a) => a.id == event.activityId,
|
||||
);
|
||||
add(LoadDbActivities(
|
||||
tripId: activity.tripId,
|
||||
tripParticipants: currentState.tripParticipants,
|
||||
));
|
||||
}
|
||||
|
||||
emit(const DbActivityOperationSuccess('Vote enregistré'));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur vote: $e');
|
||||
emit(const DbActivityError('Impossible d\'enregistrer le vote'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une activité
|
||||
Future<void> _onDeleteDbActivity(
|
||||
DeleteDbActivity event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.deleteActivity(event.activityId);
|
||||
|
||||
// Mettre à jour la liste locale si on est dans l'état chargé
|
||||
if (state is DbActivityLoaded) {
|
||||
final currentState = state as DbActivityLoaded;
|
||||
final updatedActivities = currentState.activities
|
||||
.where((activity) => activity.id != event.activityId)
|
||||
.toList();
|
||||
|
||||
emit(currentState.copyWith(activities: updatedActivities));
|
||||
}
|
||||
|
||||
emit(const DbActivityOperationSuccess('Activité supprimée'));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur suppression: $e');
|
||||
emit(const DbActivityError('Impossible de supprimer l\'activité'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtrer les activités
|
||||
Future<void> _onFilterDbActivities(
|
||||
FilterDbActivities event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
if (state is! DbActivityLoaded) return;
|
||||
|
||||
final currentState = state as DbActivityLoaded;
|
||||
List<Activity> filteredActivities = List.from(currentState.activities);
|
||||
|
||||
// Filtrer par catégorie
|
||||
if (event.category != null && event.category!.isNotEmpty) {
|
||||
filteredActivities = filteredActivities
|
||||
.where((activity) => activity.category == event.category)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Note: isFavorite n'existe pas dans le modèle Activity actuel
|
||||
// if (event.showFavorites == true) {
|
||||
// filteredActivities = filteredActivities
|
||||
// .where((activity) => activity.isFavorite == true)
|
||||
// .toList();
|
||||
// }
|
||||
|
||||
emit(currentState.copyWith(activities: filteredActivities));
|
||||
}
|
||||
|
||||
/// Rafraîchir les activités
|
||||
Future<void> _onRefreshDbActivities(
|
||||
RefreshDbActivities event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
add(LoadDbActivities(
|
||||
tripId: event.tripId,
|
||||
tripParticipants: event.tripParticipants,
|
||||
));
|
||||
}
|
||||
|
||||
/// Mettre à jour une activité
|
||||
Future<void> _onUpdateDbActivity(
|
||||
UpdateDbActivity event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _repository.updateActivity(event.activity);
|
||||
|
||||
// Mettre à jour la liste locale
|
||||
if (state is DbActivityLoaded) {
|
||||
final currentState = state as DbActivityLoaded;
|
||||
final updatedActivities = currentState.activities.map((activity) {
|
||||
return activity.id == event.activity.id ? event.activity : activity;
|
||||
}).toList();
|
||||
|
||||
emit(currentState.copyWith(activities: updatedActivities));
|
||||
}
|
||||
|
||||
emit(const DbActivityOperationSuccess('Activité mise à jour'));
|
||||
} catch (e) {
|
||||
_errorService.logError('db_activity_bloc', 'Erreur mise à jour: $e');
|
||||
emit(const DbActivityError('Impossible de mettre à jour l\'activité'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Basculer le favori d'une activité
|
||||
Future<void> _onToggleDbActivityFavorite(
|
||||
ToggleDbActivityFavorite event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
// Note: La fonctionnalité favori n'existe pas encore dans le repository
|
||||
// Cette méthode peut être implémentée plus tard
|
||||
emit(const DbActivityError('Fonctionnalité favori non disponible'));
|
||||
}
|
||||
|
||||
/// Effacer les résultats de recherche
|
||||
Future<void> _onClearDbSearchResults(
|
||||
ClearDbSearchResults event,
|
||||
Emitter<DbActivityState> emit,
|
||||
) async {
|
||||
if (state is DbActivitySearchResults) {
|
||||
// Retourner à l'état précédent ou initial
|
||||
emit(const DbActivityInitial());
|
||||
}
|
||||
}
|
||||
}
|
||||
143
lib/blocs/activity/db_activities/db_activity_event.dart
Normal file
143
lib/blocs/activity/db_activities/db_activity_event.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../models/activity.dart';
|
||||
|
||||
/// Events pour les activités de la base de données
|
||||
abstract class DbActivityEvent extends Equatable {
|
||||
const DbActivityEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger les activités d'un voyage depuis la DB
|
||||
class LoadDbActivities extends DbActivityEvent {
|
||||
final String tripId;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const LoadDbActivities({
|
||||
required this.tripId,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, tripParticipants];
|
||||
}
|
||||
|
||||
/// Rechercher des activités dans la DB
|
||||
class SearchDbActivities extends DbActivityEvent {
|
||||
final String tripId;
|
||||
final String query;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const SearchDbActivities({
|
||||
required this.tripId,
|
||||
required this.query,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, query, tripParticipants];
|
||||
}
|
||||
|
||||
/// Ajouter une activité à la DB
|
||||
class AddDbActivity extends DbActivityEvent {
|
||||
final Activity activity;
|
||||
|
||||
const AddDbActivity({required this.activity});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activity];
|
||||
}
|
||||
|
||||
/// Ajouter plusieurs activités à la DB
|
||||
class AddDbActivitiesBatch extends DbActivityEvent {
|
||||
final List<Activity> activities;
|
||||
|
||||
const AddDbActivitiesBatch({required this.activities});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activities];
|
||||
}
|
||||
|
||||
/// Voter pour une activité
|
||||
class VoteForDbActivity extends DbActivityEvent {
|
||||
final String activityId;
|
||||
final String userId;
|
||||
final bool isUpvote;
|
||||
|
||||
const VoteForDbActivity({
|
||||
required this.activityId,
|
||||
required this.userId,
|
||||
required this.isUpvote,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activityId, userId, isUpvote];
|
||||
}
|
||||
|
||||
/// Supprimer une activité
|
||||
class DeleteDbActivity extends DbActivityEvent {
|
||||
final String activityId;
|
||||
|
||||
const DeleteDbActivity({required this.activityId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activityId];
|
||||
}
|
||||
|
||||
/// Filtrer les activités
|
||||
class FilterDbActivities extends DbActivityEvent {
|
||||
final String? category;
|
||||
final bool? showFavorites;
|
||||
|
||||
const FilterDbActivities({
|
||||
this.category,
|
||||
this.showFavorites,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [category, showFavorites];
|
||||
}
|
||||
|
||||
/// Rafraîchir les activités
|
||||
class RefreshDbActivities extends DbActivityEvent {
|
||||
final String tripId;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const RefreshDbActivities({
|
||||
required this.tripId,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, tripParticipants];
|
||||
}
|
||||
|
||||
/// Mettre à jour une activité
|
||||
class UpdateDbActivity extends DbActivityEvent {
|
||||
final Activity activity;
|
||||
|
||||
const UpdateDbActivity({required this.activity});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activity];
|
||||
}
|
||||
|
||||
/// Basculer le favori d'une activité
|
||||
class ToggleDbActivityFavorite extends DbActivityEvent {
|
||||
final String activityId;
|
||||
final String userId;
|
||||
|
||||
const ToggleDbActivityFavorite({
|
||||
required this.activityId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activityId, userId];
|
||||
}
|
||||
|
||||
/// Effacer les résultats de recherche
|
||||
class ClearDbSearchResults extends DbActivityEvent {
|
||||
const ClearDbSearchResults();
|
||||
}
|
||||
86
lib/blocs/activity/db_activities/db_activity_state.dart
Normal file
86
lib/blocs/activity/db_activities/db_activity_state.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../models/activity.dart';
|
||||
|
||||
/// States pour les activités de la base de données
|
||||
abstract class DbActivityState extends Equatable {
|
||||
const DbActivityState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class DbActivityInitial extends DbActivityState {
|
||||
const DbActivityInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class DbActivityLoading extends DbActivityState {
|
||||
const DbActivityLoading();
|
||||
}
|
||||
|
||||
/// État de recherche
|
||||
class DbActivitySearching extends DbActivityState {
|
||||
const DbActivitySearching();
|
||||
}
|
||||
|
||||
/// État avec les activités chargées
|
||||
class DbActivityLoaded extends DbActivityState {
|
||||
final List<Activity> activities;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const DbActivityLoaded({
|
||||
required this.activities,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activities, tripParticipants];
|
||||
|
||||
/// Créer une copie avec des modifications
|
||||
DbActivityLoaded copyWith({
|
||||
List<Activity>? activities,
|
||||
List<String>? tripParticipants,
|
||||
}) {
|
||||
return DbActivityLoaded(
|
||||
activities: activities ?? this.activities,
|
||||
tripParticipants: tripParticipants ?? this.tripParticipants,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// État avec résultats de recherche
|
||||
class DbActivitySearchResults extends DbActivityState {
|
||||
final List<Activity> results;
|
||||
final String query;
|
||||
final List<String> tripParticipants;
|
||||
|
||||
const DbActivitySearchResults({
|
||||
required this.results,
|
||||
required this.query,
|
||||
required this.tripParticipants,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [results, query, tripParticipants];
|
||||
}
|
||||
|
||||
/// État de succès d'opération
|
||||
class DbActivityOperationSuccess extends DbActivityState {
|
||||
final String message;
|
||||
|
||||
const DbActivityOperationSuccess(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class DbActivityError extends DbActivityState {
|
||||
final String message;
|
||||
|
||||
const DbActivityError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
157
lib/blocs/activity/google_activities/google_activity_bloc.dart
Normal file
157
lib/blocs/activity/google_activities/google_activity_bloc.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
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 'google_activity_event.dart';
|
||||
import 'google_activity_state.dart';
|
||||
|
||||
/// BLoC pour gérer les activités Google Places
|
||||
class GoogleActivityBloc extends Bloc<GoogleActivityEvent, GoogleActivityState> {
|
||||
final ActivityPlacesService _placesService;
|
||||
final ActivityRepository _repository;
|
||||
final ErrorService _errorService;
|
||||
|
||||
GoogleActivityBloc({
|
||||
required ActivityPlacesService placesService,
|
||||
required ActivityRepository repository,
|
||||
required ErrorService errorService,
|
||||
}) : _placesService = placesService,
|
||||
_repository = repository,
|
||||
_errorService = errorService,
|
||||
super(const GoogleActivityInitial()) {
|
||||
|
||||
on<LoadGoogleActivities>(_onLoadGoogleActivities);
|
||||
on<LoadMoreGoogleActivities>(_onLoadMoreGoogleActivities);
|
||||
on<UpdateGoogleActivities>(_onUpdateGoogleActivities);
|
||||
on<AddGoogleActivityToDb>(_onAddGoogleActivityToDb);
|
||||
on<SearchGoogleActivitiesByCategory>(_onSearchGoogleActivitiesByCategory);
|
||||
on<ClearGoogleActivities>(_onClearGoogleActivities);
|
||||
}
|
||||
|
||||
/// Charger les activités Google Places avec pagination (6 par page)
|
||||
Future<void> _onLoadGoogleActivities(
|
||||
LoadGoogleActivities event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const GoogleActivitySearching());
|
||||
|
||||
final result = await _placesService.searchActivitiesPaginated(
|
||||
destination: event.destination,
|
||||
tripId: event.tripId,
|
||||
category: event.category,
|
||||
pageSize: 6,
|
||||
);
|
||||
|
||||
emit(GoogleActivityLoaded(
|
||||
googleActivities: result['activities'] as List<Activity>,
|
||||
nextPageToken: result['nextPageToken'] as String?,
|
||||
hasMoreData: result['hasMoreData'] as bool? ?? false,
|
||||
query: event.category?.displayName ?? 'Toutes les activités',
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('google_activity_bloc', 'Erreur chargement activités Google: $e');
|
||||
emit(const GoogleActivityError('Impossible de charger les activités Google'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger plus d'activités (pagination)
|
||||
Future<void> _onLoadMoreGoogleActivities(
|
||||
LoadMoreGoogleActivities event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
if (state is! GoogleActivityLoaded) return;
|
||||
|
||||
final currentState = state as GoogleActivityLoaded;
|
||||
if (!currentState.hasMoreData || currentState.nextPageToken == null) return;
|
||||
|
||||
try {
|
||||
emit(GoogleActivityLoadingMore(
|
||||
currentActivities: currentState.googleActivities,
|
||||
query: currentState.query,
|
||||
));
|
||||
|
||||
final result = await _placesService.searchActivitiesPaginated(
|
||||
destination: event.destination,
|
||||
tripId: event.tripId,
|
||||
category: event.category,
|
||||
pageSize: 6,
|
||||
nextPageToken: currentState.nextPageToken,
|
||||
);
|
||||
|
||||
final newActivities = result['activities'] as List<Activity>;
|
||||
final allActivities = [...currentState.googleActivities, ...newActivities];
|
||||
|
||||
emit(GoogleActivityLoaded(
|
||||
googleActivities: allActivities,
|
||||
nextPageToken: result['nextPageToken'] as String?,
|
||||
hasMoreData: result['hasMoreData'] as bool? ?? false,
|
||||
query: currentState.query,
|
||||
));
|
||||
} catch (e) {
|
||||
_errorService.logError('google_activity_bloc', 'Erreur chargement plus activités: $e');
|
||||
emit(const GoogleActivityError('Impossible de charger plus d\'activités'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour les activités Google
|
||||
Future<void> _onUpdateGoogleActivities(
|
||||
UpdateGoogleActivities event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
emit(GoogleActivityLoaded(
|
||||
googleActivities: event.activities,
|
||||
nextPageToken: event.nextPageToken,
|
||||
hasMoreData: event.hasMoreData,
|
||||
query: event.query,
|
||||
));
|
||||
}
|
||||
|
||||
/// Ajouter une activité Google à la base de données
|
||||
Future<void> _onAddGoogleActivityToDb(
|
||||
AddGoogleActivityToDb event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
try {
|
||||
// Vérifier si l'activité existe déjà
|
||||
if (event.activity.placeId != null) {
|
||||
final existingActivity = await _repository.findExistingActivity(
|
||||
event.activity.tripId,
|
||||
event.activity.placeId!,
|
||||
);
|
||||
|
||||
if (existingActivity != null) {
|
||||
emit(const GoogleActivityOperationSuccess('Cette activité est déjà dans votre voyage'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.addActivity(event.activity);
|
||||
emit(const GoogleActivityOperationSuccess('Activité ajoutée au voyage'));
|
||||
} catch (e) {
|
||||
_errorService.logError('google_activity_bloc', 'Erreur ajout activité: $e');
|
||||
emit(const GoogleActivityError('Impossible d\'ajouter l\'activité'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechercher par catégorie
|
||||
Future<void> _onSearchGoogleActivitiesByCategory(
|
||||
SearchGoogleActivitiesByCategory event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
add(LoadGoogleActivities(
|
||||
tripId: event.tripId,
|
||||
destination: event.destination,
|
||||
category: event.category,
|
||||
));
|
||||
}
|
||||
|
||||
/// Effacer les activités Google
|
||||
Future<void> _onClearGoogleActivities(
|
||||
ClearGoogleActivities event,
|
||||
Emitter<GoogleActivityState> emit,
|
||||
) async {
|
||||
emit(const GoogleActivityInitial());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../models/activity.dart';
|
||||
|
||||
/// Events pour les activités Google Places
|
||||
abstract class GoogleActivityEvent extends Equatable {
|
||||
const GoogleActivityEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger les activités Google Places
|
||||
class LoadGoogleActivities extends GoogleActivityEvent {
|
||||
final String tripId;
|
||||
final String destination;
|
||||
final ActivityCategory? category;
|
||||
|
||||
const LoadGoogleActivities({
|
||||
required this.tripId,
|
||||
required this.destination,
|
||||
this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, destination, category];
|
||||
}
|
||||
|
||||
/// Charger plus d'activités Google (pagination)
|
||||
class LoadMoreGoogleActivities extends GoogleActivityEvent {
|
||||
final String tripId;
|
||||
final String destination;
|
||||
final ActivityCategory? category;
|
||||
final String? nextPageToken;
|
||||
|
||||
const LoadMoreGoogleActivities({
|
||||
required this.tripId,
|
||||
required this.destination,
|
||||
this.category,
|
||||
this.nextPageToken,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, destination, category, nextPageToken];
|
||||
}
|
||||
|
||||
/// Mettre à jour les activités Google
|
||||
class UpdateGoogleActivities extends GoogleActivityEvent {
|
||||
final List<Activity> activities;
|
||||
final String? nextPageToken;
|
||||
final bool hasMoreData;
|
||||
final String query;
|
||||
|
||||
const UpdateGoogleActivities({
|
||||
required this.activities,
|
||||
this.nextPageToken,
|
||||
required this.hasMoreData,
|
||||
required this.query,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activities, nextPageToken, hasMoreData, query];
|
||||
}
|
||||
|
||||
/// Ajouter une activité Google à la DB
|
||||
class AddGoogleActivityToDb extends GoogleActivityEvent {
|
||||
final Activity activity;
|
||||
|
||||
const AddGoogleActivityToDb({required this.activity});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [activity];
|
||||
}
|
||||
|
||||
/// Rechercher des activités Google par catégorie
|
||||
class SearchGoogleActivitiesByCategory extends GoogleActivityEvent {
|
||||
final String tripId;
|
||||
final String destination;
|
||||
final ActivityCategory category;
|
||||
|
||||
const SearchGoogleActivitiesByCategory({
|
||||
required this.tripId,
|
||||
required this.destination,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, destination, category];
|
||||
}
|
||||
|
||||
/// Effacer les résultats Google
|
||||
class ClearGoogleActivities extends GoogleActivityEvent {
|
||||
const ClearGoogleActivities();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../models/activity.dart';
|
||||
|
||||
/// States pour les activités Google Places
|
||||
abstract class GoogleActivityState extends Equatable {
|
||||
const GoogleActivityState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class GoogleActivityInitial extends GoogleActivityState {
|
||||
const GoogleActivityInitial();
|
||||
}
|
||||
|
||||
/// État de recherche
|
||||
class GoogleActivitySearching extends GoogleActivityState {
|
||||
const GoogleActivitySearching();
|
||||
}
|
||||
|
||||
/// État avec les activités Google chargées
|
||||
class GoogleActivityLoaded extends GoogleActivityState {
|
||||
final List<Activity> googleActivities;
|
||||
final String? nextPageToken;
|
||||
final bool hasMoreData;
|
||||
final String query;
|
||||
|
||||
const GoogleActivityLoaded({
|
||||
required this.googleActivities,
|
||||
this.nextPageToken,
|
||||
required this.hasMoreData,
|
||||
required this.query,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [googleActivities, nextPageToken, hasMoreData, query];
|
||||
|
||||
/// Créer une copie avec des modifications
|
||||
GoogleActivityLoaded copyWith({
|
||||
List<Activity>? googleActivities,
|
||||
String? nextPageToken,
|
||||
bool? hasMoreData,
|
||||
String? query,
|
||||
}) {
|
||||
return GoogleActivityLoaded(
|
||||
googleActivities: googleActivities ?? this.googleActivities,
|
||||
nextPageToken: nextPageToken ?? this.nextPageToken,
|
||||
hasMoreData: hasMoreData ?? this.hasMoreData,
|
||||
query: query ?? this.query,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// État de chargement de plus d'activités (pagination)
|
||||
class GoogleActivityLoadingMore extends GoogleActivityState {
|
||||
final List<Activity> currentActivities;
|
||||
final String query;
|
||||
|
||||
const GoogleActivityLoadingMore({
|
||||
required this.currentActivities,
|
||||
required this.query,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [currentActivities, query];
|
||||
}
|
||||
|
||||
/// État de succès d'opération
|
||||
class GoogleActivityOperationSuccess extends GoogleActivityState {
|
||||
final String message;
|
||||
|
||||
const GoogleActivityOperationSuccess(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class GoogleActivityError extends GoogleActivityState {
|
||||
final String message;
|
||||
|
||||
const GoogleActivityError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,28 +9,29 @@ class ActivityCard extends StatelessWidget {
|
||||
final VoidCallback? onAddToTrip;
|
||||
|
||||
const ActivityCard({
|
||||
Key? key,
|
||||
super.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);
|
||||
final totalVotes = activity.positiveVotes + activity.negativeVotes;
|
||||
final positivePercentage = totalVotes > 0 ? (activity.positiveVotes / totalVotes) : 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -40,32 +41,32 @@ class ActivityCard extends StatelessWidget {
|
||||
// Image de l'activité
|
||||
if (activity.imageUrl != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
activity.imageUrl!,
|
||||
height: 200,
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 200,
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceVariant,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Icon(
|
||||
child: const Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Badge catégorie
|
||||
// Badge catégorie (simplifié)
|
||||
if (activity.category.isNotEmpty)
|
||||
Positioned(
|
||||
top: 12,
|
||||
@@ -73,13 +74,14 @@ class ActivityCard extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(activity.category),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
activity.category,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -88,6 +90,21 @@ class ActivityCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Placeholder si pas d'image
|
||||
Container(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Contenu de la carte
|
||||
@@ -103,9 +120,10 @@ class ActivityCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -116,19 +134,20 @@ class ActivityCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.star, size: 14, color: Colors.amber),
|
||||
const Icon(Icons.star, size: 12, color: Color(0xFFFFB400)),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
activity.rating!.toStringAsFixed(1),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.amber[800],
|
||||
color: Color(0xFFB8860B),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -140,161 +159,186 @@ class ActivityCard extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Description
|
||||
// Description (simplifiée)
|
||||
Text(
|
||||
activity.description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6B6B6B),
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations supplémentaires
|
||||
if (activity.priceLevel != null || activity.address != null) ...[
|
||||
// Adresse (si disponible)
|
||||
if (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),
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activity.address!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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),
|
||||
// Section vote avec barre de progression
|
||||
if (activity.id.isNotEmpty) ...[
|
||||
// Barre de progression des votes
|
||||
Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE0E0E0),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: totalVotes > 0
|
||||
? Row(
|
||||
children: [
|
||||
if (activity.positiveVotes > 0)
|
||||
Expanded(
|
||||
flex: activity.positiveVotes,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4CAF50),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(3),
|
||||
bottomLeft: const Radius.circular(3),
|
||||
topRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero,
|
||||
bottomRight: activity.negativeVotes == 0 ? const Radius.circular(3) : Radius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (activity.negativeVotes > 0)
|
||||
Expanded(
|
||||
flex: activity.negativeVotes,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF44336),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero,
|
||||
bottomLeft: activity.positiveVotes == 0 ? const Radius.circular(3) : Radius.zero,
|
||||
topRight: const Radius.circular(3),
|
||||
bottomRight: const Radius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Stats et boutons de vote
|
||||
Row(
|
||||
children: [
|
||||
// Stats des votes
|
||||
Text(
|
||||
totalVotes > 0
|
||||
? '${(positivePercentage * 100).round()}% positif • $totalVotes vote${totalVotes > 1 ? 's' : ''}'
|
||||
: 'Aucun vote',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Boutons de vote compacts
|
||||
Row(
|
||||
children: [
|
||||
// Vote positif
|
||||
_buildVoteButton(
|
||||
_buildCompactVoteButton(
|
||||
icon: Icons.thumb_up,
|
||||
count: activity.positiveVotes,
|
||||
isActive: userVote == 1,
|
||||
onTap: () => onVote(userVote == 1 ? 0 : 1),
|
||||
activeColor: Colors.blue,
|
||||
activeColor: const Color(0xFF4CAF50),
|
||||
),
|
||||
|
||||
Container(
|
||||
width: 1,
|
||||
height: 24,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.2),
|
||||
),
|
||||
|
||||
// Vote négatif
|
||||
_buildVoteButton(
|
||||
const SizedBox(width: 8),
|
||||
_buildCompactVoteButton(
|
||||
icon: Icons.thumb_down,
|
||||
count: activity.negativeVotes,
|
||||
isActive: userVote == -1,
|
||||
onTap: () => onVote(userVote == -1 ? 0 : -1),
|
||||
activeColor: Colors.red,
|
||||
activeColor: const Color(0xFFF44336),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// Pour les activités non sauvegardées
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getScoreColor(activity.totalVotes).withOpacity(0.2),
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getScoreColor(activity.totalVotes),
|
||||
),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'vote${activity.votes.length > 1 ? 's' : ''}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _getScoreColor(activity.totalVotes),
|
||||
'Ajoutez pour voter',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Bouton d'ajout
|
||||
if (onAddToTrip != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAddToTrip,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Ajouter'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF007AFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -303,9 +347,8 @@ class ActivityCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVoteButton({
|
||||
Widget _buildCompactVoteButton({
|
||||
required IconData icon,
|
||||
required int count,
|
||||
required bool isActive,
|
||||
required VoidCallback onTap,
|
||||
required Color activeColor,
|
||||
@@ -313,62 +356,19 @@ class ActivityCard extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : const Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../blocs/activity/activity_bloc.dart';
|
||||
import '../../blocs/activity/activity_event.dart';
|
||||
import '../../models/activity.dart';
|
||||
@@ -41,10 +42,17 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final keyboardHeight = mediaQuery.viewInsets.bottom;
|
||||
|
||||
return Container(
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: mediaQuery.size.height * 0.85,
|
||||
margin: const EdgeInsets.all(16),
|
||||
margin: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -84,10 +92,15 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Formulaire
|
||||
// Formulaire avec SingleChildScrollView pour le scroll automatique
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: math.max(20, keyboardHeight),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
|
||||
@@ -67,6 +67,23 @@ class Activity {
|
||||
return votes[userId] ?? 0;
|
||||
}
|
||||
|
||||
/// Vérifie si tous les participants du voyage ont voté positivement pour cette activité
|
||||
bool isApprovedByAllParticipants(List<String> tripParticipants) {
|
||||
if (tripParticipants.isEmpty) return false;
|
||||
|
||||
// Tous les participants doivent avoir voté
|
||||
for (String participantId in tripParticipants) {
|
||||
if (!votes.containsKey(participantId)) {
|
||||
return false; // Quelqu'un n'a pas encore voté
|
||||
}
|
||||
if (votes[participantId] != 1) {
|
||||
return false; // Quelqu'un a voté négativement ou neutre
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Tous ont voté positivement
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
Activity copyWith({
|
||||
String? id,
|
||||
|
||||
@@ -37,16 +37,20 @@ class ActivityRepository {
|
||||
try {
|
||||
print('ActivityRepository: Récupération des activités pour le voyage: $tripId');
|
||||
|
||||
// Modifié pour éviter l'erreur d'index composite
|
||||
// On récupère d'abord par tripId, puis on trie en mémoire
|
||||
final querySnapshot = await _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.get();
|
||||
|
||||
final activities = querySnapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.toList();
|
||||
|
||||
// Tri en mémoire par date de mise à jour (plus récent en premier)
|
||||
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
print('ActivityRepository: ${activities.length} activités trouvées');
|
||||
return activities;
|
||||
} catch (e) {
|
||||
@@ -111,6 +115,19 @@ class ActivityRepository {
|
||||
/// Vote pour une activité
|
||||
Future<bool> voteForActivity(String activityId, String userId, int vote) async {
|
||||
try {
|
||||
// Validation des paramètres
|
||||
if (activityId.isEmpty) {
|
||||
print('ActivityRepository: ID d\'activité vide');
|
||||
_errorService.logError('activity_repository', 'ID d\'activité vide pour le vote');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userId.isEmpty) {
|
||||
print('ActivityRepository: ID d\'utilisateur vide');
|
||||
_errorService.logError('activity_repository', 'ID d\'utilisateur vide pour le vote');
|
||||
return false;
|
||||
}
|
||||
|
||||
print('ActivityRepository: Vote pour l\'activité $activityId: $vote');
|
||||
|
||||
// vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote
|
||||
@@ -155,12 +172,16 @@ class ActivityRepository {
|
||||
return _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs
|
||||
final activities = snapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.toList();
|
||||
|
||||
// Tri en mémoire par date de mise à jour (plus récent en premier)
|
||||
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
return activities;
|
||||
});
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur stream activités: $e');
|
||||
@@ -198,16 +219,23 @@ class ActivityRepository {
|
||||
/// Recherche des activités par catégorie
|
||||
Future<List<Activity>> getActivitiesByCategory(String tripId, String category) async {
|
||||
try {
|
||||
print('ActivityRepository: Recherche par catégorie: $category pour le voyage: $tripId');
|
||||
|
||||
// Récupérer toutes les activités du voyage puis filtrer en mémoire
|
||||
final querySnapshot = await _firestore
|
||||
.collection(_collection)
|
||||
.where('tripId', isEqualTo: tripId)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.get();
|
||||
|
||||
return querySnapshot.docs
|
||||
final activities = querySnapshot.docs
|
||||
.map((doc) => Activity.fromSnapshot(doc))
|
||||
.where((activity) => activity.category == category)
|
||||
.toList();
|
||||
|
||||
// Tri en mémoire par date de mise à jour (plus récent en premier)
|
||||
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
return activities;
|
||||
} catch (e) {
|
||||
print('ActivityRepository: Erreur recherche par catégorie: $e');
|
||||
_errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e');
|
||||
|
||||
@@ -19,15 +19,14 @@ class ActivityPlacesService {
|
||||
required String tripId,
|
||||
ActivityCategory? category,
|
||||
int radius = 5000,
|
||||
int maxResults = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
print('ActivityPlacesService: Recherche d\'activités pour: $destination');
|
||||
print('ActivityPlacesService: Recherche d\'activités pour: $destination (max: $maxResults, offset: $offset)');
|
||||
|
||||
// 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 = [];
|
||||
@@ -67,8 +66,21 @@ class ActivityPlacesService {
|
||||
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
|
||||
print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées au total');
|
||||
|
||||
// 4. Appliquer la pagination
|
||||
final startIndex = offset;
|
||||
final endIndex = (startIndex + maxResults).clamp(0, uniqueActivities.length);
|
||||
|
||||
if (startIndex >= uniqueActivities.length) {
|
||||
print('ActivityPlacesService: Offset $startIndex dépasse le nombre total (${uniqueActivities.length})');
|
||||
return [];
|
||||
}
|
||||
|
||||
final paginatedResults = uniqueActivities.sublist(startIndex, endIndex);
|
||||
print('ActivityPlacesService: Retour de ${paginatedResults.length} activités (offset: $offset, max: $maxResults)');
|
||||
|
||||
return paginatedResults;
|
||||
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur lors de la recherche: $e');
|
||||
@@ -78,29 +90,56 @@ class ActivityPlacesService {
|
||||
}
|
||||
|
||||
/// Géocode une destination pour obtenir les coordonnées
|
||||
Future<Map<String, double>?> _geocodeDestination(String destination) async {
|
||||
Future<Map<String, dynamic>> _geocodeDestination(String destination) async {
|
||||
try {
|
||||
// Vérifier que la clé API est configurée
|
||||
if (_apiKey.isEmpty) {
|
||||
print('ActivityPlacesService: Clé API Google Maps manquante');
|
||||
throw Exception('Clé API Google Maps non configurée');
|
||||
}
|
||||
|
||||
final encodedDestination = Uri.encodeComponent(destination);
|
||||
final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey';
|
||||
|
||||
print('ActivityPlacesService: Géocodage de "$destination"');
|
||||
print('ActivityPlacesService: URL = $url');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
print('ActivityPlacesService: Status code = ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
print('ActivityPlacesService: Réponse géocodage = ${data['status']}');
|
||||
|
||||
if (data['status'] == 'OK' && data['results'].isNotEmpty) {
|
||||
final location = data['results'][0]['geometry']['location'];
|
||||
return {
|
||||
final coordinates = {
|
||||
'lat': location['lat'].toDouble(),
|
||||
'lng': location['lng'].toDouble(),
|
||||
};
|
||||
print('ActivityPlacesService: Coordonnées trouvées = $coordinates');
|
||||
return coordinates;
|
||||
} else {
|
||||
print('ActivityPlacesService: Erreur API = ${data['error_message'] ?? data['status']}');
|
||||
if (data['status'] == 'REQUEST_DENIED') {
|
||||
throw Exception('🔑 Clé API non autorisée. Activez les APIs suivantes dans Google Cloud Console:\n'
|
||||
'• Geocoding API\n'
|
||||
'• Places API\n'
|
||||
'• Maps JavaScript API\n'
|
||||
'Puis ajoutez des restrictions appropriées.');
|
||||
} else if (data['status'] == 'ZERO_RESULTS') {
|
||||
throw Exception('Aucun résultat trouvé pour cette destination');
|
||||
} else {
|
||||
throw Exception('Erreur API: ${data['status']}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw Exception('Erreur HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur géocodage: $e');
|
||||
return null;
|
||||
throw e; // Rethrow pour permettre la gestion d'erreur en amont
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +307,6 @@ class ActivityPlacesService {
|
||||
|
||||
// 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'
|
||||
@@ -338,4 +374,190 @@ class ActivityPlacesService {
|
||||
|
||||
return ActivityCategory.attraction; // Par défaut
|
||||
}
|
||||
|
||||
/// Recherche d'activités avec pagination (6 par page)
|
||||
Future<Map<String, dynamic>> searchActivitiesPaginated({
|
||||
required String destination,
|
||||
required String tripId,
|
||||
ActivityCategory? category,
|
||||
int pageSize = 6,
|
||||
String? nextPageToken,
|
||||
int radius = 5000,
|
||||
}) async {
|
||||
try {
|
||||
print('ActivityPlacesService: Recherche paginée pour: $destination (page: ${nextPageToken ?? "première"})');
|
||||
|
||||
// 1. Géocoder la destination
|
||||
final coordinates = await _geocodeDestination(destination);
|
||||
|
||||
// 2. Rechercher les activités par catégorie avec pagination
|
||||
if (category != null) {
|
||||
return await _searchByCategoryPaginated(
|
||||
coordinates['lat']!,
|
||||
coordinates['lng']!,
|
||||
category,
|
||||
tripId,
|
||||
radius,
|
||||
pageSize,
|
||||
nextPageToken,
|
||||
);
|
||||
} else {
|
||||
// Pour toutes les catégories, faire une recherche générale paginée
|
||||
return await _searchAllCategoriesPaginated(
|
||||
coordinates['lat']!,
|
||||
coordinates['lng']!,
|
||||
tripId,
|
||||
radius,
|
||||
pageSize,
|
||||
nextPageToken,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur recherche paginée: $e');
|
||||
_errorService.logError('activity_places_service', e);
|
||||
return {
|
||||
'activities': <Activity>[],
|
||||
'nextPageToken': null,
|
||||
'hasMoreData': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche paginée par catégorie spécifique
|
||||
Future<Map<String, dynamic>> _searchByCategoryPaginated(
|
||||
double lat,
|
||||
double lng,
|
||||
ActivityCategory category,
|
||||
String tripId,
|
||||
int radius,
|
||||
int pageSize,
|
||||
String? nextPageToken,
|
||||
) async {
|
||||
try {
|
||||
String url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json'
|
||||
'?location=$lat,$lng'
|
||||
'&radius=$radius'
|
||||
'&type=${category.googlePlaceType}'
|
||||
'&key=$_apiKey';
|
||||
|
||||
if (nextPageToken != null) {
|
||||
url += '&pagetoken=$nextPageToken';
|
||||
}
|
||||
|
||||
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 = [];
|
||||
final results = data['results'] as List? ?? [];
|
||||
|
||||
// Limiter à pageSize résultats
|
||||
final limitedResults = results.take(pageSize).toList();
|
||||
|
||||
for (final place in limitedResults) {
|
||||
try {
|
||||
final activity = await _convertPlaceToActivity(place, tripId, category);
|
||||
if (activity != null) {
|
||||
activities.add(activity);
|
||||
}
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur conversion place: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'activities': activities,
|
||||
'nextPageToken': data['next_page_token'],
|
||||
'hasMoreData': data['next_page_token'] != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'activities': <Activity>[],
|
||||
'nextPageToken': null,
|
||||
'hasMoreData': false,
|
||||
};
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur recherche catégorie paginée: $e');
|
||||
return {
|
||||
'activities': <Activity>[],
|
||||
'nextPageToken': null,
|
||||
'hasMoreData': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche paginée pour toutes les catégories
|
||||
Future<Map<String, dynamic>> _searchAllCategoriesPaginated(
|
||||
double lat,
|
||||
double lng,
|
||||
String tripId,
|
||||
int radius,
|
||||
int pageSize,
|
||||
String? nextPageToken,
|
||||
) async {
|
||||
try {
|
||||
// Pour toutes les catégories, on utilise une recherche plus générale
|
||||
String url = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json'
|
||||
'?location=$lat,$lng'
|
||||
'&radius=$radius'
|
||||
'&type=tourist_attraction'
|
||||
'&key=$_apiKey';
|
||||
|
||||
if (nextPageToken != null) {
|
||||
url += '&pagetoken=$nextPageToken';
|
||||
}
|
||||
|
||||
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 = [];
|
||||
final results = data['results'] as List? ?? [];
|
||||
|
||||
// Limiter à pageSize résultats
|
||||
final limitedResults = results.take(pageSize).toList();
|
||||
|
||||
for (final place in limitedResults) {
|
||||
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': activities,
|
||||
'nextPageToken': data['next_page_token'],
|
||||
'hasMoreData': data['next_page_token'] != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'activities': <Activity>[],
|
||||
'nextPageToken': null,
|
||||
'hasMoreData': false,
|
||||
};
|
||||
} catch (e) {
|
||||
print('ActivityPlacesService: Erreur recherche toutes catégories paginée: $e');
|
||||
return {
|
||||
'activities': <Activity>[],
|
||||
'nextPageToken': null,
|
||||
'hasMoreData': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user