feat: Implement trip invitation functionality and notification handling

- Added TripInvitationRepository for managing trip invitations.
- Created TripInvitation model with serialization methods.
- Implemented notification payload parser for handling FCM notifications.
- Enhanced NotificationService to manage trip invitations and related actions.
- Updated UserRepository to include user search functionality.
- Modified AuthRepository to store multiple FCM tokens.
- Added tests for trip invitation logic and notification payload parsing.
- Updated pubspec.yaml and pubspec.lock for dependency management.
This commit is contained in:
Van Leemput Dayron
2026-03-13 13:54:47 +01:00
parent e665dea82a
commit 3215a990d1
27 changed files with 1961 additions and 321 deletions

View File

@@ -16,6 +16,7 @@ import '../services/notification_service.dart';
import '../services/map_navigation_service.dart';
import '../services/whats_new_service.dart';
import '../components/whats_new_dialog.dart';
import 'trip_invitations_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -227,6 +228,19 @@ class _HomePageState extends State<HomePage> {
title: "Comptes",
index: 4,
),
ListTile(
leading: const Icon(Icons.mail_outline),
title: const Text('Invitations'),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TripInvitationsPage(),
),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),

View File

@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/blocs/user/user_bloc.dart';
import 'package:travel_mate/blocs/user/user_state.dart' as user_state;
import 'package:travel_mate/models/trip_invitation.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
import 'package:travel_mate/services/error_service.dart';
/// Affiche la boîte de réception des invitations de voyage.
///
/// Cette page permet de consulter toutes les invitations reçues et de répondre
/// aux invitations en attente (`pending`) via les actions accepter/refuser.
class TripInvitationsPage extends StatelessWidget {
/// Repository utilisé pour charger et mettre à jour les invitations.
final TripInvitationDataSource repository;
/// Identifiant utilisateur injecté pour les tests ou contextes spécifiques.
final String? userIdOverride;
/// Crée la page des invitations.
///
/// [repository] peut être injecté pour les tests; sinon un repository réel est utilisé.
TripInvitationsPage({
super.key,
TripInvitationDataSource? repository,
this.userIdOverride,
}) : repository = repository ?? TripInvitationRepository();
/// Retourne les invitations correspondant au [status] demandé.
///
/// [status] peut valoir `pending`, `accepted` ou `rejected`.
List<TripInvitation> _filterByStatus(
List<TripInvitation> invitations,
String status,
) {
return invitations
.where((invitation) => invitation.status == status)
.toList(growable: false);
}
/// Envoie la réponse utilisateur pour une invitation.
///
/// [isAccepted] à `true` accepte l'invitation, sinon la refuse.
/// Un feedback visuel est affiché à l'utilisateur.
Future<void> _respondToInvitation({
required BuildContext context,
required String invitationId,
required bool isAccepted,
}) async {
try {
await repository.respondToInvitation(
invitationId: invitationId,
isAccepted: isAccepted,
);
if (!context.mounted) {
return;
}
ErrorService().showSnackbar(
message: isAccepted ? 'Invitation acceptée' : 'Invitation refusée',
isError: false,
);
} catch (e) {
if (!context.mounted) {
return;
}
ErrorService().showError(message: 'Erreur lors de la réponse: $e');
}
}
@override
Widget build(BuildContext context) {
if (userIdOverride != null && userIdOverride!.isNotEmpty) {
return _InvitationsScaffold(
userId: userIdOverride!,
repository: repository,
onRespond: ({required String invitationId, required bool isAccepted}) {
return _respondToInvitation(
context: context,
invitationId: invitationId,
isAccepted: isAccepted,
);
},
filterByStatus: _filterByStatus,
);
}
return BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, state) {
if (state is! user_state.UserLoaded) {
return Scaffold(
appBar: AppBar(title: const Text('Invitations')),
body: const Center(child: Text('Utilisateur non chargé')),
);
}
return _InvitationsScaffold(
userId: state.user.id,
repository: repository,
onRespond:
({required String invitationId, required bool isAccepted}) {
return _respondToInvitation(
context: context,
invitationId: invitationId,
isAccepted: isAccepted,
);
},
filterByStatus: _filterByStatus,
);
},
);
}
}
/// Échafaudage principal des invitations avec onglets par statut.
class _InvitationsScaffold extends StatelessWidget {
/// Identifiant utilisateur des invitations à afficher.
final String userId;
/// Source de données des invitations.
final TripInvitationDataSource repository;
/// Callback appelé lors d'une réponse utilisateur.
final Future<void> Function({
required String invitationId,
required bool isAccepted,
})
onRespond;
/// Fonction de filtrage par statut.
final List<TripInvitation> Function(
List<TripInvitation> invitations,
String status,
)
filterByStatus;
/// Crée l'échafaudage d'affichage des invitations.
const _InvitationsScaffold({
required this.userId,
required this.repository,
required this.onRespond,
required this.filterByStatus,
});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Invitations'),
bottom: const TabBar(
tabs: [
Tab(text: 'En attente'),
Tab(text: 'Acceptées'),
Tab(text: 'Refusées'),
],
),
),
body: StreamBuilder<List<TripInvitation>>(
stream: repository.watchInvitationsForUser(userId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Erreur de chargement: ${snapshot.error}'),
);
}
final invitations = snapshot.data ?? const <TripInvitation>[];
final pending = filterByStatus(invitations, 'pending');
final accepted = filterByStatus(invitations, 'accepted');
final rejected = filterByStatus(invitations, 'rejected');
return TabBarView(
children: [
_InvitationsList(
invitations: pending,
onAccept: (id) =>
onRespond(invitationId: id, isAccepted: true),
onReject: (id) =>
onRespond(invitationId: id, isAccepted: false),
),
_InvitationsList(invitations: accepted),
_InvitationsList(invitations: rejected),
],
);
},
),
),
);
}
}
/// Affiche une liste d'invitations avec actions éventuelles.
class _InvitationsList extends StatelessWidget {
/// Invitations à afficher.
final List<TripInvitation> invitations;
/// Callback appelé lors d'une acceptation.
final Future<void> Function(String invitationId)? onAccept;
/// Callback appelé lors d'un refus.
final Future<void> Function(String invitationId)? onReject;
/// Crée une liste d'invitations.
const _InvitationsList({
required this.invitations,
this.onAccept,
this.onReject,
});
@override
Widget build(BuildContext context) {
if (invitations.isEmpty) {
return const Center(child: Text('Aucune invitation'));
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: invitations.length,
separatorBuilder: (context, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final invitation = invitations[index];
final isPending = invitation.status == 'pending';
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invitation.tripTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text('Invité par: ${invitation.inviterName}'),
Text(
'Reçu: ${invitation.createdAt.day}/${invitation.createdAt.month}/${invitation.createdAt.year}',
),
if (isPending) ...[
const SizedBox(height: 12),
Row(
children: [
OutlinedButton(
onPressed: onReject == null
? null
: () => onReject!(invitation.id),
child: const Text('Refuser'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: onAccept == null
? null
: () => onAccept!(invitation.id),
child: const Text('Accepter'),
),
],
),
],
],
),
),
);
},
);
}
}