Files
TravelMate/lib/components/activities/activities_page.dart

1438 lines
52 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../activities/add_activity_bottom_sheet.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();
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) {
_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 ActivityLoaded) {
print('✅ Activités chargées: ${state.activities.length}');
// Stocker les activités localement
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_tripActivities = state.activities;
_approvedActivities = state.activities.where((a) => a.totalVotes > 0).toList();
_isLoadingTripActivities = false;
});
print('🔄 [ActivityLoaded] Activités du voyage mises à jour: ${_tripActivities.length}');
// Vérifier si on a besoin de charger plus d'activités dans les suggestions
Future.delayed(const Duration(milliseconds: 500), () {
print('🚀 [ActivityLoaded] Déclenchement de la vérification auto-reload');
_checkAndLoadMoreActivitiesIfNeeded();
});
});
}
if (state is ActivitySearchResults) {
print('🔍 Résultats Google: ${state.searchResults.length}');
// 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((_) {
print('🎯 [ActivitySearchResults] Première recherche avec peu de résultats, vérification auto-reload');
Future.delayed(const Duration(milliseconds: 500), () {
_checkAndLoadMoreActivitiesIfNeeded();
});
});
}
}
if (state is ActivityVoteRecorded) {
print('<EFBFBD> Vote enregistré pour activité: ${state.activityId}');
// Recharger les activités du voyage pour mettre à jour les votes
_loadActivities();
}
if (state is ActivityAdded) {
print('✅ Activité ajoutée avec succès: ${state.activity.name}');
// Recharger automatiquement les activités du voyage
_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();
print('🔍 [Google Search] ${googleActivities.length} résultats trouvés, ${filteredActivities.length} après filtrage');
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),
),
),
),
),
// Bouton de debug temporaire
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () {
print('🧪 [DEBUG] Force auto-reload check - État actuel:');
print('🧪 [DEBUG] _tripActivities: ${_tripActivities.length}');
print('🧪 [DEBUG] _autoReloadInProgress: $_autoReloadInProgress');
print('🧪 [DEBUG] _lastAutoReloadTriggerCount: $_lastAutoReloadTriggerCount');
_checkAndLoadMoreActivitiesIfNeeded();
},
icon: const Icon(Icons.bug_report, size: 16),
label: const Text('🧪 Test Auto-Reload'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
],
),
],
);
}
// É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: Container(
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) {
print('🗳️ Vote pour activité $activityId: $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) {
print(' [Add Activity] Adding ${activity.name} to trip');
// 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(),
);
context.read<ActivityBloc>().add(AddActivity(newActivity));
// Afficher un feedback à l'utilisateur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${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);
},
),
),
);
}
void _checkAndLoadMoreActivitiesIfNeeded() {
// Protection contre les rechargements en boucle
if (_autoReloadInProgress) {
print('⏸️ [Auto-reload] Auto-reload déjà en cours, skip');
return;
}
final currentState = context.read<ActivityBloc>().state;
if (currentState is ActivitySearchResults) {
final googleActivities = currentState.searchResults;
print('🔍 [Auto-reload] Activités du voyage en mémoire: ${_tripActivities.length}');
print('🔍 [Auto-reload] Activités Google total: ${googleActivities.length}');
// 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) {
print('🔍 [Auto-reload] Activité filtrée: ${googleActivity.name}');
}
return !isDuplicate;
}).toList();
print('🔍 [Auto-reload] ${filteredActivities.length} activités visibles après filtrage sur ${googleActivities.length} total');
// Protection: ne pas redéclencher pour le même nombre d'activités Google
if (googleActivities.length == _lastAutoReloadTriggerCount) {
print('🔒 [Auto-reload] Même nombre qu\'avant (${googleActivities.length}), skip pour éviter la boucle');
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
print('🔄 [Auto-reload] DÉCLENCHEMENT: Besoin de ${activitiesNeeded} activités supplémentaires');
print('📊 [Auto-reload] Demande totale: ${newTotalToRequest} activités (actuellement: ${googleActivities.length})');
// 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;
print('🔓 [Auto-reload] Verrou libéré');
});
} else if (filteredActivities.length >= 4) {
print('✅ [Auto-reload] Suffisamment d\'activités visibles (${filteredActivities.length} >= 4)');
} else {
print('🚫 [Auto-reload] Trop d\'activités Google déjà chargées (${googleActivities.length} >= 20), arrêt auto-reload');
}
} else {
print('⚠️ [Auto-reload] État pas prêt pour auto-chargement: ${currentState.runtimeType}');
}
}
void _searchGoogleActivities() {
print('🔍 [Google Search] Initializing first search with 6 results');
_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) {
print('🌍 [Google Search] Using pre-geocoded coordinates: ${widget.trip.latitude}, ${widget.trip.longitude}');
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 {
print('⚠️ [Google Search] No coordinates available, falling back to destination geocoding');
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() {
print('🔄 [Google Search] Resetting and starting fresh search');
_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) {
print('🌍 [Google Search] Using pre-geocoded coordinates: ${widget.trip.latitude}, ${widget.trip.longitude}');
context.read<ActivityBloc>().add(SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: 6,
reset: true,
));
} else {
print('⚠️ [Google Search] No coordinates available, falling back to destination geocoding');
context.read<ActivityBloc>().add(SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: 6,
reset: true,
));
}
_googleSearchPerformed = true;
}
void _loadMoreGoogleActivities() {
print('📄 [Google Search] Loading more activities (next 6 results)');
final currentState = context.read<ActivityBloc>().state;
if (currentState is ActivitySearchResults) {
final currentCount = currentState.searchResults.length;
final newTotal = currentCount + 6;
print('📊 [Google Search] Current results count: $currentCount, requesting total: $newTotal');
_totalGoogleActivitiesRequested = newTotal;
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
print('🌍 [Google Search] Using pre-geocoded coordinates for more results');
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 {
print('⚠️ [Google Search] No coordinates available, falling back to destination geocoding');
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) {
print('📈 [Google Search] Loading activities with specific total: $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;
print('📊 [Google Search] Current: $currentCount, Total demandé: $totalToRequest, Additional: $additionalNeeded');
if (additionalNeeded > 0) {
// Utiliser les coordonnées pré-géolocalisées du voyage si disponibles
if (widget.trip.hasCoordinates) {
print('🌍 [Google Search] Using pre-geocoded coordinates for additional results');
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 {
print('⚠️ [Google Search] No coordinates available, falling back to destination geocoding');
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 {
print('⚠️ [Google Search] Pas besoin de charger plus (déjà suffisant)');
}
} else {
// Si pas de résultats existants, faire une recherche complète
if (widget.trip.hasCoordinates) {
print('🌍 [Google Search] Using pre-geocoded coordinates for fresh search');
context.read<ActivityBloc>().add(SearchActivitiesWithCoordinates(
tripId: widget.trip.id!,
latitude: widget.trip.latitude!,
longitude: widget.trip.longitude!,
category: null,
maxResults: totalToRequest,
reset: true,
));
} else {
print('⚠️ [Google Search] No coordinates available, falling back to destination geocoding');
context.read<ActivityBloc>().add(SearchActivities(
tripId: widget.trip.id!,
destination: widget.trip.location,
category: null,
maxResults: totalToRequest,
reset: true,
));
}
}
}
}