feat: Add calendar page, enhance activity search and approval logic, and refactor activity filtering UI.

This commit is contained in:
Van Leemput Dayron
2025-11-26 12:15:13 +01:00
parent 258f10b42b
commit f7eeb7c6f1
11 changed files with 952 additions and 700 deletions

View File

@@ -6,7 +6,7 @@ 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 {
@@ -23,9 +23,6 @@ class _ActivitiesPageState extends State<ActivitiesPage>
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;
@@ -83,7 +80,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
// 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!);
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(
@@ -144,13 +143,13 @@ class _ActivitiesPageState extends State<ActivitiesPage>
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(
@@ -174,10 +173,26 @@ class _ActivitiesPageState extends State<ActivitiesPage>
// Stocker les activités localement
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_tripActivities = state.activities;
_approvedActivities = state.activities
.where((a) => a.totalVotes > 0)
final allActivities = state.filteredActivities;
_approvedActivities = allActivities
.where(
(a) => a.isApprovedByAllParticipants([
...widget.trip.participants,
widget.trip.createdBy,
]),
)
.toList();
_tripActivities = allActivities
.where(
(a) => !a.isApprovedByAllParticipants([
...widget.trip.participants,
widget.trip.createdBy,
]),
)
.toList();
_isLoadingTripActivities = false;
});
@@ -189,6 +204,37 @@ class _ActivitiesPageState extends State<ActivitiesPage>
}
if (state is ActivitySearchResults) {
// Gérer l'ajout d'activité depuis les résultats de recherche
if (state.newlyAddedActivity != null) {
// Fermer le dialog de loading s'il est ouvert
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_tripActivities.add(state.newlyAddedActivity!);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${state.newlyAddedActivity!.name} ajoutée au voyage !',
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green,
action: SnackBarAction(
label: 'Voir',
textColor: Colors.white,
onPressed: () {
_tabController.animateTo(0);
},
),
),
);
});
}
// 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) {
@@ -220,21 +266,13 @@ class _ActivitiesPageState extends State<ActivitiesPage>
backgroundColor: theme.colorScheme.surface,
elevation: 0,
foregroundColor: theme.colorScheme.onSurface,
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _showAddActivityBottomSheet,
),
],
actions: [],
),
body: Column(
children: [
// Barre de recherche
_buildSearchBar(theme),
// Filtres
_buildFilters(theme),
// Onglets de catégories
_buildCategoryTabs(theme),
@@ -260,7 +298,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
@@ -268,11 +308,11 @@ class _ActivitiesPageState extends State<ActivitiesPage>
decoration: InputDecoration(
hintText: 'Rechercher restaurants, musées...',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
prefixIcon: Icon(
Icons.search,
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
@@ -280,95 +320,32 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 12,
),
),
onChanged: (value) {
// TODO: Implémenter la recherche
onSubmitted: (value) {
if (value.isNotEmpty) {
_performSearch(value);
}
},
),
),
);
}
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.withValues(alpha:0.5)),
borderRadius: BorderRadius.circular(20),
color: theme.colorScheme.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurface.withValues(alpha:0.7),
),
const SizedBox(width: 6),
Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.7),
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildCategoryTabs(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha:0.3),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(8),
),
child: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: theme.colorScheme.onSurface.withValues(alpha:0.7),
unselectedLabelColor: theme.colorScheme.onSurface.withValues(
alpha: 0.7,
),
indicator: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(6),
@@ -376,246 +353,15 @@ class _ActivitiesPageState extends State<ActivitiesPage>
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Activités du voyage'),
Tab(text: 'Activités approuvées'),
Tab(text: 'Suggestions Google'),
Tab(text: 'Voyage'),
Tab(text: 'Approuvées'),
Tab(text: 'Suggestion'),
],
),
),
);
}
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) {
@@ -814,7 +560,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Text(
'Recherche powered by Google Places',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.6),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
@@ -845,7 +593,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.7),
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
@@ -977,7 +725,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha:0.1),
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
@@ -1015,7 +763,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha:0.1),
color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -1043,7 +791,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Text(
activity.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.8),
color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -1056,14 +804,18 @@ class _ActivitiesPageState extends State<ActivitiesPage>
Icon(
Icons.location_on,
size: 16,
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
activity.address!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
),
),
),
@@ -1084,10 +836,10 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha:0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withValues(alpha:0.3),
color: Colors.orange.withValues(alpha: 0.3),
),
),
child: Row(
@@ -1140,7 +892,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha:0.1),
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -1170,7 +922,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha:0.1),
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -1248,8 +1000,38 @@ class _ActivitiesPageState extends State<ActivitiesPage>
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';
// Pour l'instant, on utilise l'ID du créateur du voyage pour que le vote compte
final userId = widget.trip.createdBy;
// Vérifier si l'activité existe dans la liste locale pour vérifier le vote
// (car l'objet activity passé peut venir d'une liste filtrée ou autre)
final currentActivity = _tripActivities.firstWhere(
(a) => a.id == activityId,
orElse: () => _approvedActivities.firstWhere(
(a) => a.id == activityId,
orElse: () => Activity(
id: '',
tripId: '',
name: '',
description: '',
category: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
),
);
// Si l'activité a été trouvée et que l'utilisateur a déjà voté
if (currentActivity.id.isNotEmpty && currentActivity.hasUserVoted(userId)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous avez déjà voté pour cette activité'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
);
return;
}
context.read<ActivityBloc>().add(
VoteForActivity(activityId: activityId, userId: userId, vote: vote),
@@ -1285,12 +1067,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
loadingText: 'Ajout de ${activity.name}...',
onBackgroundTask: () async {
// Ajouter l'activité au voyage
context.read<ActivityBloc>().add(
AddActivityAndRemoveFromSearch(
activity: newActivity,
googleActivityId: activity.id,
),
);
context.read<ActivityBloc>().add(AddActivity(newActivity));
// Attendre que l'ajout soit complété
await Future.delayed(const Duration(milliseconds: 1000));
},
@@ -1514,4 +1291,19 @@ class _ActivitiesPageState extends State<ActivitiesPage>
}
}
}
void _performSearch(String query) {
// Basculer vers l'onglet suggestions
_tabController.animateTo(2);
// Déclencher la recherche textuelle
context.read<ActivityBloc>().add(
SearchActivitiesByText(
tripId: widget.trip.id!,
destination: widget.trip.location,
query: query,
),
);
_googleSearchPerformed = true;
}
}