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:
274
lib/pages/trip_invitations_page.dart
Normal file
274
lib/pages/trip_invitations_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user