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

@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart';
import '../../../models/trip.dart';
import '../../../models/activity.dart';
import '../../../blocs/activity/activity_bloc.dart';
import '../../../blocs/activity/activity_state.dart';
import '../../../blocs/activity/activity_event.dart';
class CalendarPage extends StatefulWidget {
final Trip trip;
const CalendarPage({super.key, required this.trip});
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
late DateTime _focusedDay;
DateTime? _selectedDay;
CalendarFormat _calendarFormat = CalendarFormat.month;
@override
void initState() {
super.initState();
_focusedDay = widget.trip.startDate;
_selectedDay = _focusedDay;
}
List<Activity> _getActivitiesForDay(DateTime day, List<Activity> activities) {
return activities.where((activity) {
if (activity.date == null) return false;
return isSameDay(activity.date, day);
}).toList();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Calendrier du voyage'),
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
elevation: 0,
),
body: BlocBuilder<ActivityBloc, ActivityState>(
builder: (context, state) {
if (state is ActivityLoading) {
return const Center(child: CircularProgressIndicator());
}
List<Activity> allActivities = [];
if (state is ActivityLoaded) {
allActivities = state.activities;
} else if (state is ActivitySearchResults) {
// Fallback if we are in search state, though ideally we should be in loaded state
// This might happen if we navigate back and forth
}
// Filter approved activities
final approvedActivities = allActivities.where((a) {
return a.isApprovedByAllParticipants([
...widget.trip.participants,
widget.trip.createdBy,
]);
}).toList();
final scheduledActivities = approvedActivities
.where((a) => a.date != null)
.toList();
final unscheduledActivities = approvedActivities
.where((a) => a.date == null)
.toList();
final selectedActivities = _getActivitiesForDay(
_selectedDay ?? _focusedDay,
scheduledActivities,
);
return Column(
children: [
TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) {
return isSameDay(_selectedDay, day);
},
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
eventLoader: (day) {
return _getActivitiesForDay(day, scheduledActivities);
},
calendarBuilders: CalendarBuilders(
markerBuilder: (context, day, events) {
if (events.isEmpty) return null;
return Positioned(
bottom: 1,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
),
),
);
},
),
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
selectedDecoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
),
const Divider(),
Expanded(
child: Row(
children: [
// Scheduled Activities for Selected Day
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Activités du ${DateFormat('dd/MM/yyyy').format(_selectedDay!)}',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: selectedActivities.isEmpty
? Center(
child: Text(
'Aucune activité prévue',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),
),
),
)
: ListView.builder(
itemCount: selectedActivities.length,
itemBuilder: (context, index) {
final activity =
selectedActivities[index];
return ListTile(
title: Text(activity.name),
subtitle: Text(activity.category),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
context.read<ActivityBloc>().add(
UpdateActivityDate(
tripId: widget.trip.id!,
activityId: activity.id,
date: null,
),
);
},
),
);
},
),
),
],
),
),
const VerticalDivider(),
// Unscheduled Activities
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'À planifier',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: unscheduledActivities.isEmpty
? Center(
child: Text(
'Tout est planifié !',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: unscheduledActivities.length,
itemBuilder: (context, index) {
final activity =
unscheduledActivities[index];
return Draggable<Activity>(
data: activity,
feedback: Material(
elevation: 4,
child: Container(
padding: const EdgeInsets.all(8),
color: theme.cardColor,
child: Text(activity.name),
),
),
child: ListTile(
title: Text(
activity.name,
style: theme.textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (_selectedDay != null) {
context
.read<ActivityBloc>()
.add(
UpdateActivityDate(
tripId: widget.trip.id!,
activityId: activity.id,
date: _selectedDay,
),
);
}
},
),
),
);
},
),
),
],
),
),
],
),
),
],
);
},
),
);
}
}

View File

@@ -134,17 +134,7 @@ class _HomeContentState extends State<HomeContent>
: Colors.black,
),
),
const SizedBox(height: 8),
Text(
'Vos voyages',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[600],
),
),
const SizedBox(height: 20),
const SizedBox(height: 16),
if (tripState is TripLoading || tripState is TripCreated)
_buildLoadingState()

View File

@@ -15,6 +15,7 @@ import 'package:travel_mate/repositories/user_repository.dart';
import 'package:travel_mate/repositories/account_repository.dart';
import 'package:travel_mate/models/group_member.dart';
import 'package:travel_mate/components/activities/activities_page.dart';
import 'package:travel_mate/components/home/calendar/calendar_page.dart';
import 'package:url_launcher/url_launcher.dart';
class ShowTripDetailsContent extends StatefulWidget {
@@ -94,7 +95,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour afficher le dialogue de sélection de carte
void _showMapOptions() {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (BuildContext context) {
@@ -193,7 +194,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour ouvrir Google Maps
Future<void> _openGoogleMaps() async {
final location = Uri.encodeComponent(widget.trip.location);
try {
// Essayer d'abord l'URL scheme pour l'app mobile
final appUrl = 'comgooglemaps://?q=$location';
@@ -202,17 +203,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(appUri);
return;
}
// Fallback vers l'URL web
final webUrl = 'https://www.google.com/maps/search/?api=1&query=$location';
final webUrl =
'https://www.google.com/maps/search/?api=1&query=$location';
final webUri = Uri.parse(webUrl);
if (await canLaunchUrl(webUri)) {
await launchUrl(webUri, mode: LaunchMode.externalApplication);
return;
}
_errorService.showError(
message: 'Impossible d\'ouvrir Google Maps. Vérifiez que l\'application est installée.',
message:
'Impossible d\'ouvrir Google Maps. Vérifiez que l\'application est installée.',
);
} catch (e) {
_errorService.showError(
@@ -224,7 +227,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour ouvrir Waze
Future<void> _openWaze() async {
final location = Uri.encodeComponent(widget.trip.location);
try {
// Essayer d'abord l'URL scheme pour l'app mobile
final appUrl = 'waze://?q=$location';
@@ -233,7 +236,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(appUri);
return;
}
// Fallback vers l'URL web
final webUrl = 'https://waze.com/ul?q=$location';
final webUri = Uri.parse(webUrl);
@@ -241,14 +244,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await launchUrl(webUri, mode: LaunchMode.externalApplication);
return;
}
_errorService.showError(
message: 'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.',
message:
'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.',
);
} catch (e) {
_errorService.showError(
message: 'Erreur lors de l\'ouverture de Waze',
);
_errorService.showError(message: 'Erreur lors de l\'ouverture de Waze');
}
}
@@ -256,9 +258,11 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? theme.scaffoldBackgroundColor : Colors.grey[50],
backgroundColor: isDarkMode
? theme.scaffoldBackgroundColor
: Colors.grey[50],
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
@@ -292,7 +296,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha:0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
@@ -300,16 +304,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.trip.imageUrl != null && widget.trip.imageUrl!.isNotEmpty
child:
widget.trip.imageUrl != null &&
widget.trip.imageUrl!.isNotEmpty
? Image.network(
widget.trip.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildPlaceholderImage(),
errorBuilder: (context, error, stackTrace) =>
_buildPlaceholderImage(),
)
: _buildPlaceholderImage(),
),
),
// Contenu principal
Padding(
padding: const EdgeInsets.all(16),
@@ -318,21 +325,24 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
children: [
// Section "Départ dans X jours"
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDarkMode
? Colors.white.withValues(alpha:0.1)
: Colors.black.withValues(alpha:0.1),
color: isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withValues(alpha:0.3)
: Colors.black.withValues(alpha:0.1),
color: isDarkMode
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: isDarkMode ? 8 : 5,
offset: const Offset(0, 2),
),
@@ -343,7 +353,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.teal.withValues(alpha:0.1),
color: Colors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
@@ -359,11 +369,15 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Text(
'Départ dans',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
),
),
Text(
daysUntilTrip > 0 ? '$daysUntilTrip Jours' : 'Voyage terminé',
daysUntilTrip > 0
? '$daysUntilTrip Jours'
: 'Voyage terminé',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
@@ -372,7 +386,9 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Text(
widget.trip.formattedDates,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha:0.6),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
),
),
],
@@ -380,9 +396,9 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
],
),
),
const SizedBox(height: 24),
// Section Participants
Text(
'Participants',
@@ -392,12 +408,12 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
),
),
const SizedBox(height: 12),
// Afficher les participants avec leurs images
_buildParticipantsSection(),
const SizedBox(height: 32),
// Grille d'actions
GridView.count(
shrinkWrap: true,
@@ -411,7 +427,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
icon: Icons.calendar_today,
title: 'Calendrier',
color: Colors.blue,
onTap: () => _showComingSoon('Calendrier'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
CalendarPage(trip: widget.trip),
),
),
),
_buildActionButton(
icon: Icons.local_activity,
@@ -449,18 +471,11 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.location_city,
size: 48,
color: Colors.grey,
),
Icon(Icons.location_city, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Aucune image',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
style: TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
@@ -476,7 +491,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
}) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
@@ -486,16 +501,16 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
color: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDarkMode
? Colors.white.withValues(alpha:0.1)
: Colors.black.withValues(alpha:0.1),
color: isDarkMode
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withValues(alpha:0.3)
: Colors.black.withValues(alpha:0.1),
color: isDarkMode
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: isDarkMode ? 8 : 5,
offset: const Offset(0, 2),
),
@@ -510,11 +525,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 24,
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
@@ -542,7 +553,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showOptionsMenu() {
final theme = Theme.of(context);
showModalBottomSheet(
context: context,
backgroundColor: theme.bottomSheetTheme.backgroundColor,
@@ -594,7 +605,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showDeleteConfirmation() {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -627,10 +638,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
Navigator.pop(context);
Navigator.pop(context, true);
},
child: const Text(
'Supprimer',
style: TextStyle(color: Colors.red),
),
child: const Text('Supprimer', style: TextStyle(color: Colors.red)),
),
],
),
@@ -678,16 +686,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
...List.generate(
members.length,
(index) {
final member = members[index];
return Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildParticipantAvatar(member),
);
},
),
...List.generate(members.length, (index) {
final member = members[index];
return Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildParticipantAvatar(member),
);
}),
// Bouton "+" pour ajouter un participant
Padding(
padding: const EdgeInsets.only(right: 12),
@@ -705,7 +710,9 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
final theme = Theme.of(context);
final initials = member.pseudo.isNotEmpty
? member.pseudo[0].toUpperCase()
: (member.firstName.isNotEmpty ? member.firstName[0].toUpperCase() : '?');
: (member.firstName.isNotEmpty
? member.firstName[0].toUpperCase()
: '?');
final name = member.pseudo.isNotEmpty ? member.pseudo : member.firstName;
@@ -729,11 +736,14 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2),
backgroundImage: (member.profilePictureUrl != null &&
member.profilePictureUrl!.isNotEmpty)
backgroundImage:
(member.profilePictureUrl != null &&
member.profilePictureUrl!.isNotEmpty)
? NetworkImage(member.profilePictureUrl!)
: null,
child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty)
child:
(member.profilePictureUrl == null ||
member.profilePictureUrl!.isEmpty)
? Text(
initials,
style: TextStyle(
@@ -774,11 +784,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
child: CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
child: Icon(
Icons.add,
color: theme.colorScheme.primary,
size: 28,
),
child: Icon(Icons.add, color: theme.colorScheme.primary, size: 28),
),
),
),
@@ -869,7 +875,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
try {
// Chercher l'utilisateur par email
final user = await _userRepository.getUserByEmail(email);
if (user == null) {
_errorService.showError(
message: 'Utilisateur non trouvé avec cet email',
@@ -878,9 +884,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
}
if (user.id == null) {
_errorService.showError(
message: 'ID utilisateur invalide',
);
_errorService.showError(message: 'ID utilisateur invalide');
return;
}
@@ -901,20 +905,19 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
await _groupRepository.addMember(group.id, newMember);
// Ajouter le membre au compte
final account = await _accountRepository.getAccountByTripId(widget.trip.id!);
final account = await _accountRepository.getAccountByTripId(
widget.trip.id!,
);
if (account != null) {
await _accountRepository.addMemberToAccount(account.id, newMember);
}
// Mettre à jour la liste des participants du voyage
final newParticipants = [
...widget.trip.participants,
user.id!,
];
final newParticipants = [...widget.trip.participants, user.id!];
final updatedTrip = widget.trip.copyWith(
participants: newParticipants,
);
if (mounted) {
context.read<TripBloc>().add(
TripUpdateRequested(trip: updatedTrip),