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:
Dayron
2025-11-04 20:21:54 +01:00
parent 8ff9e12fd4
commit f6c8432335
19 changed files with 2902 additions and 961 deletions

View File

@@ -61,17 +61,36 @@ class ActivityBloc extends Bloc<ActivityEvent, ActivityState> {
Emitter<ActivityState> emit, Emitter<ActivityState> emit,
) async { ) async {
try { 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( final searchResults = await _placesService.searchActivities(
destination: event.destination, destination: event.destination,
tripId: event.tripId, tripId: event.tripId,
category: event.category, category: event.category,
maxResults: event.maxResults ?? 20, // Par défaut 20, ou utiliser la valeur spécifiée
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( emit(ActivitySearchResults(
searchResults: searchResults, searchResults: finalResults,
query: event.category?.displayName ?? 'Toutes les activités', query: event.category?.displayName ?? 'Toutes les activités',
isLoading: false,
)); ));
} catch (e) { } catch (e) {
_errorService.logError('activity_bloc', 'Erreur recherche activités: $e'); _errorService.logError('activity_bloc', 'Erreur recherche activités: $e');

View File

@@ -24,15 +24,23 @@ class SearchActivities extends ActivityEvent {
final String tripId; final String tripId;
final String destination; final String destination;
final ActivityCategory? category; final ActivityCategory? category;
final int? maxResults;
final int? offset;
final bool reset;
final bool appendToExisting;
const SearchActivities({ const SearchActivities({
required this.tripId, required this.tripId,
required this.destination, required this.destination,
this.category, this.category,
this.maxResults,
this.offset,
this.reset = false,
this.appendToExisting = false,
}); });
@override @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 /// Event to search activities by text query

View File

@@ -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());
}
}
}

View File

@@ -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();
}

View File

@@ -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];
}

View 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());
}
}
}

View 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();
}

View 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];
}

View 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());
}
}

View File

@@ -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();
}

View File

@@ -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

View File

@@ -9,28 +9,29 @@ class ActivityCard extends StatelessWidget {
final VoidCallback? onAddToTrip; final VoidCallback? onAddToTrip;
const ActivityCard({ const ActivityCard({
Key? key, super.key,
required this.activity, required this.activity,
required this.currentUserId, required this.currentUserId,
required this.onVote, required this.onVote,
this.onAddToTrip, this.onAddToTrip,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
final userVote = activity.getUserVote(currentUserId); final userVote = activity.getUserVote(currentUserId);
final totalVotes = activity.positiveVotes + activity.negativeVotes;
final positivePercentage = totalVotes > 0 ? (activity.positiveVotes / totalVotes) : 0.0;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.cardColor, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), color: Colors.black.withOpacity(0.05),
blurRadius: 10, blurRadius: 8,
offset: const Offset(0, 5), offset: const Offset(0, 2),
), ),
], ],
), ),
@@ -40,32 +41,32 @@ class ActivityCard extends StatelessWidget {
// Image de l'activité // Image de l'activité
if (activity.imageUrl != null) ...[ if (activity.imageUrl != null) ...[
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Stack( child: Stack(
children: [ children: [
Image.network( Image.network(
activity.imageUrl!, activity.imageUrl!,
height: 200, height: 160,
width: double.infinity, width: double.infinity,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Container( return Container(
height: 200, height: 160,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: const BoxDecoration(
color: theme.colorScheme.surfaceVariant, color: Color(0xFFF5F5F5),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
), ),
child: Icon( child: const Icon(
Icons.image_not_supported, Icons.image_not_supported,
size: 48, size: 48,
color: theme.colorScheme.onSurfaceVariant, color: Color(0xFF9E9E9E),
), ),
); );
}, },
), ),
// Badge catégorie // Badge catégorie (simplifié)
if (activity.category.isNotEmpty) if (activity.category.isNotEmpty)
Positioned( Positioned(
top: 12, top: 12,
@@ -73,13 +74,14 @@ class ActivityCard extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCategoryColor(activity.category), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
activity.category, activity.category,
style: theme.textTheme.bodySmall?.copyWith( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500, 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 // Contenu de la carte
@@ -103,9 +120,10 @@ class ActivityCard extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
activity.name, activity.name,
style: theme.textTheme.titleMedium?.copyWith( style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 16,
color: theme.colorScheme.onSurface, fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -116,19 +134,20 @@ class ActivityCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2), color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, size: 14, color: Colors.amber), const Icon(Icons.star, size: 12, color: Color(0xFFFFB400)),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
activity.rating!.toStringAsFixed(1), activity.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith( style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.amber[800], color: Color(0xFFB8860B),
), ),
), ),
], ],
@@ -140,161 +159,186 @@ class ActivityCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// Description // Description (simplifiée)
Text( Text(
activity.description, activity.description,
style: theme.textTheme.bodyMedium?.copyWith( style: const TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.7), fontSize: 14,
color: Color(0xFF6B6B6B),
height: 1.4,
), ),
maxLines: 3, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Informations supplémentaires // Adresse (si disponible)
if (activity.priceLevel != null || activity.address != null) ...[ if (activity.address != null) ...[
Row( Row(
children: [ children: [
if (activity.priceLevel != null) ...[ const Icon(
Icon( Icons.location_on,
Icons.euro, size: 14,
size: 16, color: Color(0xFF9E9E9E),
color: theme.colorScheme.onSurface.withOpacity(0.5), ),
), const SizedBox(width: 4),
const SizedBox(width: 4), Expanded(
Text( child: Text(
activity.priceLevel!, activity.address!,
style: theme.textTheme.bodySmall?.copyWith( style: const TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.7), 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), const SizedBox(height: 12),
], ],
// Section vote et actions // Section vote avec barre de progression
Row( if (activity.id.isNotEmpty) ...[
children: [ // Barre de progression des votes
// Boutons de vote Container(
Container( height: 6,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5), color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(20), 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: [ children: [
// Vote positif _buildCompactVoteButton(
_buildVoteButton(
icon: Icons.thumb_up, icon: Icons.thumb_up,
count: activity.positiveVotes,
isActive: userVote == 1, isActive: userVote == 1,
onTap: () => onVote(userVote == 1 ? 0 : 1), onTap: () => onVote(userVote == 1 ? 0 : 1),
activeColor: Colors.blue, activeColor: const Color(0xFF4CAF50),
), ),
const SizedBox(width: 8),
Container( _buildCompactVoteButton(
width: 1,
height: 24,
color: theme.colorScheme.onSurface.withOpacity(0.2),
),
// Vote négatif
_buildVoteButton(
icon: Icons.thumb_down, icon: Icons.thumb_down,
count: activity.negativeVotes,
isActive: userVote == -1, isActive: userVote == -1,
onTap: () => onVote(userVote == -1 ? 0 : -1), onTap: () => onVote(userVote == -1 ? 0 : -1),
activeColor: Colors.red, activeColor: const Color(0xFFF44336),
), ),
], ],
), ),
), ],
),
const Spacer(), ] else ...[
// Pour les activités non sauvegardées
// Bouton d'action Row(
if (onAddToTrip != null) children: [
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( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getScoreColor(activity.totalVotes).withOpacity(0.2), color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Icon(
'${activity.totalVotes > 0 ? '+' : ''}${activity.totalVotes}', Icons.info_outline,
style: theme.textTheme.bodySmall?.copyWith( size: 14,
fontWeight: FontWeight.bold, color: Color(0xFF9E9E9E),
color: _getScoreColor(activity.totalVotes),
),
), ),
const SizedBox(width: 4), SizedBox(width: 4),
Text( Text(
'vote${activity.votes.length > 1 ? 's' : ''}', 'Ajoutez pour voter',
style: theme.textTheme.bodySmall?.copyWith( style: TextStyle(
color: _getScoreColor(activity.totalVotes), 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 IconData icon,
required int count,
required bool isActive, required bool isActive,
required VoidCallback onTap, required VoidCallback onTap,
required Color activeColor, required Color activeColor,
@@ -313,62 +356,19 @@ class ActivityCard extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), width: 32,
child: Row( height: 32,
mainAxisSize: MainAxisSize.min, decoration: BoxDecoration(
children: [ color: isActive ? activeColor : const Color(0xFFF5F5F5),
Icon( borderRadius: BorderRadius.circular(16),
icon, ),
size: 18, child: Icon(
color: isActive ? activeColor : Colors.grey, icon,
), size: 16,
if (count > 0) ...[ color: isActive ? Colors.white : const Color(0xFF9E9E9E),
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;
}
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:math' as math;
import '../../blocs/activity/activity_bloc.dart'; import '../../blocs/activity/activity_bloc.dart';
import '../../blocs/activity/activity_event.dart'; import '../../blocs/activity/activity_event.dart';
import '../../models/activity.dart'; import '../../models/activity.dart';
@@ -41,10 +42,17 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.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, height: mediaQuery.size.height * 0.85,
margin: const EdgeInsets.all(16), margin: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.cardColor, color: theme.cardColor,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -84,10 +92,15 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
const Divider(), const Divider(),
// Formulaire // Formulaire avec SingleChildScrollView pour le scroll automatique
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: math.max(20, keyboardHeight),
),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(

View File

@@ -67,6 +67,23 @@ class Activity {
return votes[userId] ?? 0; 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 /// Crée une copie avec des modifications
Activity copyWith({ Activity copyWith({
String? id, String? id,

View File

@@ -37,16 +37,20 @@ class ActivityRepository {
try { try {
print('ActivityRepository: Récupération des activités pour le voyage: $tripId'); print('ActivityRepository: Récupération des activités pour le voyage: $tripId');
// Modifié pour éviter l'erreur d'index composite
// On récupère d'abord par tripId, puis on trie en mémoire
final querySnapshot = await _firestore final querySnapshot = await _firestore
.collection(_collection) .collection(_collection)
.where('tripId', isEqualTo: tripId) .where('tripId', isEqualTo: tripId)
.orderBy('updatedAt', descending: true)
.get(); .get();
final activities = querySnapshot.docs final activities = querySnapshot.docs
.map((doc) => Activity.fromSnapshot(doc)) .map((doc) => Activity.fromSnapshot(doc))
.toList(); .toList();
// Tri en mémoire par date de mise à jour (plus récent en premier)
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
print('ActivityRepository: ${activities.length} activités trouvées'); print('ActivityRepository: ${activities.length} activités trouvées');
return activities; return activities;
} catch (e) { } catch (e) {
@@ -111,6 +115,19 @@ class ActivityRepository {
/// Vote pour une activité /// Vote pour une activité
Future<bool> voteForActivity(String activityId, String userId, int vote) async { Future<bool> voteForActivity(String activityId, String userId, int vote) async {
try { try {
// Validation des paramètres
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'); print('ActivityRepository: Vote pour l\'activité $activityId: $vote');
// vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote // vote: 1 pour positif, -1 pour négatif, 0 pour supprimer le vote
@@ -155,12 +172,16 @@ class ActivityRepository {
return _firestore return _firestore
.collection(_collection) .collection(_collection)
.where('tripId', isEqualTo: tripId) .where('tripId', isEqualTo: tripId)
.orderBy('updatedAt', descending: true)
.snapshots() .snapshots()
.map((snapshot) { .map((snapshot) {
return snapshot.docs final activities = snapshot.docs
.map((doc) => Activity.fromSnapshot(doc)) .map((doc) => Activity.fromSnapshot(doc))
.toList(); .toList();
// Tri en mémoire par date de mise à jour (plus récent en premier)
activities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return activities;
}); });
} catch (e) { } catch (e) {
print('ActivityRepository: Erreur stream activités: $e'); print('ActivityRepository: Erreur stream activités: $e');
@@ -198,16 +219,23 @@ class ActivityRepository {
/// Recherche des activités par catégorie /// Recherche des activités par catégorie
Future<List<Activity>> getActivitiesByCategory(String tripId, String category) async { Future<List<Activity>> getActivitiesByCategory(String tripId, String category) async {
try { try {
print('ActivityRepository: Recherche par catégorie: $category pour le voyage: $tripId');
// Récupérer toutes les activités du voyage puis filtrer en mémoire
final querySnapshot = await _firestore final querySnapshot = await _firestore
.collection(_collection) .collection(_collection)
.where('tripId', isEqualTo: tripId) .where('tripId', isEqualTo: tripId)
.where('category', isEqualTo: category)
.orderBy('updatedAt', descending: true)
.get(); .get();
return querySnapshot.docs final activities = querySnapshot.docs
.map((doc) => Activity.fromSnapshot(doc)) .map((doc) => Activity.fromSnapshot(doc))
.where((activity) => activity.category == category)
.toList(); .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) { } catch (e) {
print('ActivityRepository: Erreur recherche par catégorie: $e'); print('ActivityRepository: Erreur recherche par catégorie: $e');
_errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e'); _errorService.logError('activity_repository', 'Erreur recherche par catégorie: $e');

View File

@@ -19,15 +19,14 @@ class ActivityPlacesService {
required String tripId, required String tripId,
ActivityCategory? category, ActivityCategory? category,
int radius = 5000, int radius = 5000,
int maxResults = 20,
int offset = 0,
}) async { }) async {
try { 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 // 1. Géocoder la destination
final coordinates = await _geocodeDestination(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 // 2. Rechercher les activités par catégorie ou toutes les catégories
List<Activity> allActivities = []; List<Activity> allActivities = [];
@@ -67,8 +66,21 @@ class ActivityPlacesService {
final uniqueActivities = _removeDuplicates(allActivities); final uniqueActivities = _removeDuplicates(allActivities);
uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0)); uniqueActivities.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0));
print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées'); print('ActivityPlacesService: ${uniqueActivities.length} activités trouvées au total');
return uniqueActivities.take(50).toList(); // Limiter à 50 résultats
// 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) { } catch (e) {
print('ActivityPlacesService: Erreur lors de la recherche: $e'); print('ActivityPlacesService: Erreur lors de la recherche: $e');
@@ -78,29 +90,56 @@ class ActivityPlacesService {
} }
/// Géocode une destination pour obtenir les coordonnées /// 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 { 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 encodedDestination = Uri.encodeComponent(destination);
final url = 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey'; 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)); final response = await http.get(Uri.parse(url));
print('ActivityPlacesService: Status code = ${response.statusCode}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
print('ActivityPlacesService: Réponse géocodage = ${data['status']}');
if (data['status'] == 'OK' && data['results'].isNotEmpty) { if (data['status'] == 'OK' && data['results'].isNotEmpty) {
final location = data['results'][0]['geometry']['location']; final location = data['results'][0]['geometry']['location'];
return { final coordinates = {
'lat': location['lat'].toDouble(), 'lat': location['lat'].toDouble(),
'lng': location['lng'].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) { } catch (e) {
print('ActivityPlacesService: Erreur géocodage: $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 // Géocoder la destination
final coordinates = await _geocodeDestination(destination); final coordinates = await _geocodeDestination(destination);
if (coordinates == null) {
throw Exception('Impossible de localiser la destination');
}
final encodedQuery = Uri.encodeComponent(query); final encodedQuery = Uri.encodeComponent(query);
final url = 'https://maps.googleapis.com/maps/api/place/textsearch/json' final url = 'https://maps.googleapis.com/maps/api/place/textsearch/json'
@@ -338,4 +374,190 @@ class ActivityPlacesService {
return ActivityCategory.attraction; // Par défaut 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,
};
}
}
} }

View File

@@ -1,46 +0,0 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import '../lib/services/trip_image_service.dart';
import '../lib/firebase_options.dart';
/// Script pour nettoyer les doublons d'images de Londres
void main() async {
try {
// Initialiser Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
final tripImageService = TripImageService();
await tripImageService.cleanupDuplicateImages();
} catch (e) {
exit(1);
}
}
/*
Instructions d'utilisation:
1. Assurez-vous que Firebase est configuré dans votre projet
2. Exécutez: dart run scripts/cleanup_london_duplicates.dart
3. Le script analysera automatiquement tous les doublons et les supprimera
4. Vérifiez Firebase Storage après l'exécution
Le script:
- Groupe toutes les images par destination (normalisée)
- Identifie les doublons pour la même destination
- Garde l'image la plus récente (basé sur le timestamp)
- Supprime les anciennes versions
Pour Londres spécifiquement, si vous avez:
- Londres_Royaume_Uni_1762175016594.jpg
- Londres_Royaume_Uni_1762175016603.jpg
Le script gardera la version _1762175016603.jpg (plus récente)
et supprimera _1762175016594.jpg (plus ancienne)
*/

View File

@@ -1,81 +0,0 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_storage/firebase_storage.dart';
void main() async {
try {
await Firebase.initializeApp();
final storage = FirebaseStorage.instance;
final ref = storage.ref().child('trip_images');
final result = await ref.listAll();
// Grouper les images par ville
Map<String, List<Reference>> imagesByCity = {};
for (var item in result.items) {
final name = item.name;
// Extraire la ville du nom de fichier
String city = 'unknown';
if (name.contains('_')) {
// Format: londres_timestamp.jpg ou london_timestamp.jpg
city = name.split('_')[0].toLowerCase();
}
if (!imagesByCity.containsKey(city)) {
imagesByCity[city] = [];
}
imagesByCity[city]!.add(item);
}
for (var entry in imagesByCity.entries) {
}
// Focus sur Londres/London
final londonImages = <Reference>[];
londonImages.addAll(imagesByCity['londres'] ?? []);
londonImages.addAll(imagesByCity['london'] ?? []);
if (londonImages.length > 1) {
// Trier par timestamp (garder la plus récente)
londonImages.sort((a, b) {
final timestampA = _extractTimestamp(a.name);
final timestampB = _extractTimestamp(b.name);
return timestampB.compareTo(timestampA); // Plus récent en premier
});
for (var image in londonImages) {
final timestamp = _extractTimestamp(image.name);
}
// Supprimer toutes sauf la première (plus récente)
for (int i = 1; i < londonImages.length; i++) {
await londonImages[i].delete();
}
} else {
}
} catch (e) {
}
}
int _extractTimestamp(String filename) {
try {
// Extraire le timestamp du nom de fichier
// Format: ville_timestamp.jpg
final parts = filename.split('_');
if (parts.length >= 2) {
final timestampPart = parts[1].split('.')[0]; // Enlever l'extension
return int.parse(timestampPart);
}
} catch (e) {
}
return 0; // Timestamp par défaut
}