Files
TravelMate/lib/components/activities/activities_page.dart
Dayron dd8de46e71 Add new activity events and enhance ActivityBloc for better state management
- Introduced LoadTripActivitiesPreservingSearch event to load trip activities while preserving search results.
- Added RemoveFromSearchResults and AddActivityAndRemoveFromSearch events for improved activity handling.
- Updated ActivitiesPage to show loading dialog during activity addition and provide user feedback.
- Increased maxResults for activity search to load more activities.
2025-11-13 10:46:36 +01:00

1518 lines
51 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/activity/activity_bloc.dart';
import '../../blocs/activity/activity_event.dart';
import '../../blocs/activity/activity_state.dart';
import '../../models/trip.dart';
import '../../models/activity.dart';
import '../../services/activity_cache_service.dart';
import '../activities/add_activity_bottom_sheet.dart';
import '../loading/laoding_content.dart';
class ActivitiesPage extends StatefulWidget {
final Trip trip;
const ActivitiesPage({super.key, required this.trip});
@override
State<ActivitiesPage> createState() => _ActivitiesPageState();
}
class _ActivitiesPageState extends State<ActivitiesPage>
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late TabController _tabController;
final TextEditingController _searchController = TextEditingController();
final ActivityCacheService _cacheService = ActivityCacheService();
String _selectedCategory = 'Toutes les catégories';
String _selectedPrice = 'Prix';
String _selectedRating = 'Note';
// Cache pour éviter de recharger les données
bool _activitiesLoaded = false;
bool _googleSearchPerformed = false;
// Variables pour stocker les activités localement
List<Activity> _tripActivities = [];
List<Activity> _approvedActivities = [];
bool _isLoadingTripActivities = false;
int _totalGoogleActivitiesRequested =
0; // Compteur pour les recherches progressives
bool _autoReloadInProgress =
false; // Protection contre les rechargements en boucle
int _lastAutoReloadTriggerCount =
0; // Éviter de redéclencher pour le même nombre
@override
bool get wantKeepAlive => true; // Maintient l'état de la page
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// Charger les activités au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadActivitiesIfNeeded();
});
// Écouter les changements d'onglets
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_handleTabChange();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Recharger si nécessaire quand on revient sur la page
if (!_activitiesLoaded) {
_loadActivitiesIfNeeded();
}
}
void _loadActivitiesIfNeeded() {
final state = context.read<ActivityBloc>().state;
if (state is! ActivityLoaded || !_activitiesLoaded) {
_loadActivities();
}
}
void _handleTabChange() {
// Si on va sur l'onglet suggestions Google et qu'aucune recherche n'a été faite
if (_tabController.index == 2 && !_googleSearchPerformed) {
// Vérifier si on a des activités en cache
final cachedActivities = _cacheService.getCachedActivities(widget.trip.id!);
if (cachedActivities != null && cachedActivities.isNotEmpty) {
// Restaurer les activités en cache dans le BLoC
context.read<ActivityBloc>().add(
RestoreCachedSearchResults(searchResults: cachedActivities),
);
_googleSearchPerformed = true;
} else {
// Sinon, faire une nouvelle recherche
_searchGoogleActivities();
}
}
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
void _loadActivities() {
setState(() {
_isLoadingTripActivities = true;
});
context.read<ActivityBloc>().add(LoadActivities(widget.trip.id!));
_activitiesLoaded = true;
}
@override
Widget build(BuildContext context) {
super.build(context); // Nécessaire pour AutomaticKeepAliveClientMixin
final theme = Theme.of(context);
return BlocListener<ActivityBloc, ActivityState>(
listener: (context, state) {
if (state is ActivityError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () {
if (_tabController.index == 2) {
_searchGoogleActivities();
} else {
_loadActivities();
}
},
),
),
);
}
if (state is ActivityAdded) {
// Fermer le dialog de loading
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
// Ajouter l'activité à la liste locale des activités du voyage
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_tripActivities.add(state.activity);
});
// Afficher un feedback de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${state.activity.name} ajoutée au voyage !'),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green,
action: SnackBarAction(
label: 'Voir',
textColor: Colors.white,
onPressed: () {
// Revenir à l'onglet des activités du voyage
_tabController.animateTo(0);
},
),
),
);
});
}
if (state is ActivityLoaded) {
// Stocker les activités localement
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_tripActivities = state.activities;
_approvedActivities = state.activities
.where((a) => a.totalVotes > 0)
.toList();
_isLoadingTripActivities = false;
});
// Vérifier si on a besoin de charger plus d'activités dans les suggestions
Future.delayed(const Duration(milliseconds: 500), () {
_checkAndLoadMoreActivitiesIfNeeded();
});
});
}
if (state is ActivitySearchResults) {
// Déclencher l'auto-reload uniquement pour la recherche initiale (6 résultats)
// et pas pour les rechargements automatiques
if (state.searchResults.length <= 6 && !_autoReloadInProgress) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 500), () {
_checkAndLoadMoreActivitiesIfNeeded();
});
});
}
}
if (state is ActivityVoteRecorded) {
// Recharger les activités du voyage pour mettre à jour les votes
_loadActivities();
}
},
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
title: Text(
'Voyage à ${widget.trip.location}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
backgroundColor: theme.colorScheme.surface,
elevation: 0,
foregroundColor: theme.colorScheme.onSurface,
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showAddActivityBottomSheet,
),
],
),
body: Column(
children: [
// Barre de recherche
_buildSearchBar(theme),
// Filtres
_buildFilters(theme),
// Onglets de catégories
_buildCategoryTabs(theme),
// Contenu des onglets
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildTripActivitiesTab(),
_buildApprovedActivitiesTab(),
_buildGoogleSuggestionsTab(),
],
),
),
],
),
),
);
}
Widget _buildSearchBar(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher restaurants, musées...',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
prefixIcon: Icon(
Icons.search,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
// TODO: Implémenter la recherche
},
),
),
);
}
Widget _buildFilters(ThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: _buildFilterButton(
theme,
_selectedCategory,
Icons.category,
() => _showCategoryFilter(),
),
),
const SizedBox(width: 12),
_buildFilterButton(
theme,
_selectedPrice,
Icons.euro,
() => _showPriceFilter(),
),
const SizedBox(width: 12),
_buildFilterButton(
theme,
_selectedRating,
Icons.star,
() => _showRatingFilter(),
),
],
),
);
}
Widget _buildFilterButton(
ThemeData theme,
String text,
IconData icon,
VoidCallback onPressed,
) {
return GestureDetector(
onTap: onPressed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.5)),
borderRadius: BorderRadius.circular(20),
color: theme.colorScheme.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 6),
Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildCategoryTabs(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.7),
indicator: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(6),
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Activités du voyage'),
Tab(text: 'Activités approuvées'),
Tab(text: 'Suggestions Google'),
],
),
),
);
}
void _showCategoryFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildCategoryFilterSheet(),
);
}
void _showPriceFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildPriceFilterSheet(),
);
}
void _showRatingFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildRatingFilterSheet(),
);
}
Widget _buildCategoryFilterSheet() {
final theme = Theme.of(context);
final categories = [
'Toutes les catégories',
...ActivityCategory.values.map((e) => e.displayName),
];
return Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height * 0.7, // Limite à 70% de l'écran
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Catégories',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
children: categories
.map(
(category) => ListTile(
title: Text(category),
onTap: () {
setState(() {
_selectedCategory = category;
});
Navigator.pop(context);
_applyFilters();
},
trailing: _selectedCategory == category
? Icon(
Icons.check,
color: theme.colorScheme.primary,
)
: null,
),
)
.toList(),
),
),
),
],
),
);
}
Widget _buildPriceFilterSheet() {
final theme = Theme.of(context);
final prices = [
'Prix',
'Gratuit',
'Bon marché',
'Modéré',
'Cher',
'Très cher',
];
return Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Niveau de prix',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
children: prices
.map(
(price) => ListTile(
title: Text(price),
onTap: () {
setState(() {
_selectedPrice = price;
});
Navigator.pop(context);
_applyFilters();
},
trailing: _selectedPrice == price
? Icon(
Icons.check,
color: theme.colorScheme.primary,
)
: null,
),
)
.toList(),
),
),
),
],
),
);
}
Widget _buildRatingFilterSheet() {
final theme = Theme.of(context);
final ratings = [
'Note',
'4+ étoiles',
'3+ étoiles',
'2+ étoiles',
'1+ étoiles',
];
return Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Note minimale',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
children: ratings
.map(
(rating) => ListTile(
title: Text(rating),
onTap: () {
setState(() {
_selectedRating = rating;
});
Navigator.pop(context);
_applyFilters();
},
trailing: _selectedRating == rating
? Icon(
Icons.check,
color: theme.colorScheme.primary,
)
: null,
),
)
.toList(),
),
),
),
],
),
);
}
void _applyFilters() {
String? category = _selectedCategory == 'Toutes les catégories'
? null
: _selectedCategory;
double? minRating = _getMinRatingFromString(_selectedRating);
context.read<ActivityBloc>().add(
FilterActivities(category: category, minRating: minRating),
);
}
double? _getMinRatingFromString(String rating) {
switch (rating) {
case '4+ étoiles':
return 4.0;
case '3+ étoiles':
return 3.0;
case '2+ étoiles':
return 2.0;
case '1+ étoiles':
return 1.0;
default:
return null;
}
}
void _showAddActivityBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: AddActivityBottomSheet(trip: widget.trip),
),
);
}
Widget _buildTripActivitiesTab() {
// Utiliser les données locales au lieu du BLoC
if (_isLoadingTripActivities) {
return const Center(child: CircularProgressIndicator());
}
if (_tripActivities.isEmpty) {
return _buildEmptyState(
'Aucune activité du voyage',
'Ajoutez vos premières activités pour ce voyage',
Icons.add_location,
);
}
return _buildActivityList(_tripActivities);
}
Widget _buildApprovedActivitiesTab() {
// Utiliser les données locales au lieu du BLoC
if (_isLoadingTripActivities) {
return const Center(child: CircularProgressIndicator());
}
if (_approvedActivities.isEmpty) {
return _buildEmptyState(
'Aucune activité approuvée',
'Les activités avec des votes positifs apparaîtront ici',
Icons.thumb_up_outlined,
);
}
return _buildActivityList(_approvedActivities);
}
Widget _buildGoogleSuggestionsTab() {
return BlocBuilder<ActivityBloc, ActivityState>(
builder: (context, state) {
if (state is ActivitySearching) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Recherche d\'activités en cours...',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
if (state is ActivitySearchResults) {
final googleActivities = state.searchResults;
// Filtrer les activités déjà présentes dans le voyage
final filteredActivities = googleActivities.where((googleActivity) {
return !_tripActivities.any(
(tripActivity) =>
tripActivity.name.toLowerCase().trim() ==
googleActivity.name.toLowerCase().trim(),
);
}).toList();
if (filteredActivities.isEmpty && googleActivities.isNotEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildEmptyState(
'Toutes les activités sont déjà dans votre voyage',
'Recherchez plus d\'activités pour découvrir de nouvelles suggestions',
Icons.check_circle,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => _loadMoreGoogleActivities(),
icon: const Icon(Icons.add_circle_outline),
label: const Text('Rechercher plus d\'activités'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
);
}
if (filteredActivities.isEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildEmptyState(
'Aucun résultat trouvé',
'Aucune activité trouvée pour cette destination',
Icons.search_off,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => _searchGoogleActivities(),
icon: const Icon(Icons.refresh),
label: const Text('Rechercher à nouveau'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
);
}
return Column(
children: [
// Liste des activités
Expanded(
child: RefreshIndicator(
onRefresh: () async {
_resetAndSearchGoogleActivities();
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredActivities.length,
itemBuilder: (context, index) {
final activity = filteredActivities[index];
return _buildActivityCard(
activity,
isGoogleSuggestion: true,
);
},
),
),
),
// Bouton "Rechercher plus d'activités"
if (state.isLoading)
Container(
padding: const EdgeInsets.all(16),
child: const Center(child: CircularProgressIndicator()),
)
else
Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
child: ElevatedButton.icon(
onPressed: () => _loadMoreGoogleActivities(),
icon: const Icon(Icons.add_circle_outline),
label: const Text('Rechercher plus d\'activités'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
],
);
}
// État initial - aucune recherche effectuée
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildEmptyState(
'Découvrir des activités',
'Trouvez des restaurants, musées et attractions près de ${widget.trip.location}',
Icons.explore,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => _searchGoogleActivities(),
icon: const Icon(Icons.search),
label: const Text('Rechercher des activités (6 résultats)'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
),
const SizedBox(height: 16),
Text(
'Recherche powered by Google Places',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
],
);
},
);
}
Widget _buildEmptyState(String title, String subtitle, IconData icon) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: theme.colorScheme.outline),
const SizedBox(height: 16),
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildActivityList(List<Activity> activities) {
return RefreshIndicator(
onRefresh: () async {
// Rafraîchir selon l'onglet actuel
if (_tabController.index == 2) {
// Onglet Google - relancer la recherche
_searchGoogleActivities();
} else {
// Onglets activités du voyage - recharger depuis la base de données
_loadActivities();
}
},
child: activities.isEmpty
? ListView(
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: _buildEmptyState(
'Aucune activité',
'Tirez vers le bas pour actualiser',
Icons.refresh,
),
),
],
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
return _buildActivityCard(activity);
},
),
);
}
Widget _buildActivityCard(
Activity activity, {
bool isGoogleSuggestion = false,
}) {
final theme = Theme.of(context);
// Vérifier si l'activité existe déjà dans le voyage (pour les suggestions Google)
final bool activityAlreadyExists =
isGoogleSuggestion &&
_tripActivities.any(
(tripActivity) =>
tripActivity.name.toLowerCase().trim() ==
activity.name.toLowerCase().trim(),
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image de l'activité
if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: SizedBox(
height: 200,
width: double.infinity,
child: Image.network(
activity.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 200,
color: theme.colorScheme.surfaceContainerHighest,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
'Image non disponible',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
height: 200,
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
),
),
),
// Contenu de la carte
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Icône de catégorie
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getCategoryIcon(activity.category),
color: theme.colorScheme.primary,
size: 20,
),
),
const SizedBox(width: 12),
// Nom et catégorie
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
activity.category,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
// Note
if (activity.rating != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
activity.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
if (activity.description.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
activity.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (activity.address != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.location_on,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Expanded(
child: Text(
activity.address!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
),
],
),
],
const SizedBox(height: 12),
// Boutons d'action et votes (différents selon le contexte)
if (isGoogleSuggestion) ...[
// Pour les suggestions Google : bouton d'ajout ou indication si déjà ajoutée
Row(
children: [
if (activityAlreadyExists) ...[
// Activité déjà dans le voyage
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
size: 16,
color: Colors.orange.shade700,
),
const SizedBox(width: 6),
Text(
'Déjà dans le voyage',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.orange.shade700,
fontWeight: FontWeight.w600,
),
),
],
),
),
] else ...[
// Bouton pour ajouter l'activité
Expanded(
child: ElevatedButton.icon(
onPressed: () => _addGoogleActivityToTrip(activity),
icon: const Icon(Icons.add, size: 18),
label: const Text('Ajouter au voyage'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
],
),
] else ...[
// Pour les activités du voyage : système de votes
Row(
children: [
// Votes positifs (pouces verts)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${activity.positiveVotes}',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.thumb_up,
size: 16,
color: Colors.green.shade700,
),
],
),
),
const SizedBox(width: 8),
// Votes négatifs (pouces rouges)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${activity.negativeVotes}',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.red.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.thumb_down,
size: 16,
color: Colors.red.shade700,
),
],
),
),
const Spacer(),
// Bouton J'aime/J'aime pas
IconButton(
onPressed: () => _voteForActivity(activity.id, 1),
icon: const Icon(Icons.thumb_up),
iconSize: 20,
),
IconButton(
onPressed: () => _voteForActivity(activity.id, -1),
icon: const Icon(Icons.thumb_down),
iconSize: 20,
),
],
),
],
],
),
),
],
),
);
}
IconData _getCategoryIcon(String category) {
switch (category.toLowerCase()) {
case 'museum':
case 'musée':
return Icons.museum;
case 'restaurant':
return Icons.restaurant;
case 'attraction':
return Icons.place;
case 'divertissement':
case 'entertainment':
return Icons.sports_esports;
case 'shopping':
return Icons.shopping_bag;
case 'nature':
return Icons.park;
case 'culture':
return Icons.account_balance;
case 'vie nocturne':
case 'nightlife':
return Icons.nightlife;
case 'sports':
return Icons.sports;
case 'détente':
case 'relaxation':
return Icons.spa;
default:
return Icons.place;
}
}
void _voteForActivity(String activityId, int vote) {
// TODO: Récupérer l'ID utilisateur actuel
// Pour l'instant, on utilise un ID temporaire
final userId = 'current_user_id';
context.read<ActivityBloc>().add(
VoteForActivity(activityId: activityId, userId: userId, vote: vote),
);
// Afficher un feedback à l'utilisateur
final message = vote == 1
? 'Vote positif ajouté !'
: 'Vote négatif ajouté !';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 1),
backgroundColor: vote == 1 ? Colors.green : Colors.orange,
),
);
}
void _addGoogleActivityToTrip(Activity activity) {
// Créer une nouvelle activité avec l'ID du voyage
final newActivity = activity.copyWith(
tripId: widget.trip.id,
// Générer un nouvel ID unique pour cette activité dans le voyage
id: DateTime.now().millisecondsSinceEpoch.toString(),
);
// Afficher le LoadingContent avec la tâche d'ajout
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return LoadingContent(
loadingText: 'Ajout de ${activity.name}...',
onBackgroundTask: () async {
// Ajouter l'activité au voyage
context.read<ActivityBloc>().add(
AddActivityAndRemoveFromSearch(
activity: newActivity,
googleActivityId: activity.id,
),
);
// Attendre que l'ajout soit complété
await Future.delayed(const Duration(milliseconds: 1000));
},
onComplete: () {
// Fermer le dialog quand l'ajout est complété
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
);
},
);
}
void _checkAndLoadMoreActivitiesIfNeeded() {
// Protection contre les rechargements en boucle
if (_autoReloadInProgress) {
return;
}
final currentState = context.read<ActivityBloc>().state;
if (currentState is ActivitySearchResults) {
final googleActivities = currentState.searchResults;
// Filtrer les activités déjà présentes dans le voyage
final filteredActivities = googleActivities.where((googleActivity) {
final isDuplicate = _tripActivities.any(
(tripActivity) =>
tripActivity.name.toLowerCase().trim() ==
googleActivity.name.toLowerCase().trim(),
);
if (isDuplicate) {}
return !isDuplicate;
}).toList();
// Protection: ne pas redéclencher pour le même nombre d'activités Google
if (googleActivities.length == _lastAutoReloadTriggerCount) {
return;
}
// Si on a moins de 4 activités visibles ET qu'on n'a pas déjà beaucoup d'activités Google
if (filteredActivities.length < 4 && googleActivities.length < 20) {
_autoReloadInProgress = true;
_lastAutoReloadTriggerCount = googleActivities.length;
// Calculer combien d'activités on doit demander
final activitiesNeeded =
6 - filteredActivities.length; // Manque pour arriver à 6
final newTotalToRequest =
googleActivities.length +
activitiesNeeded +
6; // Activités actuelles + ce qui manque + buffer de 6
// Mettre à jour le compteur et recharger avec le nouveau total
_totalGoogleActivitiesRequested = newTotalToRequest;
_loadMoreGoogleActivitiesWithTotal(newTotalToRequest);
// Libérer le verrou après un délai
Future.delayed(const Duration(seconds: 3), () {
_autoReloadInProgress = false;
});
} else if (filteredActivities.length >= 4) {
} else {}
} else {}
}
void _searchGoogleActivities() {
_totalGoogleActivitiesRequested = 6; // Reset du compteur
_autoReloadInProgress = false; // Reset des protections
_lastAutoReloadTriggerCount = 0;
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
context.read<ActivityBloc>().add(
SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null, // Rechercher dans toutes les catégories
maxResults: 6, // Charger 6 résultats à la fois
reset: true, // Nouveau flag pour reset
),
);
} else {
context.read<ActivityBloc>().add(
SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null, // Rechercher dans toutes les catégories
maxResults: 6, // Charger 6 résultats à la fois
reset: true, // Nouveau flag pour reset
),
);
}
_googleSearchPerformed = true;
}
void _resetAndSearchGoogleActivities() {
_totalGoogleActivitiesRequested = 6; // Reset du compteur
_autoReloadInProgress = false; // Reset des protections
_lastAutoReloadTriggerCount = 0;
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
context.read<ActivityBloc>().add(
SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: 6,
reset: true,
),
);
} else {
context.read<ActivityBloc>().add(
SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: 6,
reset: true,
),
);
}
_googleSearchPerformed = true;
}
void _loadMoreGoogleActivities() {
final currentState = context.read<ActivityBloc>().state;
if (currentState is ActivitySearchResults) {
final currentCount = currentState.searchResults.length;
final newTotal = currentCount + 6;
_totalGoogleActivitiesRequested = newTotal;
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
context.read<ActivityBloc>().add(
SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: newTotal, // Demander le total cumulé
reset: true, // Reset pour avoir tous les résultats d'un coup
),
);
} else {
context.read<ActivityBloc>().add(
SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: newTotal, // Demander le total cumulé
reset: true, // Reset pour avoir tous les résultats d'un coup
),
);
}
}
}
void _loadMoreGoogleActivitiesWithTotal(int totalToRequest) {
// Au lieu de reset, on utilise l'offset et append pour forcer plus de résultats
final currentState = context.read<ActivityBloc>().state;
if (currentState is ActivitySearchResults) {
final currentCount = currentState.searchResults.length;
final additionalNeeded = totalToRequest - currentCount;
if (additionalNeeded > 0) {
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
context.read<ActivityBloc>().add(
SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: additionalNeeded,
offset: currentCount,
appendToExisting: true, // Ajouter aux résultats existants
),
);
} else {
context.read<ActivityBloc>().add(
SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: additionalNeeded,
offset: currentCount,
appendToExisting: true, // Ajouter aux résultats existants
),
);
}
} else {}
} else {
// Si pas de résultats existants, faire une recherche complète
if (widget.trip.hasCoordinates) {
context.read<ActivityBloc>().add(
SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: totalToRequest,
reset: true,
),
);
} else {
context.read<ActivityBloc>().add(
SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: totalToRequest,
reset: true,
),
);
}
}
}
}