From 9f2bfcaa551685ef245920196b51763d19f7369b Mon Sep 17 00:00:00 2001 From: Van Leemput Dayron Date: Thu, 4 Dec 2025 14:25:27 +0100 Subject: [PATCH] feat: Add full-screen receipt viewer, display trip participants in calendar, and restrict trip management options to creator. --- .../account/expense_detail_dialog.dart | 35 ++++- .../home/calendar/calendar_page.dart | 67 ++++++++- .../home/calendar/calendar_page.dart_snippet | 59 ++++++++ .../home/show_trip_details_content.dart | 128 ++++++++++++------ lib/repositories/user_repository.dart | 39 ++++++ lib/services/notification_service.dart | 10 +- 6 files changed, 296 insertions(+), 42 deletions(-) create mode 100644 lib/components/home/calendar/calendar_page.dart_snippet diff --git a/lib/components/account/expense_detail_dialog.dart b/lib/components/account/expense_detail_dialog.dart index bb67516..41e3c3e 100644 --- a/lib/components/account/expense_detail_dialog.dart +++ b/lib/components/account/expense_detail_dialog.dart @@ -322,7 +322,40 @@ class ExpenseDetailDialog extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: () { - // TODO: Show full screen image + showDialog( + context: context, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: Stack( + alignment: Alignment.center, + children: [ + InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: Image.network( + expense.receiptUrl!, + fit: BoxFit.contain, + ), + ), + Positioned( + top: 40, + right: 20, + child: IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + size: 30, + ), + onPressed: () => Navigator.of( + context, + ).pop(), + ), + ), + ], + ), + ), + ); }, ), ), diff --git a/lib/components/home/calendar/calendar_page.dart b/lib/components/home/calendar/calendar_page.dart index 75cdc27..bb65af7 100644 --- a/lib/components/home/calendar/calendar_page.dart +++ b/lib/components/home/calendar/calendar_page.dart @@ -7,6 +7,8 @@ import '../../../models/activity.dart'; import '../../../blocs/activity/activity_bloc.dart'; import '../../../blocs/activity/activity_state.dart'; import '../../../blocs/activity/activity_event.dart'; +import '../../../repositories/user_repository.dart'; +import '../../../models/user.dart'; class CalendarPage extends StatefulWidget { final Trip trip; @@ -93,7 +95,7 @@ class _CalendarPageState extends State { actions: [ IconButton( icon: Icon(Icons.people, color: theme.colorScheme.onSurface), - onPressed: () {}, // TODO: Show participants + onPressed: () => _showParticipantsDialog(context), ), ], ), @@ -419,4 +421,67 @@ class _CalendarPageState extends State { } return Icons.place; } + + void _showParticipantsDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Participants'), + content: SizedBox( + width: double.maxFinite, + child: FutureBuilder>( + future: UserRepository().getUsersByIds([ + ...widget.trip.participants, + widget.trip.createdBy, + ]), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return const Center(child: Text('Erreur de chargement')); + } + + final users = snapshot.data ?? []; + if (users.isEmpty) { + return const Center(child: Text('Aucun participant trouvé')); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + final isCreator = user.id == widget.trip.createdBy; + + return ListTile( + leading: CircleAvatar( + backgroundImage: user.profilePictureUrl != null + ? NetworkImage(user.profilePictureUrl!) + : null, + child: user.profilePictureUrl == null + ? Text( + '${user.prenom.isNotEmpty ? user.prenom[0] : ''}${user.nom.isNotEmpty ? user.nom[0] : ''}' + .toUpperCase(), + ) + : null, + ), + title: Text(user.fullName), + subtitle: isCreator ? const Text('Organisateur') : null, + ); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } } diff --git a/lib/components/home/calendar/calendar_page.dart_snippet b/lib/components/home/calendar/calendar_page.dart_snippet new file mode 100644 index 0000000..a720140 --- /dev/null +++ b/lib/components/home/calendar/calendar_page.dart_snippet @@ -0,0 +1,59 @@ + void _showParticipantsDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Participants'), + content: SizedBox( + width: double.maxFinite, + child: FutureBuilder>( + future: UserRepository().getUsersByIds([ + ...widget.trip.participants, + widget.trip.createdBy, + ]), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return const Center(child: Text('Erreur de chargement')); + } + + final users = snapshot.data ?? []; + if (users.isEmpty) { + return const Center(child: Text('Aucun participant trouvé')); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + final isCreator = user.id == widget.trip.createdBy; + + return ListTile( + leading: CircleAvatar( + backgroundImage: user.photoUrl != null + ? NetworkImage(user.photoUrl!) + : null, + child: user.photoUrl == null + ? Text(user.initials) + : null, + ), + title: Text(user.fullName), + subtitle: isCreator ? const Text('Organisateur') : null, + ); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 86d4938..c82f4fd 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:travel_mate/blocs/trip/trip_bloc.dart'; @@ -589,50 +590,99 @@ class _ShowTripDetailsContentState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => Container( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(Icons.edit, color: theme.colorScheme.primary), - title: Text( - 'Modifier le voyage', - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - CreateTripContent(tripToEdit: widget.trip), + builder: (context) { + return BlocBuilder( + builder: (context, state) { + final currentUser = state is user_state.UserLoaded + ? state.user + : null; + final isCreator = currentUser?.id == widget.trip.createdBy; + + return Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCreator) ...[ + ListTile( + leading: Icon( + Icons.edit, + color: theme.colorScheme.primary, + ), + title: Text( + 'Modifier le voyage', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + CreateTripContent(tripToEdit: widget.trip), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: Text( + 'Supprimer le voyage', + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.red, + ), + ), + onTap: () { + Navigator.pop(context); + _confirmDeleteTrip(); + }, + ), + const Divider(), + ], + ListTile( + leading: Icon( + Icons.share, + color: theme.colorScheme.onSurface, + ), + title: Text( + 'Partager le code', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + onTap: () { + Navigator.pop(context); + // Implement share functionality + if (_group != null) { + // Use share_plus package to share the code + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('ID du groupe : ${_group!.id}'), + action: SnackBarAction( + label: 'Copier', + onPressed: () { + Clipboard.setData( + ClipboardData(text: _group!.id), + ); + }, + ), + ), + ); + } + }, ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: Text( - 'Supprimer le voyage', - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface, - ), + ], ), - onTap: () { - Navigator.pop(context); - _showDeleteConfirmation(); - }, - ), - ], - ), - ), + ); + }, + ); + }, ); } - void _showDeleteConfirmation() { + void _confirmDeleteTrip() { final theme = Theme.of(context); showDialog( diff --git a/lib/repositories/user_repository.dart b/lib/repositories/user_repository.dart index de73c68..d91b941 100644 --- a/lib/repositories/user_repository.dart +++ b/lib/repositories/user_repository.dart @@ -150,4 +150,43 @@ class UserRepository { throw Exception('Impossible de changer le mot de passe'); } } + + // Récupérer plusieurs utilisateurs par leurs IDs + Future> getUsersByIds(List uids) async { + if (uids.isEmpty) return []; + + try { + // Firestore 'in' query supports up to 10 values. + // If we have more, we need to split into chunks. + List users = []; + + // Remove duplicates + final uniqueIds = uids.toSet().toList(); + + // Split into chunks of 10 + for (var i = 0; i < uniqueIds.length; i += 10) { + final end = (i + 10 < uniqueIds.length) ? i + 10 : uniqueIds.length; + final chunk = uniqueIds.sublist(i, end); + + final querySnapshot = await _firestore + .collection('users') + .where(FieldPath.documentId, whereIn: chunk) + .get(); + + for (var doc in querySnapshot.docs) { + final data = doc.data(); + users.add(User.fromMap({...data, 'id': doc.id})); + } + } + + return users; + } catch (e, stackTrace) { + _errorService.logError( + 'UserRepository', + 'Error retrieving users by IDs: $e', + stackTrace, + ); + return []; + } + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index a8c8a85..1b0f62d 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -49,7 +50,14 @@ class NotificationService { onDidReceiveNotificationResponse: (details) { // Handle notification tap LoggerService.info('Notification tapped: ${details.payload}'); - // TODO: Handle local notification tap if needed, usually we rely on FCM callbacks + if (details.payload != null) { + try { + final data = json.decode(details.payload!) as Map; + _handleNotificationTap(data); + } catch (e) { + LoggerService.error('Error parsing notification payload', error: e); + } + } }, );