diff --git a/.gitignore b/.gitignore index 1aadad7..4ceab8e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,9 @@ app.*.map.json /android/app/profile /android/app/release .vscode -.VSCodeCounter/ +.VSCodeCounter/* .env .env.local -.env.*.local \ No newline at end of file +.env.*.local +firestore.rules +storage.rules diff --git a/lib/components/home/calendar/calendar_page.dart b/lib/components/home/calendar/calendar_page.dart index f45513c..29768d0 100644 --- a/lib/components/home/calendar/calendar_page.dart +++ b/lib/components/home/calendar/calendar_page.dart @@ -36,6 +36,37 @@ class _CalendarPageState extends State { }).toList(); } + Future _selectTimeAndSchedule(Activity activity, DateTime date) async { + final TimeOfDay? pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child!, + ); + }, + ); + + if (pickedTime != null && mounted) { + final scheduledDate = DateTime( + date.year, + date.month, + date.day, + pickedTime.hour, + pickedTime.minute, + ); + + context.read().add( + UpdateActivityDate( + tripId: widget.trip.id!, + activityId: activity.id, + date: scheduledDate, + ), + ); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -172,7 +203,9 @@ class _CalendarPageState extends State { selectedActivities[index]; return ListTile( title: Text(activity.name), - subtitle: Text(activity.category), + subtitle: Text( + '${activity.category} - ${DateFormat('HH:mm').format(activity.date!)}', + ), trailing: IconButton( icon: const Icon(Icons.close), onPressed: () { @@ -243,15 +276,10 @@ class _CalendarPageState extends State { icon: const Icon(Icons.arrow_back), onPressed: () { if (_selectedDay != null) { - context - .read() - .add( - UpdateActivityDate( - tripId: widget.trip.id!, - activityId: activity.id, - date: _selectedDay, - ), - ); + _selectTimeAndSchedule( + activity, + _selectedDay!, + ); } }, ), @@ -260,6 +288,33 @@ class _CalendarPageState extends State { }, ), ), + // Zone de drop pour le calendrier + DragTarget( + onWillAccept: (data) => true, + onAccept: (activity) { + if (_selectedDay != null) { + _selectTimeAndSchedule(activity, _selectedDay!); + } + }, + builder: (context, candidateData, rejectedData) { + return Container( + height: 50, + color: candidateData.isNotEmpty + ? theme.colorScheme.primary.withValues( + alpha: 0.1, + ) + : null, + child: Center( + child: Text( + 'Glisser ici pour planifier', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + ), + ); + }, + ), ], ), ), diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 0117620..97ac0be 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -455,6 +455,9 @@ class _ShowTripDetailsContentState extends State { ), ], ), + const SizedBox(height: 32), + _buildNextActivitiesSection(), + _buildExpensesCard(), ], ), ), @@ -654,52 +657,63 @@ class _ShowTripDetailsContentState extends State { return FutureBuilder( future: _groupRepository.getGroupByTripId(widget.trip.id!), - builder: (context, snapshot) { - // En attente - if (snapshot.connectionState == ConnectionState.waiting) { + builder: (context, groupSnapshot) { + if (groupSnapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } - // Erreur - if (snapshot.hasError) { - return Center( - child: Text( - 'Erreur: ${snapshot.error}', - style: TextStyle(color: Colors.red), - ), - ); - } - - // Pas de groupe trouvé - if (!snapshot.hasData || snapshot.data == null) { + if (groupSnapshot.hasError || + !groupSnapshot.hasData || + groupSnapshot.data == null) { return const Center(child: Text('Aucun participant')); } - final group = snapshot.data!; - final members = group.members; + final groupId = groupSnapshot.data!.id; - if (members.isEmpty) { - return const Center(child: Text('Aucun participant')); - } + return StreamBuilder>( + stream: _groupRepository.watchGroupMembers(groupId), + builder: (context, snapshot) { + // En attente + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } - return SingleChildScrollView( - 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), - ); - }), - // Bouton "+" pour ajouter un participant - Padding( - padding: const EdgeInsets.only(right: 12), - child: _buildAddParticipantButton(), + // Erreur + if (snapshot.hasError) { + return Center( + child: Text( + 'Erreur: ${snapshot.error}', + style: TextStyle(color: Colors.red), + ), + ); + } + + final members = snapshot.data ?? []; + + if (members.isEmpty) { + return const Center(child: Text('Aucun participant')); + } + + return SingleChildScrollView( + 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), + ); + }), + // Bouton "+" pour ajouter un participant + Padding( + padding: const EdgeInsets.only(right: 12), + child: _buildAddParticipantButton(), + ), + ], ), - ], - ), + ); + }, ); }, ); @@ -950,4 +964,175 @@ class _ShowTripDetailsContentState extends State { ), ); } + + Widget _buildNextActivitiesSection() { + final theme = Theme.of(context); + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Prochaines activités', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + TextButton( + onPressed: () => _navigateToActivities(), + child: Text( + 'Tout voir', + style: TextStyle( + color: Colors.teal, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + _buildActivityCard( + title: 'Visite du Colisée', + date: '11 août, 10:00', + icon: Icons.museum, + ), + const SizedBox(height: 12), + _buildActivityCard( + title: 'Dîner à Trastevere', + date: '11 août, 20:30', + icon: Icons.restaurant, + ), + ], + ); + } + + Widget _buildActivityCard({ + required String title, + required String date, + required IconData icon, + }) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.05), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.teal.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: Colors.teal, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + date, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ], + ), + ); + } + + Widget _buildExpensesCard() { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.only(top: 24), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFDF4E3), // Light beige background + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.warning_amber_rounded, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dépenses', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF5D4037), // Brown text + ), + ), + const SizedBox(height: 4), + Text( + 'Vous devez 25€ à Clara', + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color(0xFF8D6E63), // Lighter brown + ), + ), + ], + ), + ), + TextButton( + onPressed: () => _showComingSoon('Régler les dépenses'), + child: Text( + 'Régler', + style: TextStyle( + color: const Color(0xFF5D4037), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } } diff --git a/lib/models/group.dart b/lib/models/group.dart index 7097091..f095b91 100644 --- a/lib/models/group.dart +++ b/lib/models/group.dart @@ -1,54 +1,59 @@ import 'group_member.dart'; /// Model representing a travel group. -/// +/// /// A group is a collection of travelers who are part of a specific trip. /// It contains information about group members, creation details, and /// provides methods for serialization with Firestore. class Group { /// Unique identifier for the group - final String id; - + final String id; + /// Display name of the group final String name; - + /// ID of the trip this group belongs to final String tripId; - + /// ID of the user who created this group final String createdBy; - + /// Timestamp when the group was created final DateTime createdAt; - + /// Timestamp when the group was last updated final DateTime updatedAt; - + /// List of members in this group final List members; + /// List of member IDs for efficient querying and security rules + final List memberIds; + /// Creates a new [Group] instance. - /// + /// /// [id], [name], [tripId], and [createdBy] are required. /// [createdAt] and [updatedAt] default to current time if not provided. /// [members] defaults to empty list if not provided. Group({ - required this.id, + required this.id, required this.name, required this.tripId, required this.createdBy, DateTime? createdAt, DateTime? updatedAt, List? members, - }) : createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(), - members = members ?? []; + List? memberIds, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(), + members = members ?? [], + memberIds = memberIds ?? []; /// Creates a [Group] instance from a Firestore document map. - /// + /// /// [map] - The document data from Firestore /// [id] - The document ID from Firestore - /// + /// /// Returns a new [Group] instance with data from the map. factory Group.fromMap(Map map, String id) { return Group( @@ -59,6 +64,7 @@ class Group { createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] ?? 0), updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] ?? 0), members: [], + memberIds: List.from(map['memberIds'] ?? []), ); } @@ -69,6 +75,7 @@ class Group { 'createdBy': createdBy, 'createdAt': createdAt.millisecondsSinceEpoch, 'updatedAt': updatedAt.millisecondsSinceEpoch, + 'memberIds': memberIds, }; } @@ -80,6 +87,7 @@ class Group { DateTime? createdAt, DateTime? updatedAt, List? members, + List? memberIds, }) { return Group( id: id ?? this.id, @@ -89,6 +97,7 @@ class Group { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, members: members ?? this.members, + memberIds: memberIds ?? this.memberIds, ); } -} \ No newline at end of file +} diff --git a/lib/repositories/group_repository.dart b/lib/repositories/group_repository.dart index bd02b6f..ed81502 100644 --- a/lib/repositories/group_repository.dart +++ b/lib/repositories/group_repository.dart @@ -1,10 +1,12 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:travel_mate/services/error_service.dart'; import '../models/group.dart'; import '../models/group_member.dart'; class GroupRepository { final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; final _errorService = ErrorService(); CollectionReference get _groupsCollection => _firestore.collection('groups'); @@ -20,8 +22,10 @@ class GroupRepository { try { return await _firestore.runTransaction((transaction) async { final groupRef = _groupsCollection.doc(); - - final groupData = group.toMap(); + + // Ajouter les IDs des membres à la liste memberIds + final memberIds = members.map((m) => m.userId).toList(); + final groupData = group.copyWith(memberIds: memberIds).toMap(); transaction.set(groupRef, groupData); for (var member in members) { @@ -36,52 +40,15 @@ class GroupRepository { } } - Stream> getGroupsByUserId(String userId) { + Stream> getGroupsByUserId(String userId) { return _groupsCollection + .where('memberIds', arrayContains: userId) .snapshots() - .asyncMap((snapshot) async { - - List userGroups = []; - - for (var groupDoc in snapshot.docs) { - try { - final groupId = groupDoc.id; - - // Vérifier si l'utilisateur est membre - final memberDoc = await groupDoc.reference - .collection('members') - .doc(userId) - .get(); - - if (memberDoc.exists) { - final groupData = groupDoc.data() as Map; - final group = Group.fromMap(groupData, groupId); - - final members = await getGroupMembers(groupId); - userGroups.add(group.copyWith(members: members)); - } else { - _errorService.logInfo('group_repository.dart','Utilisateur NON membre de $groupId'); - } - } catch (e, stackTrace) { - _errorService.logError(e.toString(), stackTrace); - } - } - return userGroups; - }) - .distinct((prev, next) { - // Comparer les listes pour éviter les doublons - if (prev.length != next.length) { - return false; - } - - // Vérifier si les IDs sont identiques - final prevIds = prev.map((g) => g.id).toSet(); - final nextIds = next.map((g) => g.id).toSet(); - - final identical = prevIds.difference(nextIds).isEmpty && - nextIds.difference(prevIds).isEmpty; - - return identical; + .map((snapshot) { + return snapshot.docs.map((doc) { + final groupData = doc.data() as Map; + return Group.fromMap(groupData, doc.id); + }).toList(); }) .handleError((error, stackTrace) { _errorService.logError(error, stackTrace); @@ -92,12 +59,12 @@ class GroupRepository { Future getGroupById(String groupId) async { try { final doc = await _groupsCollection.doc(groupId).get(); - + if (!doc.exists) return null; - + final group = Group.fromMap(doc.data() as Map, doc.id); final members = await getGroupMembers(groupId); - + return group.copyWith(members: members); } catch (e) { throw Exception('Erreur lors de la récupération du groupe: $e'); @@ -106,34 +73,63 @@ class GroupRepository { Future getGroupByTripId(String tripId) async { try { - final querySnapshot = await _groupsCollection + final userId = _auth.currentUser?.uid; + if (userId == null) return null; + + // Tentative 1: Requête optimisée avec memberIds + var querySnapshot = await _groupsCollection .where('tripId', isEqualTo: tripId) + .where('memberIds', arrayContains: userId) .limit(1) .get(); + // Tentative 2: Fallback pour le créateur (si memberIds est manquant - anciennes données) + if (querySnapshot.docs.isEmpty) { + querySnapshot = await _groupsCollection + .where('tripId', isEqualTo: tripId) + .where('createdBy', isEqualTo: userId) + .limit(1) + .get(); + + // Si on trouve le groupe via le fallback, on lance une migration + if (querySnapshot.docs.isNotEmpty) { + _migrateGroupData(querySnapshot.docs.first.id); + } + } + if (querySnapshot.docs.isEmpty) return null; final doc = querySnapshot.docs.first; final group = Group.fromMap(doc.data() as Map, doc.id); final members = await getGroupMembers(doc.id); - + return group.copyWith(members: members); } catch (e) { throw Exception('Erreur lors de la récupération du groupe: $e'); } } + /// Méthode utilitaire pour migrer les anciennes données + Future _migrateGroupData(String groupId) async { + try { + final members = await getGroupMembers(groupId); + final memberIds = members.map((m) => m.userId).toList(); + + if (memberIds.isNotEmpty) { + await _groupsCollection.doc(groupId).update({'memberIds': memberIds}); + print('Migration réussie pour le groupe $groupId'); + } + } catch (e) { + print('Erreur de migration pour le groupe $groupId: $e'); + } + } + Future> getGroupMembers(String groupId) async { try { - final snapshot = await _membersCollection(groupId).get(); - return snapshot.docs - .map((doc) { - return GroupMember.fromMap( - doc.data() as Map, - doc.id, - ); - }) - .toList(); + final snapshot = await _membersCollection(groupId).get(); + return snapshot.docs.map((doc) { + return GroupMember.fromMap(doc.data() as Map, doc.id); + }).toList(); } catch (e) { throw Exception('Erreur lors de la récupération des membres: $e'); } @@ -141,10 +137,22 @@ class GroupRepository { Future addMember(String groupId, GroupMember member) async { try { + // 1. Récupérer le groupe pour avoir le tripId + final group = await getGroupById(groupId); + if (group == null) throw Exception('Groupe introuvable'); + + // 2. Ajouter le membre dans la sous-collection members du groupe await _membersCollection(groupId).doc(member.userId).set(member.toMap()); - + + // 3. Mettre à jour la liste memberIds du groupe await _groupsCollection.doc(groupId).update({ 'updatedAt': DateTime.now().millisecondsSinceEpoch, + 'memberIds': FieldValue.arrayUnion([member.userId]), + }); + + // 4. Mettre à jour la liste participants du voyage + await _firestore.collection('trips').doc(group.tripId).update({ + 'participants': FieldValue.arrayUnion([member.userId]), }); } catch (e) { throw Exception('Erreur lors de l\'ajout du membre: $e'); @@ -153,10 +161,22 @@ class GroupRepository { Future removeMember(String groupId, String userId) async { try { + // 1. Récupérer le groupe pour avoir le tripId + final group = await getGroupById(groupId); + if (group == null) throw Exception('Groupe introuvable'); + + // 2. Supprimer le membre de la sous-collection members du groupe await _membersCollection(groupId).doc(userId).delete(); - + + // 3. Mettre à jour la liste memberIds du groupe await _groupsCollection.doc(groupId).update({ 'updatedAt': DateTime.now().millisecondsSinceEpoch, + 'memberIds': FieldValue.arrayRemove([userId]), + }); + + // 4. Mettre à jour la liste participants du voyage + await _firestore.collection('trips').doc(group.tripId).update({ + 'participants': FieldValue.arrayRemove([userId]), }); } catch (e) { throw Exception('Erreur lors de la suppression du membre: $e'); @@ -165,8 +185,11 @@ class GroupRepository { Future updateGroup(String groupId, Group group) async { try { - await _groupsCollection.doc(groupId).update( - group.toMap()..['updatedAt'] = DateTime.now().millisecondsSinceEpoch, + await _groupsCollection + .doc(groupId) + .update( + group.toMap() + ..['updatedAt'] = DateTime.now().millisecondsSinceEpoch, ); } catch (e) { throw Exception('Erreur lors de la mise à jour du groupe: $e'); @@ -174,38 +197,42 @@ class GroupRepository { } Future deleteGroup(String tripId) async { - try { - final querySnapshot = await _groupsCollection - .where('tripId', isEqualTo: tripId) - .limit(1) - .get(); + try { + final userId = _auth.currentUser?.uid; + if (userId == null) throw Exception('Utilisateur non connecté'); - if (querySnapshot.docs.isEmpty) { - throw Exception('Aucun groupe trouvé pour ce voyage'); + final querySnapshot = await _groupsCollection + .where('tripId', isEqualTo: tripId) + .where('createdBy', isEqualTo: userId) + .limit(1) + .get(); + + if (querySnapshot.docs.isEmpty) { + throw Exception('Aucun groupe trouvé pour ce voyage'); + } + + final groupDoc = querySnapshot.docs.first; + final groupId = groupDoc.id; + + final membersSnapshot = await _membersCollection(groupId).get(); + for (var doc in membersSnapshot.docs) { + await doc.reference.delete(); + } + + await _groupsCollection.doc(groupId).delete(); + } catch (e) { + throw Exception('Erreur lors de la suppression du groupe: $e'); } - - final groupDoc = querySnapshot.docs.first; - final groupId = groupDoc.id; - - final membersSnapshot = await _membersCollection(groupId).get(); - for (var doc in membersSnapshot.docs) { - await doc.reference.delete(); - } - - await _groupsCollection.doc(groupId).delete(); - } catch (e) { - throw Exception('Erreur lors de la suppression du groupe: $e'); } -} Stream> watchGroupMembers(String groupId) { return _membersCollection(groupId).snapshots().map( - (snapshot) => snapshot.docs - .map((doc) => GroupMember.fromMap( - doc.data() as Map, - doc.id, - )) - .toList(), - ); + (snapshot) => snapshot.docs + .map( + (doc) => + GroupMember.fromMap(doc.data() as Map, doc.id), + ) + .toList(), + ); } -} \ No newline at end of file +}