diff --git a/lib/core/constants/urls.dart b/lib/core/constants/urls.dart index cb2e7d5..a9f5297 100644 --- a/lib/core/constants/urls.dart +++ b/lib/core/constants/urls.dart @@ -1,32 +1,374 @@ +import 'env_config.dart'; + +/// Classe utilitaire pour gérer toutes les URLs de l'API backend. +/// +/// Cette classe centralise toutes les URLs de l'API pour faciliter +/// la maintenance et éviter la duplication de code. +/// +/// **Usage:** +/// ```dart +/// // URL simple +/// final url = Urls.authenticateUser; +/// +/// // URL avec paramètres dynamiques +/// final userUrl = Urls.getUserByIdWithId('123'); +/// final eventUrl = Urls.getEventByIdWithId('456'); +/// ``` class Urls { - static const String baseUrl = 'http://192.168.1.16:8080'; + /// Constructeur privé pour empêcher l'instanciation + Urls._(); - // Authentication and Users Endpoints - static const String authenticateUser = '$baseUrl/users/authenticate'; - static const String createUser = '$baseUrl/users'; - static const String getUserById = '$baseUrl/users'; // Append '/{id}' dynamically - static const String deleteUser = '$baseUrl/users'; // Append '/{id}' dynamically - static const String updateUserProfileImage = '$baseUrl/users'; // Append '/{id}/profile-image' dynamically + /// URL de base de l'API + static String get baseUrl => EnvConfig.apiBaseUrl; - // Events Endpoints - static const String createEvent = '$baseUrl/events'; - static const String getEventById = '$baseUrl/events'; // Append '/{id}' dynamically - static const String deleteEvent = '$baseUrl/events'; // Append '/{id}' dynamically - static const String getEventsAfterDate = '$baseUrl/events/after-date'; - static const String addParticipant = '$baseUrl/events'; // Append '/{id}/participants' dynamically - static const String removeParticipant = '$baseUrl/events'; // Append '/{id}/participants/{userId}' dynamically - static const String getNumberOfParticipants = '$baseUrl/events'; // Append '/{id}/participants/count' dynamically - static const String closeEvent = '$baseUrl/events'; // Append '/{id}/close' dynamically - static const String reopenEvent = '$baseUrl/events'; // Append '/{id}/reopen' dynamically - static const String updateEvent = '$baseUrl/events'; // Append '/{id}' dynamically - static const String updateEventImage = '$baseUrl/events'; // Append '/{id}/image' dynamically - static const String getAllEvents = '$baseUrl/events'; - static const String getEventsByCategory = '$baseUrl/events/category'; // Append '/{category}' dynamically - static const String updateEventStatus = '$baseUrl/events'; // Append '/{id}/status' dynamically - static const String searchEvents = '$baseUrl/events/search'; // Use query parameter for 'keyword' - static const String getEventsByUser = '$baseUrl/events/user'; // Append '/{userId}' dynamically - static const String getEventsByStatus = '$baseUrl/events/status'; // Append '/{status}' dynamically - static const String getEventsBetweenDates = '$baseUrl/events/between-dates'; // Use query parameters for startDate and endDate + // ============================================================================ + // AUTHENTIFICATION ET UTILISATEURS + // ============================================================================ -// Other URLs can be added here as the project expands + /// Endpoint pour authentifier un utilisateur + static String get authenticateUser => '$baseUrl/users/authenticate'; + + /// Endpoint pour créer un nouvel utilisateur + static String get createUser => '$baseUrl/users'; + + /// Endpoint de base pour les opérations sur les utilisateurs + static String get usersBase => '$baseUrl/users'; + + /// Retourne l'URL pour obtenir un utilisateur par son ID + /// + /// [userId] L'ID de l'utilisateur + static String getUserByIdWithId(String userId) => '$usersBase/$userId'; + + /// Retourne l'URL pour rechercher un utilisateur par email + /// + /// [email] L'email de l'utilisateur à rechercher + static String searchUserByEmail(String email) => + '$usersBase/search?email=${Uri.encodeComponent(email)}'; + + /// Retourne l'URL pour supprimer un utilisateur par son ID + /// + /// [userId] L'ID de l'utilisateur + static String deleteUserWithId(String userId) => '$usersBase/$userId'; + + /// Retourne l'URL pour mettre à jour l'image de profil d'un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String updateUserProfileImageWithId(String userId) => + '$usersBase/$userId/profile-image'; + + /// Retourne l'URL pour mettre à jour un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String updateUserWithId(String userId) => '$usersBase/$userId'; + + // ============================================================================ + // ÉVÉNEMENTS + // ============================================================================ + + /// Endpoint de base pour les opérations sur les événements + static String get eventsBase => '$baseUrl/events'; + + /// Endpoint pour créer un nouvel événement + static String get createEvent => eventsBase; + + /// Endpoint pour obtenir tous les événements + static String get getAllEvents => eventsBase; + + /// Endpoint pour obtenir les événements créés par un utilisateur et ses amis + static String get getEventsCreatedByUserAndFriends => + '$eventsBase/created-by-user-and-friends'; + + /// Retourne l'URL pour obtenir un événement par son ID + /// + /// [eventId] L'ID de l'événement + static String getEventByIdWithId(String eventId) => '$eventsBase/$eventId'; + + /// Retourne l'URL pour supprimer un événement par son ID + /// + /// [eventId] L'ID de l'événement + static String deleteEventWithId(String eventId) => '$eventsBase/$eventId'; + + /// Retourne l'URL pour mettre à jour un événement + /// + /// [eventId] L'ID de l'événement + static String updateEventWithId(String eventId) => '$eventsBase/$eventId'; + + /// Retourne l'URL pour mettre à jour l'image d'un événement + /// + /// [eventId] L'ID de l'événement + static String updateEventImageWithId(String eventId) => + '$eventsBase/$eventId/image'; + + /// Retourne l'URL pour fermer un événement + /// + /// [eventId] L'ID de l'événement + static String closeEventWithId(String eventId) => '$eventsBase/$eventId/close'; + + /// Retourne l'URL pour rouvrir un événement + /// + /// [eventId] L'ID de l'événement + static String reopenEventWithId(String eventId) => + '$eventsBase/$eventId/reopen'; + + /// Retourne l'URL pour mettre à jour le statut d'un événement + /// + /// [eventId] L'ID de l'événement + static String updateEventStatusWithId(String eventId) => + '$eventsBase/$eventId/status'; + + /// Endpoint pour obtenir les événements après une date + static String get getEventsAfterDate => '$eventsBase/after-date'; + + /// Endpoint pour obtenir les événements entre deux dates + static String get getEventsBetweenDates => '$eventsBase/between-dates'; + + /// Retourne l'URL pour obtenir les événements par catégorie + /// + /// [category] La catégorie des événements + static String getEventsByCategoryWithCategory(String category) => + '$eventsBase/category/$category'; + + /// Retourne l'URL pour obtenir les événements par statut + /// + /// [status] Le statut des événements (ouvert, fermé, etc.) + static String getEventsByStatusWithStatus(String status) => + '$eventsBase/status/$status'; + + /// Retourne l'URL pour obtenir les événements d'un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String getEventsByUserWithUserId(String userId) => + '$eventsBase/user/$userId'; + + /// Endpoint pour rechercher des événements + /// + /// **Note:** Utilisez des paramètres de requête pour le mot-clé + static String get searchEvents => '$eventsBase/search'; + + // ============================================================================ + // PARTICIPANTS AUX ÉVÉNEMENTS + // ============================================================================ + + /// Retourne l'URL pour ajouter un participant à un événement + /// + /// [eventId] L'ID de l'événement + static String addParticipantWithEventId(String eventId) => + '$eventsBase/$eventId/participants'; + + /// Retourne l'URL pour retirer un participant d'un événement + /// + /// [eventId] L'ID de l'événement + /// [userId] L'ID de l'utilisateur à retirer + static String removeParticipantWithIds(String eventId, String userId) => + '$eventsBase/$eventId/participants/$userId'; + + /// Retourne l'URL pour obtenir le nombre de participants d'un événement + /// + /// [eventId] L'ID de l'événement + static String getNumberOfParticipantsWithEventId(String eventId) => + '$eventsBase/$eventId/participants/count'; + + /// Retourne l'URL pour réagir à un événement (utilise favorite) + /// + /// [eventId] L'ID de l'événement + /// [userId] L'ID de l'utilisateur + static String reactToEventWithId(String eventId, String userId) => + '$eventsBase/$eventId/favorite?userId=$userId'; + + /// Retourne l'URL pour participer à un événement (utilise participants) + /// + /// [eventId] L'ID de l'événement + static String participateInEventWithId(String eventId) => + '$eventsBase/$eventId/participants'; + + // ============================================================================ + // AMIS ET RELATIONS SOCIALES + // ============================================================================ + + /// Endpoint de base pour les opérations sur les amis + static String get friendsBase => '$baseUrl/friends'; + + /// Retourne l'URL pour obtenir les amis d'un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String getFriendsWithUserId(String userId) => + '$friendsBase/user/$userId'; + + /// Retourne l'URL pour ajouter un ami + /// + /// [userId] L'ID de l'utilisateur + /// [friendId] L'ID de l'ami à ajouter + static String addFriendWithIds(String userId, String friendId) => + '$friendsBase/$userId/$friendId'; + + /// Retourne l'URL pour supprimer un ami + /// + /// [userId] L'ID de l'utilisateur + /// [friendId] L'ID de l'ami à supprimer + static String removeFriendWithIds(String userId, String friendId) => + '$friendsBase/$userId/$friendId'; + + /// Retourne l'URL pour récupérer les demandes d'amitié en attente + /// + /// [userId] L'ID de l'utilisateur + /// [page] Le numéro de la page (optionnel, par défaut 0) + /// [size] La taille de la page (optionnel, par défaut 10) + static String getPendingFriendRequestsWithUserId(String userId, {int page = 0, int size = 10}) => + '$friendsBase/pending/$userId?page=$page&size=$size'; + + /// Retourne l'URL pour récupérer les demandes d'amitié envoyées + /// + /// [userId] L'ID de l'utilisateur + /// [page] Le numéro de la page (optionnel, par défaut 0) + /// [size] La taille de la page (optionnel, par défaut 10) + static String getSentFriendRequestsWithUserId(String userId, {int page = 0, int size = 10}) => + '$friendsBase/sent/$userId?page=$page&size=$size'; + + /// Retourne l'URL pour récupérer les demandes d'amitié reçues + /// + /// [userId] L'ID de l'utilisateur + /// [page] Le numéro de la page (optionnel, par défaut 0) + /// [size] La taille de la page (optionnel, par défaut 10) + static String getReceivedFriendRequestsWithUserId(String userId, {int page = 0, int size = 10}) => + '$friendsBase/received/$userId?page=$page&size=$size'; + + /// Retourne l'URL pour accepter une demande d'amitié + /// + /// [friendshipId] L'ID de la relation d'amitié + static String acceptFriendRequestWithId(String friendshipId) => + '$friendsBase/$friendshipId/accept'; + + /// Retourne l'URL pour rejeter une demande d'amitié + /// + /// [friendshipId] L'ID de la relation d'amitié + static String rejectFriendRequestWithId(String friendshipId) => + '$friendsBase/$friendshipId/reject'; + + // ============================================================================ + // NOTIFICATIONS + // ============================================================================ + + /// Endpoint de base pour les opérations sur les notifications + static String get notificationsBase => '$baseUrl/notifications'; + + /// Retourne l'URL pour obtenir les notifications d'un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String getNotificationsWithUserId(String userId) => + '$notificationsBase/user/$userId'; + + /// Retourne l'URL pour marquer une notification comme lue + /// + /// [notificationId] L'ID de la notification + static String markNotificationAsReadWithId(String notificationId) => + '$notificationsBase/$notificationId/read'; + + /// Retourne l'URL pour marquer toutes les notifications comme lues + /// + /// [userId] L'ID de l'utilisateur + static String markAllNotificationsAsReadWithUserId(String userId) => + '$notificationsBase/user/$userId/mark-all-read'; + + /// Retourne l'URL pour supprimer une notification + /// + /// [notificationId] L'ID de la notification + static String deleteNotificationWithId(String notificationId) => + '$notificationsBase/$notificationId'; + + // ============================================================================ + // POSTS SOCIAUX + // ============================================================================ + + /// Endpoint de base pour les opérations sur les posts sociaux + static String get postsBase => '$baseUrl/posts'; + + /// Retourne l'URL pour obtenir tous les posts (avec pagination) + static String get getAllPosts => postsBase; + + /// Retourne l'URL pour créer un nouveau post + static String get createSocialPost => postsBase; + + /// Retourne l'URL pour obtenir un post par son ID + /// + /// [postId] L'ID du post + static String getSocialPostByIdWithId(String postId) => '$postsBase/$postId'; + + /// Retourne l'URL pour mettre à jour un post + /// + /// [postId] L'ID du post + static String updateSocialPostWithId(String postId) => '$postsBase/$postId'; + + /// Retourne l'URL pour supprimer un post + /// + /// [postId] L'ID du post + static String deleteSocialPostWithId(String postId) => '$postsBase/$postId'; + + /// Retourne l'URL pour rechercher des posts + /// + /// [query] Le terme de recherche + static String searchSocialPostsWithQuery(String query) => + '$postsBase/search?q=${Uri.encodeComponent(query)}'; + + /// Retourne l'URL pour liker un post + /// + /// [postId] L'ID du post + static String likeSocialPostWithId(String postId) => '$postsBase/$postId/like'; + + /// Retourne l'URL pour commenter un post + /// + /// [postId] L'ID du post + static String commentSocialPostWithId(String postId) => + '$postsBase/$postId/comment'; + + /// Retourne l'URL pour partager un post + /// + /// [postId] L'ID du post + static String shareSocialPostWithId(String postId) => '$postsBase/$postId/share'; + + /// Retourne l'URL pour obtenir les posts d'un utilisateur + /// + /// [userId] L'ID de l'utilisateur + static String getSocialPostsByUserId(String userId) => + '$postsBase/user/$userId'; + + // ============================================================================ + // MÉTHODES UTILITAIRES + // ============================================================================ + + /// Construit une URL avec des paramètres de requête + /// + /// [baseUrl] L'URL de base + /// [params] Les paramètres de requête (clé-valeur) + /// + /// **Exemple:** + /// ```dart + /// final url = Urls.buildUrlWithParams( + /// Urls.searchEvents, + /// {'keyword': 'concert', 'category': 'music'}, + /// ); + /// // Résultat: 'http://api.com/events/search?keyword=concert&category=music' + /// ``` + static String buildUrlWithParams(String baseUrl, Map params) { + if (params.isEmpty) return baseUrl; + + final queryString = params.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return '$baseUrl?$queryString'; + } + + /// Valide qu'une URL est bien formée + /// + /// [url] L'URL à valider + /// + /// Returns `true` si l'URL est valide, `false` sinon + static bool isValidUrl(String url) { + try { + final uri = Uri.parse(url); + return uri.hasScheme && uri.hasAuthority; + } catch (e) { + return false; + } + } } diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart index 0d44523..840fddb 100644 --- a/lib/data/models/user_model.dart +++ b/lib/data/models/user_model.dart @@ -1,45 +1,223 @@ +import '../../core/constants/env_config.dart'; import '../../domain/entities/user.dart'; -/// Modèle représentant l'utilisateur dans l'application AfterWork. -/// Ce modèle est utilisé pour la conversion JSON et l'interaction avec l'API. +/// Modèle de données pour les utilisateurs (Data Transfer Object). +/// +/// Cette classe est responsable de la sérialisation/désérialisation +/// avec l'API backend et convertit vers/depuis l'entité de domaine [User]. +/// +/// **Usage:** +/// ```dart +/// // Depuis JSON +/// final user = UserModel.fromJson(jsonData); +/// +/// // Vers JSON +/// final json = user.toJson(); +/// +/// // Vers entité de domaine +/// final entity = user.toEntity(); +/// ``` class UserModel extends User { - UserModel({ - required String userId, - required String userLastName, - required String userFirstName, - required String email, - required String motDePasse, - required String profileImageUrl, - }) : super( - userId: userId, - userLastName: userLastName, - userFirstName: userFirstName, - email: email, - motDePasse: motDePasse, - profileImageUrl: profileImageUrl, - ); + /// Crée une nouvelle instance de [UserModel]. + /// + /// [userId] L'identifiant unique de l'utilisateur + /// [userLastName] Le nom de famille de l'utilisateur + /// [userFirstName] Le prénom de l'utilisateur + /// [email] L'adresse email de l'utilisateur + /// [motDePasse] Le mot de passe (hashé côté serveur) + /// [profileImageUrl] L'URL de l'image de profil + /// [eventsCount] Le nombre d'événements créés (optionnel) + /// [friendsCount] Le nombre d'amis (optionnel) + /// [postsCount] Le nombre de posts (optionnel) + /// [visitedPlacesCount] Le nombre de lieux visités (optionnel) + const UserModel({ + required super.userId, + required super.userLastName, + required super.userFirstName, + required super.email, + required super.motDePasse, + required super.profileImageUrl, + super.eventsCount, + super.friendsCount, + super.postsCount, + super.visitedPlacesCount, + }); - /// Factory pour créer un `UserModel` à partir d'un JSON reçu depuis l'API. + /// Crée un [UserModel] à partir d'un JSON reçu depuis l'API. + /// + /// [json] Les données JSON à parser + /// + /// Returns un [UserModel] avec les données parsées + /// + /// **Note:** Les valeurs par défaut sont utilisées si des champs sont manquants. + /// + /// **Exemple:** + /// ```dart + /// final json = { + /// 'userId': '123', + /// 'nom': 'Doe', + /// 'prenoms': 'John', + /// 'email': 'john@example.com', + /// }; + /// final user = UserModel.fromJson(json); + /// ``` factory UserModel.fromJson(Map json) { - return UserModel( - userId: json['userId'] ?? '', - userLastName: json['nom'] ?? 'Inconnu', - userFirstName: json['prenoms'] ?? 'Inconnu', - email: json['email'] ?? 'inconnu@example.com', - motDePasse: json['motDePasse'] ?? '', - profileImageUrl: json['profileImageUrl'] ?? '', - ); + try { + // Le backend peut renvoyer 'uuid' ou 'userId', on accepte les deux + final userId = _parseString(json, 'userId', '') != '' + ? _parseString(json, 'userId', '') + : _parseString(json, 'uuid', ''); + + return UserModel( + userId: userId.isNotEmpty ? userId : (json['uuid']?.toString() ?? json['userId']?.toString() ?? ''), + userLastName: _parseString(json, 'nom', 'Inconnu'), + userFirstName: _parseString(json, 'prenoms', 'Inconnu'), + email: _parseString(json, 'email', ''), + motDePasse: _parseString(json, 'motDePasse', ''), + profileImageUrl: _parseString(json, 'profileImageUrl', ''), + eventsCount: _parseInt(json, 'eventsCount') ?? 0, + friendsCount: _parseInt(json, 'friendsCount') ?? 0, + postsCount: _parseInt(json, 'postsCount') ?? 0, + visitedPlacesCount: _parseInt(json, 'visitedPlacesCount') ?? 0, + ); + } catch (e) { + if (EnvConfig.enableDetailedLogs) { + print('[UserModel] Erreur lors du parsing JSON: $e'); + } + rethrow; + } } - /// Convertit le `UserModel` en JSON pour l'envoi vers l'API. + /// Parse une valeur string depuis le JSON avec valeur par défaut. + static String _parseString( + Map json, + String key, + String defaultValue, + ) { + final value = json[key]; + if (value == null) return defaultValue; + return value.toString(); + } + + /// Parse une valeur int depuis le JSON (optionnel). + static int? _parseInt(Map json, String key) { + final value = json[key]; + if (value == null) return null; + if (value is int) return value; + if (value is String) { + return int.tryParse(value); + } + return null; + } + + /// Convertit ce [UserModel] en JSON pour l'envoi vers l'API. + /// + /// Returns une [Map] contenant les données de l'utilisateur + /// + /// **Note:** Le mot de passe est envoyé en clair (hashé côté serveur). + /// + /// **Exemple:** + /// ```dart + /// final user = UserModel(...); + /// final json = user.toJson(); + /// // Envoyer json à l'API + /// ``` + @override Map toJson() { - return { - 'id': userId, + final json = { + if (userId.isNotEmpty) 'id': userId, 'nom': userLastName, 'prenoms': userFirstName, 'email': email, - 'motDePasse': motDePasse, // Mot de passe en clair (comme demandé temporairement) - 'profileImageUrl':profileImageUrl, + if (motDePasse.isNotEmpty) 'motDePasse': motDePasse, + if (profileImageUrl.isNotEmpty) 'profileImageUrl': profileImageUrl, }; + + // Ajouter les compteurs optionnels s'ils sont présents + if (eventsCount != null) { + json['eventsCount'] = eventsCount; + } + if (friendsCount != null) { + json['friendsCount'] = friendsCount; + } + if (postsCount != null) { + json['postsCount'] = postsCount; + } + if (visitedPlacesCount != null) { + json['visitedPlacesCount'] = visitedPlacesCount; + } + + return json; + } + + /// Convertit ce modèle vers une entité de domaine [User]. + /// + /// Returns une instance de [User] avec les mêmes données + /// + /// **Exemple:** + /// ```dart + /// final model = UserModel.fromJson(json); + /// final entity = model.toEntity(); + /// ``` + User toEntity() { + return User( + userId: userId, + userLastName: userLastName, + userFirstName: userFirstName, + email: email, + motDePasse: motDePasse, + profileImageUrl: profileImageUrl, + eventsCount: eventsCount, + friendsCount: friendsCount, + postsCount: postsCount, + visitedPlacesCount: visitedPlacesCount, + ); + } + + /// Crée une copie de ce [UserModel] avec des valeurs modifiées. + /// + /// Tous les paramètres sont optionnels. Seuls les paramètres fournis + /// seront modifiés dans la nouvelle instance. + /// + /// **Exemple:** + /// ```dart + /// final updated = user.copyWith( + /// userFirstName: 'Jane', + /// profileImageUrl: 'https://example.com/new-image.jpg', + /// ); + /// ``` + UserModel copyWith({ + String? userId, + String? userLastName, + String? userFirstName, + String? email, + String? motDePasse, + String? profileImageUrl, + int? eventsCount, + int? friendsCount, + int? postsCount, + int? visitedPlacesCount, + }) { + return UserModel( + userId: userId ?? this.userId, + userLastName: userLastName ?? this.userLastName, + userFirstName: userFirstName ?? this.userFirstName, + email: email ?? this.email, + motDePasse: motDePasse ?? this.motDePasse, + profileImageUrl: profileImageUrl ?? this.profileImageUrl, + eventsCount: eventsCount ?? this.eventsCount, + friendsCount: friendsCount ?? this.friendsCount, + postsCount: postsCount ?? this.postsCount, + visitedPlacesCount: visitedPlacesCount ?? this.visitedPlacesCount, + ); + } + + @override + String toString() { + return 'UserModel(' + 'userId: $userId, ' + 'name: $userFirstName $userLastName, ' + 'email: $email' + ')'; } } diff --git a/lib/data/providers/friends_provider.dart b/lib/data/providers/friends_provider.dart index eb85b7f..8001d4c 100644 --- a/lib/data/providers/friends_provider.dart +++ b/lib/data/providers/friends_provider.dart @@ -1,13 +1,19 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; -import '../../domain/entities/friend.dart'; + import '../../data/repositories/friends_repository_impl.dart'; +import '../../data/services/secure_storage.dart'; +import '../../domain/entities/friend.dart'; +import '../../domain/entities/friend_request.dart'; /// [FriendsProvider] est un `ChangeNotifier` qui gère la logique de gestion des amis. /// Il interagit avec le [FriendsRepositoryImpl] pour effectuer des appels API et gérer /// la liste des amis de l'utilisateur, avec une gestion avancée de la pagination, /// du statut des amis et de la gestion des erreurs. -class FriendsProvider with ChangeNotifier { +class FriendsProvider with ChangeNotifier { // Nombre d'amis à récupérer par page + + /// Constructeur de [FriendsProvider] qui nécessite l'instance d'un [FriendsRepositoryImpl]. + FriendsProvider({required this.friendsRepository}); final FriendsRepositoryImpl friendsRepository; final Logger _logger = Logger(); // Utilisation du logger pour une traçabilité complète des actions. @@ -16,15 +22,32 @@ class FriendsProvider with ChangeNotifier { bool _isLoading = false; // Indicateur de chargement bool _hasMore = true; // Indicateur de pagination int _currentPage = 0; // Numéro de la page actuelle pour la pagination - final int _friendsPerPage = 10; // Nombre d'amis à récupérer par page + final int _friendsPerPage = 10; - /// Constructeur de [FriendsProvider] qui nécessite l'instance d'un [FriendsRepositoryImpl]. - FriendsProvider({required this.friendsRepository}); + // Liste des demandes d'amitié envoyées + List _sentRequests = []; + bool _isLoadingSentRequests = false; + int _currentSentRequestPage = 0; + + // Liste des demandes d'amitié reçues + List _receivedRequests = []; + bool _isLoadingReceivedRequests = false; + int _currentReceivedRequestPage = 0; + + final int _requestsPerPage = 10; // Getters pour accéder à l'état actuel des données bool get isLoading => _isLoading; bool get hasMore => _hasMore; List get friendsList => _friendsList; + List get sentRequests => _sentRequests; + List get receivedRequests => _receivedRequests; + bool get isLoadingSentRequests => _isLoadingSentRequests; + bool get isLoadingReceivedRequests => _isLoadingReceivedRequests; + + // Pour compatibilité avec l'ancien code + List get pendingRequests => _receivedRequests; + bool get isLoadingRequests => _isLoadingReceivedRequests; /// Récupère la liste des amis pour un utilisateur donné avec pagination. /// @@ -63,10 +86,10 @@ class FriendsProvider with ChangeNotifier { _logger.i('[LOG] Plus d\'amis à charger.'); } else { // Ajout des amis à la liste, en excluant l'utilisateur connecté - for (var friend in newFriends) { + for (final friend in newFriends) { if (friend.friendId != userId) { _friendsList.add(friend); - _logger.i("[LOG] Ami ajouté : ID = ${friend.friendId}, Nom = ${friend.friendFirstName} ${friend.friendLastName}"); + _logger.i('[LOG] Ami ajouté : ID = ${friend.friendId}, Nom = ${friend.friendFirstName} ${friend.friendLastName}'); } else { _logger.w("[WARN] L'utilisateur connecté est exclu de la liste des amis : ${friend.friendId}"); } @@ -171,4 +194,192 @@ class FriendsProvider with ChangeNotifier { notifyListeners(); } } + + /// Ajoute un nouvel ami. + /// + /// [friendId] : L'identifiant unique de l'ami à ajouter. + /// + /// Cette méthode : + /// - Loggue chaque étape. + /// - Envoie la demande d'ami via l'API. + /// - Rafraîchit la liste des amis si l'ajout réussit. + Future addFriend(String friendId) async { + try { + // Récupérer le userId de l'utilisateur actuel + final currentUserId = await _getCurrentUserId(); + if (currentUserId == null || currentUserId.isEmpty) { + throw Exception('Utilisateur non connecté'); + } + + _logger.i('[LOG] Ajout de l\'ami: userId=$currentUserId, friendId=$friendId'); + await friendsRepository.addFriend(currentUserId, friendId); + _logger.i('[LOG] Demande d\'ami envoyée avec succès'); + + // Rafraîchir la liste des amis après l'ajout + // Note: L'ami ne sera visible qu'après acceptation de la demande + } catch (e) { + _logger.e('[ERROR] Erreur lors de l\'ajout de l\'ami : $e'); + rethrow; // Propager l'erreur pour que l'UI puisse l'afficher + } finally { + notifyListeners(); + } + } + + /// Récupère l'ID de l'utilisateur actuel depuis le stockage sécurisé + Future _getCurrentUserId() async { + try { + final secureStorage = SecureStorage(); + return await secureStorage.getUserId(); + } catch (e) { + _logger.e('[ERROR] Erreur lors de la récupération de l\'userId : $e'); + return null; + } + } + + /// Récupère les demandes d'amitié en attente pour l'utilisateur actuel (compatibilité). + Future fetchPendingRequests({bool loadMore = false}) async { + await fetchReceivedRequests(loadMore: loadMore); + } + + /// Récupère les demandes d'amitié envoyées par l'utilisateur actuel. + Future fetchSentRequests({bool loadMore = false}) async { + try { + final currentUserId = await _getCurrentUserId(); + if (currentUserId == null || currentUserId.isEmpty) { + throw Exception('Utilisateur non connecté'); + } + + if (!loadMore) { + _currentSentRequestPage = 0; + _sentRequests = []; + } + + _isLoadingSentRequests = true; + notifyListeners(); + + final page = loadMore ? _currentSentRequestPage + 1 : 0; + final requests = await friendsRepository.getSentFriendRequests( + currentUserId, + page, + _requestsPerPage, + ); + + if (loadMore) { + _sentRequests.addAll(requests); + _currentSentRequestPage = page; + } else { + _sentRequests = requests; + _currentSentRequestPage = 0; + } + + _logger.i('[LOG] ${requests.length} demandes d\'amitié envoyées récupérées'); + } catch (e) { + _logger.e('[ERROR] Erreur lors de la récupération des demandes envoyées : $e'); + rethrow; + } finally { + _isLoadingSentRequests = false; + notifyListeners(); + } + } + + /// Récupère les demandes d'amitié reçues par l'utilisateur actuel. + Future fetchReceivedRequests({bool loadMore = false}) async { + try { + final currentUserId = await _getCurrentUserId(); + if (currentUserId == null || currentUserId.isEmpty) { + throw Exception('Utilisateur non connecté'); + } + + if (!loadMore) { + _currentReceivedRequestPage = 0; + _receivedRequests = []; + } + + _isLoadingReceivedRequests = true; + notifyListeners(); + + final page = loadMore ? _currentReceivedRequestPage + 1 : 0; + final requests = await friendsRepository.getReceivedFriendRequests( + currentUserId, + page, + _requestsPerPage, + ); + + if (loadMore) { + _receivedRequests.addAll(requests); + _currentReceivedRequestPage = page; + } else { + _receivedRequests = requests; + _currentReceivedRequestPage = 0; + } + + _logger.i('[LOG] ${requests.length} demandes d\'amitié reçues récupérées'); + } catch (e) { + _logger.e('[ERROR] Erreur lors de la récupération des demandes reçues : $e'); + rethrow; + } finally { + _isLoadingReceivedRequests = false; + notifyListeners(); + } + } + + /// Accepte une demande d'amitié. + Future acceptFriendRequest(String friendshipId) async { + try { + _logger.i('[LOG] Acceptation de la demande d\'amitié: $friendshipId'); + await friendsRepository.acceptFriendRequest(friendshipId); + + // Retirer la demande de la liste des demandes reçues + _receivedRequests.removeWhere((req) => req.friendshipId == friendshipId); + + // Rafraîchir la liste des amis + final currentUserId = await _getCurrentUserId(); + if (currentUserId != null) { + await fetchFriends(currentUserId); + } + + _logger.i('[LOG] Demande d\'amitié acceptée avec succès'); + } catch (e) { + _logger.e('[ERROR] Erreur lors de l\'acceptation de la demande : $e'); + rethrow; + } finally { + notifyListeners(); + } + } + + /// Rejette une demande d'amitié. + Future rejectFriendRequest(String friendshipId) async { + try { + _logger.i('[LOG] Rejet de la demande d\'amitié: $friendshipId'); + await friendsRepository.rejectFriendRequest(friendshipId); + + // Retirer la demande de la liste des demandes reçues + _receivedRequests.removeWhere((req) => req.friendshipId == friendshipId); + + _logger.i('[LOG] Demande d\'amitié rejetée avec succès'); + } catch (e) { + _logger.e('[ERROR] Erreur lors du rejet de la demande : $e'); + rethrow; + } finally { + notifyListeners(); + } + } + + /// Annule une demande d'amitié envoyée. + Future cancelFriendRequest(String friendshipId) async { + try { + _logger.i('[LOG] Annulation de la demande d\'amitié: $friendshipId'); + await friendsRepository.cancelFriendRequest(friendshipId); + + // Retirer la demande de la liste des demandes envoyées + _sentRequests.removeWhere((req) => req.friendshipId == friendshipId); + + _logger.i('[LOG] Demande d\'amitié annulée avec succès'); + } catch (e) { + _logger.e('[ERROR] Erreur lors de l\'annulation de la demande : $e'); + rethrow; + } finally { + notifyListeners(); + } + } } diff --git a/lib/data/repositories/friends_repository.dart b/lib/data/repositories/friends_repository.dart index baa8ed6..d7eeea1 100644 --- a/lib/data/repositories/friends_repository.dart +++ b/lib/data/repositories/friends_repository.dart @@ -1,4 +1,5 @@ -import 'package:afterwork/domain/entities/friend.dart'; +import '../../domain/entities/friend.dart'; +import '../../domain/entities/friend_request.dart'; /// Interface [FriendsRepository] définissant les méthodes pour gérer les amis. /// Cette interface permet de séparer la logique métier des appels API et de la gestion des données. @@ -17,10 +18,11 @@ abstract class FriendsRepository { /// Envoie une demande pour ajouter un nouvel ami via l'API. /// - /// [friend] : Objet [Friend] représentant l'ami à ajouter. + /// [userId] : Identifiant unique de l'utilisateur qui envoie la demande. + /// [friendId] : Identifiant unique de l'ami à ajouter. /// /// Retourne un `Future`. En cas d'erreur, l'implémentation peut lancer une exception. - Future addFriend(Friend friend); + Future addFriend(String userId, String friendId); /// Supprime un ami existant via l'API. /// @@ -45,4 +47,52 @@ abstract class FriendsRepository { /// /// Retourne un `Future`. En cas d'erreur, l'implémentation peut lancer une exception. Future updateFriendStatus(String friendId, String status); + + /// Récupère les demandes d'amitié en attente pour un utilisateur. + /// + /// [userId] : Identifiant unique de l'utilisateur. + /// [page] : Numéro de la page pour la pagination. + /// [size] : Nombre d'éléments par page. + /// + /// Retourne une liste de demandes d'amitié en attente. + Future> getPendingFriendRequests(String userId, int page, int size); + + /// Récupère les demandes d'amitié envoyées par un utilisateur. + /// + /// [userId] : Identifiant unique de l'utilisateur. + /// [page] : Numéro de la page pour la pagination. + /// [size] : Nombre d'éléments par page. + /// + /// Retourne une liste de demandes d'amitié envoyées. + Future> getSentFriendRequests(String userId, int page, int size); + + /// Récupère les demandes d'amitié reçues par un utilisateur. + /// + /// [userId] : Identifiant unique de l'utilisateur. + /// [page] : Numéro de la page pour la pagination. + /// [size] : Nombre d'éléments par page. + /// + /// Retourne une liste de demandes d'amitié reçues. + Future> getReceivedFriendRequests(String userId, int page, int size); + + /// Accepte une demande d'amitié. + /// + /// [friendshipId] : Identifiant unique de la relation d'amitié. + /// + /// Retourne un `Future`. En cas d'erreur, l'implémentation peut lancer une exception. + Future acceptFriendRequest(String friendshipId); + + /// Rejette une demande d'amitié. + /// + /// [friendshipId] : Identifiant unique de la relation d'amitié. + /// + /// Retourne un `Future`. En cas d'erreur, l'implémentation peut lancer une exception. + Future rejectFriendRequest(String friendshipId); + + /// Annule une demande d'amitié envoyée (supprime la relation). + /// + /// [friendshipId] : Identifiant unique de la relation d'amitié. + /// + /// Retourne un `Future`. En cas d'erreur, l'implémentation peut lancer une exception. + Future cancelFriendRequest(String friendshipId); } diff --git a/lib/data/repositories/friends_repository_impl.dart b/lib/data/repositories/friends_repository_impl.dart index d1b2440..41310ee 100644 --- a/lib/data/repositories/friends_repository_impl.dart +++ b/lib/data/repositories/friends_repository_impl.dart @@ -1,178 +1,685 @@ import 'dart:convert'; -import 'package:flutter/cupertino.dart'; +import 'dart:io'; + import 'package:http/http.dart' as http; -import 'package:logger/logger.dart'; + +import '../../core/constants/env_config.dart'; import '../../core/constants/urls.dart'; +import '../../core/errors/exceptions.dart'; import '../../domain/entities/friend.dart'; +import '../../domain/entities/friend_request.dart'; import 'friends_repository.dart'; /// Implémentation de [FriendsRepository] pour gérer les appels API relatifs aux amis. -/// Chaque action est loguée pour une traçabilité complète et une gestion des erreurs avancée. +/// +/// Cette classe gère toutes les opérations sur les amis via l'API backend, +/// avec gestion d'erreurs robuste, timeouts, et validation des réponses. +/// +/// **Usage:** +/// ```dart +/// final repository = FriendsRepositoryImpl(client: http.Client()); +/// final friends = await repository.fetchFriends('user123', 0, 20); +/// ``` class FriendsRepositoryImpl implements FriendsRepository { - final http.Client client; - final Logger _logger = Logger(); // Logger pour suivre toutes les actions. - + /// Crée une nouvelle instance de [FriendsRepositoryImpl]. + /// + /// [client] Le client HTTP à utiliser pour les requêtes FriendsRepositoryImpl({required this.client}); - /// Récupère la liste paginée des amis pour un utilisateur donné via l'API. - /// - /// [userId] : Identifiant unique de l'utilisateur. - /// [page] : Page actuelle pour la pagination. - /// [size] : Nombre d'amis par page. - /// - /// Retourne une liste d'objets [Friend] ou une liste vide en cas d'erreur. - @override - Future> fetchFriends(String userId, int page, int size) async { + /// Client HTTP pour effectuer les requêtes réseau + final http.Client client; + + /// Headers par défaut pour les requêtes + static const Map _defaultHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + /// Timeout pour les requêtes réseau + Duration get _timeout => Duration(seconds: EnvConfig.networkTimeout); + + // ============================================================================ + // MÉTHODES PRIVÉES UTILITAIRES + // ============================================================================ + + /// Effectue une requête HTTP avec gestion d'erreurs et timeout. + Future _performRequest( + String method, + Uri uri, { + Map? headers, + Object? body, + }) async { try { - _logger.i("[LOG] Chargement des amis pour l'utilisateur : $userId, page : $page, taille : $size"); - - final uri = Uri.parse('${Urls.baseUrl}/friends/list/$userId?page=$page&size=$size'); - _logger.d('[LOG] URL appelée : $uri'); - - final response = await client.get(uri); - - if (response.statusCode == 200) { - _logger.i("[LOG] Liste des amis récupérée avec succès."); - final List friendsJson = json.decode(response.body); - _logger.i("[LOG] Nombre d'amis récupérés (excluant l'utilisateur lui-même) : ${friendsJson.length}"); - - return friendsJson.map((json) { - _logger.i("[LOG] Conversion JSON -> Friend : $json"); - final friend = Friend.fromJson(json as Map); - _logger.i("[LOG] Création d'un objet Friend : ID = ${friend.friendId}, Nom = ${friend.friendFirstName} ${friend.friendLastName}"); - return friend; - }).toList(); - } else { - _logger.e("[ERROR] Échec de la récupération des amis. Code HTTP : ${response.statusCode}"); - return []; - } - } catch (e) { - _logger.e("[ERROR] Exception lors de la récupération des amis : $e"); - return []; - } - } - - /// Envoie une demande pour ajouter un nouvel ami via l'API. - /// - /// [friend] : Objet [Friend] représentant l'ami à ajouter. - /// - /// Loggue chaque étape et lève une exception en cas d'erreur. - @override - Future addFriend(Friend friend) async { - try { - _logger.i("[LOG] Tentative d'ajout de l'ami : ${friend.friendFirstName} ${friend.friendLastName}"); - - final uri = Uri.parse('${Urls.baseUrl}/friends/send'); - final response = await client.post( - uri, - headers: {'Content-Type': 'application/json'}, - body: json.encode(friend.toJson()), - ); - - if (response.statusCode == 200) { - _logger.i("[LOG] Ami ajouté avec succès : ${friend.friendId}"); - } else { - _logger.e("[ERROR] Échec lors de l'ajout de l'ami. Code HTTP : ${response.statusCode}"); - throw Exception("Erreur lors de l'ajout de l'ami"); - } - } catch (e) { - _logger.e("[ERROR] Exception lors de l'ajout de l'ami : $e"); - rethrow; - } - } - - /// Supprime un ami existant via l'API. - /// - /// [friendId] : Identifiant unique de l'ami à supprimer. - /// - /// Loggue l'action et lève une exception en cas d'erreur. - @override - Future removeFriend(String friendId) async { - try { - _logger.i("[LOG] Tentative de suppression de l'ami avec l'ID : $friendId"); - - final uri = Uri.parse('${Urls.baseUrl}/friends/$friendId'); - final response = await client.delete(uri); - - if (response.statusCode == 200) { - _logger.i("[LOG] Ami supprimé avec succès : $friendId"); - } else { - _logger.e("[ERROR] Échec lors de la suppression de l'ami. Code HTTP : ${response.statusCode}"); - throw Exception("Erreur lors de la suppression de l'ami"); - } - } catch (e) { - _logger.e("[ERROR] Exception lors de la suppression de l'ami : $e"); - rethrow; - } - } - - /// Récupère les détails d'un ami en utilisant son identifiant `friendId`. - /// - /// [friendId] : Identifiant unique de l'ami. - /// [userId] : Identifiant unique de l'utilisateur connecté. - /// - /// Retourne un `Future` avec les informations de l'ami ou `null` en cas d'échec. - @override - Future getFriendDetails(String friendId, String userId) async { - try { - _logger.i("[LOG] Récupération des détails de l'ami avec ID : $friendId pour l'utilisateur : $userId"); - - final uri = Uri.parse('${Urls.baseUrl}/friends/details'); - _logger.d("[LOG] URL pour les détails de l'ami : $uri"); - - final headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', + final requestHeaders = { + ..._defaultHeaders, + if (headers != null) ...headers, }; + http.Response response; + + switch (method.toUpperCase()) { + case 'GET': + response = await client + .get(uri, headers: requestHeaders) + .timeout(_timeout); + break; + case 'POST': + response = await client + .post(uri, headers: requestHeaders, body: body) + .timeout(_timeout); + break; + case 'DELETE': + response = await client + .delete(uri, headers: requestHeaders) + .timeout(_timeout); + break; + case 'PATCH': + response = await client + .patch(uri, headers: requestHeaders, body: body) + .timeout(_timeout); + break; + default: + throw ArgumentError('Méthode HTTP non supportée: $method'); + } + + return response; + } on SocketException { + throw ServerException( + 'Erreur de connexion réseau. Vérifiez votre connexion Internet.', + statusCode: null, + ); + } on HttpException catch (e) { + throw ServerException( + 'Erreur HTTP: ${e.message}', + statusCode: null, + ); + } on FormatException catch (e) { + throw ServerException( + 'Erreur de format de réponse: ${e.message}', + statusCode: null, + ); + } catch (e) { + if (e is ServerException) rethrow; + throw ServerException( + 'Erreur inattendue: $e', + statusCode: null, + originalError: e, + ); + } + } + + /// Parse une réponse JSON et gère les erreurs. + dynamic _parseJsonResponse( + http.Response response, + List expectedStatusCodes, + ) { + if (!expectedStatusCodes.contains(response.statusCode)) { + _handleErrorResponse(response); + } + + try { + if (response.body.isEmpty) { + return null; + } + return json.decode(response.body); + } on FormatException catch (e) { + throw ServerException( + 'Erreur de parsing JSON: ${e.message}', + statusCode: response.statusCode, + ); + } + } + + /// Gère les erreurs de réponse HTTP. + void _handleErrorResponse(http.Response response) { + String errorMessage; + + try { + if (response.body.isNotEmpty) { + final errorBody = json.decode(response.body); + + // Gérer le format Bean Validation (Quarkus) + if (errorBody is Map && errorBody.containsKey('attributeName')) { + final attributeName = errorBody['attributeName'] as String? ?? 'champ'; + final value = errorBody['value'] as String?; + final objectName = errorBody['objectName'] as String? ?? ''; + + // Construire un message d'erreur plus clair + if (attributeName == 'friendId' && value != null) { + errorMessage = 'L\'identifiant "$value" n\'est pas valide. Veuillez utiliser l\'ID utilisateur (UUID) et non l\'email.'; + } else { + errorMessage = 'Erreur de validation sur le champ "$attributeName"'; + if (value != null) { + errorMessage += ': valeur "$value" invalide'; + } + } + } else { + // Essayer plusieurs formats de réponse d'erreur standard + errorMessage = errorBody['message'] as String? ?? + errorBody['error'] as String? ?? + errorBody['errorMessage'] as String? ?? + (errorBody is Map && errorBody.isNotEmpty + ? errorBody.values.first.toString() + : 'Erreur serveur inconnue'); + } + + // Log détaillé pour le débogage + if (EnvConfig.enableDetailedLogs) { + _log('Réponse d\'erreur du serveur (${response.statusCode}): ${response.body}'); + _log('Message d\'erreur extrait: $errorMessage'); + } + } else { + errorMessage = 'Erreur serveur (${response.statusCode})'; + } + } catch (e) { + // Si le parsing JSON échoue, utiliser le body brut + errorMessage = response.body.isNotEmpty + ? response.body + : 'Erreur serveur (${response.statusCode})'; + + if (EnvConfig.enableDetailedLogs) { + _log('Erreur lors du parsing de la réponse d\'erreur: $e'); + _log('Body brut: ${response.body}'); + } + } + + switch (response.statusCode) { + case 400: + throw ValidationException(errorMessage); + case 401: + throw UnauthorizedException(errorMessage); + case 404: + throw ServerException( + 'Ressource non trouvée: $errorMessage', + statusCode: 404, + ); + case 409: + throw ConflictException(errorMessage); + case 500: + case 502: + case 503: + throw ServerException( + 'Erreur serveur: $errorMessage', + statusCode: response.statusCode, + ); + default: + throw ServerException( + errorMessage, + statusCode: response.statusCode, + ); + } + } + + /// Vérifie si une chaîne est un UUID valide + bool _isValidUUID(String value) { + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + return uuidRegex.hasMatch(value); + } + + /// Log un message si le mode debug est activé. + void _log(String message) { + if (EnvConfig.enableDetailedLogs) { + print('[FriendsRepositoryImpl] $message'); + } + } + + // ============================================================================ + // MÉTHODES PUBLIQUES + // ============================================================================ + + /// Récupère la liste paginée des amis pour un utilisateur donné. + /// + /// [userId] L'identifiant unique de l'utilisateur + /// [page] Le numéro de page (commence à 0) + /// [size] Le nombre d'amis par page + /// + /// Returns une liste d'objets [Friend] + /// + /// Throws [ServerException] en cas d'erreur + /// + /// **Exemple:** + /// ```dart + /// final friends = await repository.fetchFriends('user123', 0, 20); + /// ``` + @override + Future> fetchFriends(String userId, int page, int size) async { + _log('Récupération des amis pour l\'utilisateur $userId (page $page, taille $size)'); + + if (userId.isEmpty) { + throw ValidationException('L\'ID utilisateur ne peut pas être vide'); + } + + if (page < 0) { + throw ValidationException('Le numéro de page doit être >= 0'); + } + + if (size <= 0) { + throw ValidationException('La taille de page doit être > 0'); + } + + try { + final uri = Uri.parse('${Urls.friendsBase}/list/$userId') + .replace(queryParameters: { + 'page': page.toString(), + 'size': size.toString(), + }); + + final response = await _performRequest('GET', uri); + + // Gérer le cas 404 comme une liste vide + if (response.statusCode == 404) { + _log('Aucun ami trouvé (404) - retour d\'une liste vide'); + return []; + } + + final jsonResponse = _parseJsonResponse(response, [200]) as List?; + + if (jsonResponse == null) { + return []; + } + + final friends = jsonResponse + .map((json) => Friend.fromJson(json as Map)) + .toList(); + + _log('${friends.length} amis récupérés avec succès'); + return friends; + } catch (e) { + _log('Erreur lors de la récupération des amis: $e'); + rethrow; + } + } + + /// Envoie une demande pour ajouter un nouvel ami. + /// + /// [userId] L'identifiant unique de l'utilisateur qui envoie la demande + /// [friendId] L'identifiant unique de l'ami à ajouter + /// + /// Throws [ServerException] en cas d'erreur + /// + /// **Exemple:** + /// ```dart + /// await repository.addFriend('user-uuid', 'friend-uuid'); + /// ``` + @override + Future addFriend(String userId, String friendId) async { + _log('Ajout de l\'ami: userId=$userId, friendId=$friendId'); + + if (userId.isEmpty) { + throw ValidationException('L\'ID de l\'utilisateur ne peut pas être vide'); + } + + if (friendId.isEmpty) { + throw ValidationException('L\'ID de l\'ami ne peut pas être vide'); + } + + try { + final uri = Uri.parse('${Urls.friendsBase}/send'); + + // Le backend attend userId et friendId dans FriendshipCreateOneRequestDTO + final bodyJson = { + 'userId': userId, + 'friendId': friendId, + }; + final body = jsonEncode(bodyJson); + + // Log détaillé du body envoyé + if (EnvConfig.enableDetailedLogs) { + _log('Envoi de la demande d\'ami à: $uri'); + _log('Body JSON: $body'); + } + + final response = await _performRequest( + 'POST', + uri, + body: body, + ); + + // Log de la réponse + if (EnvConfig.enableDetailedLogs) { + _log('Réponse du serveur (${response.statusCode}): ${response.body}'); + } + + if (![200, 201].contains(response.statusCode)) { + _handleErrorResponse(response); + } + + _log('Ami ajouté avec succès: $friendId'); + } catch (e) { + _log('Erreur lors de l\'ajout de l\'ami: $e'); + rethrow; + } + } + + /// Supprime un ami existant. + /// + /// [friendId] L'identifiant unique de l'ami à supprimer + /// + /// Throws [ServerException] en cas d'erreur + /// + /// **Exemple:** + /// ```dart + /// await repository.removeFriend('friend123'); + /// ``` + @override + Future removeFriend(String friendId) async { + _log('Suppression de l\'ami $friendId'); + + if (friendId.isEmpty) { + throw ValidationException('L\'ID de l\'ami ne peut pas être vide'); + } + + try { + final uri = Uri.parse('${Urls.friendsBase}/$friendId'); + final response = await _performRequest('DELETE', uri); + + if (![200, 204].contains(response.statusCode)) { + _handleErrorResponse(response); + } + + _log('Ami $friendId supprimé avec succès'); + } catch (e) { + _log('Erreur lors de la suppression de l\'ami $friendId: $e'); + rethrow; + } + } + + /// Récupère les détails d'un ami. + /// + /// [friendId] L'identifiant unique de l'ami + /// [userId] L'identifiant unique de l'utilisateur connecté + /// + /// Returns un [Friend] avec les informations de l'ami, ou `null` si non trouvé + /// + /// **Exemple:** + /// ```dart + /// final friend = await repository.getFriendDetails('friend123', 'user123'); + /// ``` + @override + Future getFriendDetails(String friendId, String userId) async { + _log('Récupération des détails de l\'ami $friendId pour l\'utilisateur $userId'); + + if (friendId.isEmpty || userId.isEmpty) { + throw ValidationException('Les IDs ne peuvent pas être vides'); + } + + try { + final uri = Uri.parse('${Urls.friendsBase}/details'); final body = jsonEncode({ 'friendId': friendId, 'userId': userId, }); - final response = await client.post(uri, headers: headers, body: body); - _logger.d("[LOG] Réponse de l'API : ${response.body}"); + final response = await _performRequest( + 'POST', + uri, + body: body, + ); - if (response.statusCode == 200) { - final friendJson = json.decode(response.body); - _logger.i("[LOG] Détails de l'ami récupérés : $friendJson"); - return Friend.fromJson(friendJson); - } else { - _logger.e("[ERROR] Échec de la récupération des détails. Code HTTP : ${response.statusCode}"); + // Gérer le cas 404 comme null + if (response.statusCode == 404) { + _log('Ami $friendId non trouvé (404)'); return null; } + + final jsonResponse = _parseJsonResponse(response, [200]) as Map?; + + if (jsonResponse == null) { + return null; + } + + final friend = Friend.fromJson(jsonResponse); + _log('Détails de l\'ami $friendId récupérés avec succès'); + return friend; } catch (e) { - _logger.e("[ERROR] Exception lors de la récupération des détails de l'ami : $e"); - return null; + _log('Erreur lors de la récupération des détails de l\'ami $friendId: $e'); + rethrow; } } /// Met à jour le statut d'un ami (par exemple, "accepté", "bloqué"). /// - /// [friendId] : Identifiant unique de l'ami. - /// [status] : Nouveau statut sous forme de chaîne de caractères. + /// [friendId] L'identifiant unique de l'ami + /// [status] Le nouveau statut sous forme de chaîne de caractères /// - /// Loggue chaque étape et lève une exception en cas d'échec. + /// Throws [ServerException] en cas d'erreur + /// + /// **Exemple:** + /// ```dart + /// await repository.updateFriendStatus('friend123', 'accepted'); + /// ``` @override Future updateFriendStatus(String friendId, String status) async { - try { - _logger.i("[LOG] Mise à jour du statut de l'ami avec l'ID : $friendId, nouveau statut : $status"); + _log('Mise à jour du statut de l\'ami $friendId: $status'); - final uri = Uri.parse('${Urls.baseUrl}/friends/$friendId/status'); - final response = await client.patch( + if (friendId.isEmpty) { + throw ValidationException('L\'ID de l\'ami ne peut pas être vide'); + } + + if (status.isEmpty) { + throw ValidationException('Le statut ne peut pas être vide'); + } + + try { + final uri = Uri.parse('${Urls.friendsBase}/$friendId/status'); + final body = jsonEncode({'status': status}); + + final response = await _performRequest( + 'PATCH', uri, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'status': status}), + body: body, ); - if (response.statusCode == 200) { - _logger.i("[LOG] Statut de l'ami mis à jour avec succès : $friendId"); - } else { - _logger.e("[ERROR] Erreur lors de la mise à jour du statut. Code HTTP : ${response.statusCode}"); - throw Exception("Erreur lors de la mise à jour du statut"); + if (response.statusCode != 200) { + _handleErrorResponse(response); } + + _log('Statut de l\'ami $friendId mis à jour avec succès'); } catch (e) { - _logger.e("[ERROR] Exception lors de la mise à jour du statut de l'ami : $e"); + _log('Erreur lors de la mise à jour du statut de l\'ami $friendId: $e'); + rethrow; + } + } + + /// Récupère les demandes d'amitié en attente pour un utilisateur. + /// + /// [userId] L'identifiant unique de l'utilisateur + /// [page] Le numéro de la page pour la pagination + /// [size] La taille de la page + /// + /// Retourne une liste de [FriendRequest] + @override + Future> getPendingFriendRequests(String userId, int page, int size) async { + _log('Récupération des demandes d\'amitié en attente pour l\'utilisateur $userId (page $page, taille $size)'); + + try { + final uri = Uri.parse(Urls.getPendingFriendRequestsWithUserId(userId, page: page, size: size)); + final response = await _performRequest('GET', uri); + + if (response.statusCode != 200) { + _handleErrorResponse(response); + } + + final jsonResponse = _parseJsonResponse(response, [200]) as List; + final requests = jsonResponse + .map((json) => FriendRequest.fromJson(json as Map)) + .toList(); + + _log('${requests.length} demandes d\'amitié en attente récupérées avec succès'); + return requests; + } catch (e) { + _log('Erreur lors de la récupération des demandes d\'amitié en attente: $e'); + rethrow; + } + } + + /// Accepte une demande d'amitié. + /// + /// [friendshipId] L'identifiant unique de la relation d'amitié + /// + /// Throws [ServerException] en cas d'erreur + @override + Future acceptFriendRequest(String friendshipId) async { + _log('Acceptation de la demande d\'amitié: $friendshipId'); + + if (friendshipId.isEmpty) { + throw ValidationException('L\'ID de la relation d\'amitié ne peut pas être vide'); + } + + try { + final uri = Uri.parse(Urls.acceptFriendRequestWithId(friendshipId)); + final response = await _performRequest('PATCH', uri); + + if (response.statusCode != 200) { + _handleErrorResponse(response); + } + + _log('Demande d\'amitié acceptée avec succès: $friendshipId'); + } catch (e) { + _log('Erreur lors de l\'acceptation de la demande d\'amitié: $e'); + rethrow; + } + } + + /// Rejette une demande d'amitié. + /// + /// [friendshipId] L'identifiant unique de la relation d'amitié + /// + /// Throws [ServerException] en cas d'erreur + @override + Future rejectFriendRequest(String friendshipId) async { + _log('Rejet de la demande d\'amitié: $friendshipId'); + + if (friendshipId.isEmpty) { + throw ValidationException('L\'ID de la relation d\'amitié ne peut pas être vide'); + } + + try { + final uri = Uri.parse(Urls.rejectFriendRequestWithId(friendshipId)); + final response = await _performRequest('PATCH', uri); + + if (response.statusCode != 204) { + _handleErrorResponse(response); + } + + _log('Demande d\'amitié rejetée avec succès: $friendshipId'); + } catch (e) { + _log('Erreur lors du rejet de la demande d\'amitié: $e'); + rethrow; + } + } + + /// Récupère les demandes d'amitié envoyées par un utilisateur. + /// + /// [userId] L'identifiant unique de l'utilisateur + /// [page] Le numéro de page (commence à 0) + /// [size] Le nombre de demandes par page + /// + /// Returns une liste d'objets [FriendRequest] + /// + /// Throws [ServerException] en cas d'erreur + @override + Future> getSentFriendRequests(String userId, int page, int size) async { + _log('Récupération des demandes d\'amitié envoyées pour l\'utilisateur $userId (page $page, taille $size)'); + + try { + final uri = Uri.parse(Urls.getSentFriendRequestsWithUserId(userId, page: page, size: size)); + final response = await _performRequest('GET', uri); + + if (response.statusCode == 404) { + _log('Aucune demande envoyée trouvée (404) - retour d\'une liste vide'); + return []; + } + + final jsonResponse = _parseJsonResponse(response, [200]) as List; + final requests = jsonResponse + .map((json) => FriendRequest.fromJson(json as Map)) + .toList(); + + _log('${requests.length} demandes d\'amitié envoyées récupérées avec succès'); + return requests; + } catch (e) { + _log('Erreur lors de la récupération des demandes d\'amitié envoyées: $e'); + rethrow; + } + } + + /// Récupère les demandes d'amitié reçues par un utilisateur. + /// + /// [userId] L'identifiant unique de l'utilisateur + /// [page] Le numéro de page (commence à 0) + /// [size] Le nombre de demandes par page + /// + /// Returns une liste d'objets [FriendRequest] + /// + /// Throws [ServerException] en cas d'erreur + @override + Future> getReceivedFriendRequests(String userId, int page, int size) async { + _log('Récupération des demandes d\'amitié reçues pour l\'utilisateur $userId (page $page, taille $size)'); + + try { + final uri = Uri.parse(Urls.getReceivedFriendRequestsWithUserId(userId, page: page, size: size)); + final response = await _performRequest('GET', uri); + + if (response.statusCode == 404) { + _log('Aucune demande reçue trouvée (404) - retour d\'une liste vide'); + return []; + } + + final jsonResponse = _parseJsonResponse(response, [200]) as List; + + if (EnvConfig.enableDetailedLogs) { + _log('Réponse JSON brute: $jsonResponse'); + } + + final requests = jsonResponse + .map((json) { + if (EnvConfig.enableDetailedLogs) { + _log('Parsing demande: $json'); + } + return FriendRequest.fromJson(json as Map); + }) + .toList(); + + _log('${requests.length} demandes d\'amitié reçues récupérées avec succès'); + if (EnvConfig.enableDetailedLogs && requests.isNotEmpty) { + _log('Première demande: userId=${requests.first.userId}, friendId=${requests.first.friendId}, userFullName=${requests.first.userFullName}'); + } + return requests; + } catch (e) { + _log('Erreur lors de la récupération des demandes d\'amitié reçues: $e'); + rethrow; + } + } + + /// Annule une demande d'amitié envoyée (supprime la relation). + /// + /// [friendshipId] L'identifiant unique de la relation d'amitié + /// + /// Throws [ServerException] en cas d'erreur + @override + Future cancelFriendRequest(String friendshipId) async { + _log('Annulation de la demande d\'amitié: $friendshipId'); + + if (friendshipId.isEmpty) { + throw ValidationException('L\'ID de la relation d\'amitié ne peut pas être vide'); + } + + try { + // Utiliser l'endpoint DELETE existant pour supprimer la relation + final uri = Uri.parse('${Urls.friendsBase}/$friendshipId'); + final response = await _performRequest('DELETE', uri); + + if (response.statusCode != 204) { + _handleErrorResponse(response); + } + + _log('Demande d\'amitié annulée avec succès: $friendshipId'); + } catch (e) { + _log('Erreur lors de l\'annulation de la demande d\'amitié: $e'); rethrow; } } diff --git a/lib/domain/entities/friend_request.dart b/lib/domain/entities/friend_request.dart new file mode 100644 index 0000000..c3ff2cb --- /dev/null +++ b/lib/domain/entities/friend_request.dart @@ -0,0 +1,83 @@ +import 'package:equatable/equatable.dart'; + +/// Entité représentant une demande d'amitié. +/// Contient les informations nécessaires pour gérer les demandes d'amitié en attente. +class FriendRequest extends Equatable { + const FriendRequest({ + required this.friendshipId, + required this.userId, + required this.userFirstName, + required this.userLastName, + required this.friendId, + required this.friendFirstName, + required this.friendLastName, + required this.status, + required this.createdAt, + }); + + /// ID de la relation d'amitié + final String friendshipId; + + /// ID de l'utilisateur qui a envoyé la demande + final String userId; + + /// Prénom de l'utilisateur qui a envoyé la demande + final String userFirstName; + + /// Nom de l'utilisateur qui a envoyé la demande + final String userLastName; + + /// ID de l'utilisateur qui a reçu la demande + final String friendId; + + /// Prénom de l'utilisateur qui a reçu la demande + final String friendFirstName; + + /// Nom de l'utilisateur qui a reçu la demande + final String friendLastName; + + /// Statut de la demande (PENDING, ACCEPTED, REJECTED) + final String status; + + /// Date de création de la demande + final String createdAt; + + /// Nom complet de l'utilisateur qui a envoyé la demande + String get userFullName => '$userFirstName $userLastName'.trim(); + + /// Nom complet de l'utilisateur qui a reçu la demande + String get friendFullName => '$friendFirstName $friendLastName'.trim(); + + /// Factory constructor pour créer un [FriendRequest] à partir d'un JSON + factory FriendRequest.fromJson(Map json) { + // Le backend envoie : userNom (nom de famille), userPrenoms (prénoms) + // friendNom (nom de famille), friendPrenoms (prénoms) + return FriendRequest( + friendshipId: json['friendshipId']?.toString() ?? json['id']?.toString() ?? '', + userId: json['userId']?.toString() ?? '', + // userNom = nom de famille (lastName), userPrenoms = prénoms (firstName) + userFirstName: json['userPrenoms']?.toString() ?? json['userFirstName']?.toString() ?? '', + userLastName: json['userNom']?.toString() ?? json['userLastName']?.toString() ?? '', + friendId: json['friendId']?.toString() ?? '', + // friendNom = nom de famille (lastName), friendPrenoms = prénoms (firstName) + friendFirstName: json['friendPrenoms']?.toString() ?? json['friendFirstName']?.toString() ?? '', + friendLastName: json['friendNom']?.toString() ?? json['friendLastName']?.toString() ?? '', + status: json['status']?.toString() ?? 'PENDING', + createdAt: json['createdAt']?.toString() ?? json['created_at']?.toString() ?? '', + ); + } + + @override + List get props => [ + friendshipId, + userId, + userFirstName, + userLastName, + friendId, + friendFirstName, + friendLastName, + status, + createdAt, + ]; +} + diff --git a/lib/presentation/screens/friends/friends_screen.dart b/lib/presentation/screens/friends/friends_screen.dart index 08d2ec7..86b3a21 100644 --- a/lib/presentation/screens/friends/friends_screen.dart +++ b/lib/presentation/screens/friends/friends_screen.dart @@ -1,176 +1,247 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; + import '../../../data/providers/friends_provider.dart'; -import '../../../domain/entities/friend.dart'; -import '../../widgets/friend_detail_screen.dart'; +import '../../../domain/entities/friend_request.dart'; +import '../../widgets/add_friend_dialog.dart'; +import '../../widgets/cards/friend_card.dart'; +import '../../widgets/friend_request_card.dart'; import '../../widgets/search_friends.dart'; -/// [FriendsScreen] est l'écran principal permettant d'afficher et de gérer la liste des amis. -/// Il inclut des fonctionnalités de pagination, de recherche, et de rafraîchissement manuel de la liste. -/// Ce widget est un [StatefulWidget] afin de pouvoir mettre à jour dynamiquement la liste des amis. +/// Écran principal pour afficher et gérer la liste des amis. +/// +/// Cet écran inclut des fonctionnalités de pagination, de recherche, +/// et de rafraîchissement manuel de la liste avec design moderne et compact. +/// +/// **Fonctionnalités:** +/// - Affichage de la liste des amis en grille +/// - Recherche d'amis +/// - Pagination automatique +/// - Pull-to-refresh +/// - Ajout d'amis class FriendsScreen extends StatefulWidget { - final String userId; // Identifiant de l'utilisateur pour récupérer ses amis + const FriendsScreen({required this.userId, super.key}); - const FriendsScreen({Key? key, required this.userId}) : super(key: key); + final String userId; @override - _FriendsScreenState createState() => _FriendsScreenState(); + State createState() => _FriendsScreenState(); } -class _FriendsScreenState extends State { +class _FriendsScreenState extends State with SingleTickerProviderStateMixin { + // ============================================================================ + // CONTROLLERS + // ============================================================================ + late ScrollController _scrollController; + late TabController _tabController; @override void initState() { super.initState(); - // Initialisation du contrôleur de défilement pour la gestion de la pagination. + _initializeScrollController(); + _tabController = TabController(length: 2, vsync: this); + _loadFriends(); + _loadSentRequests(); + _loadReceivedRequests(); + } + + void _initializeScrollController() { _scrollController = ScrollController(); _scrollController.addListener(_onScroll); - - // Log pour indiquer le début du chargement des amis - debugPrint("[LOG] Initialisation de la page : chargement des amis pour l'utilisateur ${widget.userId}"); - // Chargement initial de la liste d'amis via le fournisseur (Provider) - Provider.of(context, listen: false).fetchFriends(widget.userId); } @override void dispose() { - // Nettoyage du contrôleur de défilement pour éviter les fuites de mémoire. _scrollController.removeListener(_onScroll); _scrollController.dispose(); + _tabController.dispose(); super.dispose(); - debugPrint("[LOG] Dispose : contrôleur de défilement supprimé"); } - /// Méthode déclenchée lors du défilement de la liste. - /// Vérifie si l'utilisateur a atteint le bas de la liste pour charger plus d'amis. + // ============================================================================ + // ACTIONS + // ============================================================================ + + /// Charge les amis au démarrage. + void _loadFriends() { + Provider.of(context, listen: false) + .fetchFriends(widget.userId); + } + + /// Charge les demandes envoyées au démarrage. + void _loadSentRequests() { + Provider.of(context, listen: false) + .fetchSentRequests(); + } + + /// Charge les demandes reçues au démarrage. + void _loadReceivedRequests() { + Provider.of(context, listen: false) + .fetchReceivedRequests(); + } + + /// Gère le défilement pour la pagination. void _onScroll() { final provider = Provider.of(context, listen: false); - // Ajout d'une marge de 200 pixels pour détecter le bas de la liste plus tôt. if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200 && - !provider.isLoading && provider.hasMore) { - debugPrint("[LOG] Scroll : Fin de liste atteinte, chargement de la page suivante."); - provider.fetchFriends(widget.userId, loadMore: true); // Chargement de plus d'amis + _scrollController.position.maxScrollExtent - 200 && + !provider.isLoading && + provider.hasMore) { + provider.fetchFriends(widget.userId, loadMore: true); } } + /// Gère le rafraîchissement. + Future _handleRefresh() async { + final provider = Provider.of(context, listen: false); + provider.fetchFriends(widget.userId); + await Future.delayed(const Duration(milliseconds: 500)); + } + + /// Gère l'ajout d'un ami. + void _handleAddFriend() { + showDialog( + context: context, + builder: (context) => AddFriendDialog( + onFriendAdded: () { + // Rafraîchir la liste des amis et des demandes après l'ajout + _loadFriends(); + _loadSentRequests(); + _loadReceivedRequests(); + }, + ), + ); + } + + // ============================================================================ + // BUILD + // ============================================================================ + @override Widget build(BuildContext context) { - // Accès au fournisseur pour gérer les données et les états des amis. - final friendsProvider = Provider.of(context, listen: false); + final theme = Theme.of(context); return Scaffold( - appBar: AppBar( - title: const Text('Mes Amis'), - actions: [ - // Bouton pour rafraîchir la liste des amis - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - // Vérifie si la liste n'est pas en cours de chargement avant d'envoyer une nouvelle requête. - if (!friendsProvider.isLoading) { - debugPrint("[LOG] Bouton Refresh : demande de rafraîchissement de la liste des amis"); - friendsProvider.fetchFriends(widget.userId); - } else { - debugPrint("[LOG] Rafraîchissement en cours, action ignorée."); - } - }, + appBar: _buildAppBar(theme), + body: _buildBody(theme), + floatingActionButton: _buildFloatingActionButton(theme), + ); + } + + /// Construit la barre d'application. + PreferredSizeWidget _buildAppBar(ThemeData theme) { + return AppBar( + title: const Text('Mes Amis'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Amis', icon: Icon(Icons.people)), + Tab(text: 'Demandes', icon: Icon(Icons.person_add)), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Actualiser', + onPressed: () { + _loadFriends(); + _loadSentRequests(); + _loadReceivedRequests(); + }, + ), + ], + ); + } + + /// Construit le corps de l'écran. + Widget _buildBody(ThemeData theme) { + return SafeArea( + child: TabBarView( + controller: _tabController, + children: [ + // Onglet Amis + Column( + children: [ + _buildSearchBar(), + Expanded( + child: Consumer( + builder: (context, provider, child) { + if (provider.isLoading && provider.friendsList.isEmpty) { + return _buildLoadingState(theme); + } + + if (provider.friendsList.isEmpty) { + return _buildEmptyState(theme); + } + + return _buildFriendsList(theme, provider); + }, + ), + ), + ], + ), + // Onglet Demandes en attente + _buildPendingRequestsTab(theme), + ], + ), + ); + } + + /// Construit la barre de recherche. + Widget _buildSearchBar() { + return const Padding( + padding: EdgeInsets.all(16), + child: SearchFriends(), + ); + } + + /// Construit l'état de chargement. + Widget _buildLoadingState(ThemeData theme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Chargement des amis...', + style: theme.textTheme.bodyMedium, ), ], ), - body: SafeArea( + ); + } + + /// Construit l'état vide. + Widget _buildEmptyState(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Widget de recherche d'amis en haut de l'écran - const Padding( - padding: EdgeInsets.all(8.0), - child: SearchFriends(), + Icon( + Icons.people_outline, + size: 64, + color: theme.colorScheme.secondary.withOpacity(0.6), ), - Expanded( - // Construction de la liste d'amis avec un affichage en grille - child: Consumer( - builder: (context, friendsProvider, child) { - // Si le chargement est en cours et qu'il n'y a aucun ami, afficher un indicateur de chargement. - if (friendsProvider.isLoading && friendsProvider.friendsList.isEmpty) { - debugPrint("[LOG] Chargement : affichage de l'indicateur de progression"); - return const Center(child: CircularProgressIndicator()); - } - - // Si la liste est vide après le chargement, afficher un message indiquant qu'aucun ami n'a été trouvé. - if (friendsProvider.friendsList.isEmpty) { - debugPrint("[LOG] Liste vide : Aucun ami trouvé"); - return const Center( - child: Text('Aucun ami trouvé'), - ); - } - - return GridView.builder( - controller: _scrollController, // Utilisation du contrôleur pour la pagination - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, // Deux amis par ligne - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 0.8, // Ajuste la taille des cartes - ), - itemCount: friendsProvider.friendsList.length, - itemBuilder: (context, index) { - final friend = friendsProvider.friendsList[index]; - // Affichage de chaque ami dans une carte avec une animation - return GestureDetector( - onTap: () => _navigateToFriendDetail(context, friend), // Action au clic sur l'avatar - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - transform: Matrix4.identity() - ..scale(1.05), // Effet de zoom lors du survol - child: Card( - elevation: 6, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircleAvatar( - radius: 50, - backgroundImage: friend.imageUrl != null && friend.imageUrl!.isNotEmpty - ? (friend.imageUrl!.startsWith('https') // Vérifie si l'image est une URL réseau. - ? NetworkImage(friend.imageUrl!) // Charge l'image depuis une URL réseau. - : AssetImage(friend.imageUrl!) as ImageProvider) // Sinon, charge depuis les ressources locales. - : const AssetImage('lib/assets/images/default_avatar.png'), // Si aucune image, utilise l'image par défaut. - ), - const SizedBox(height: 10), - Text( - "${friend.friendFirstName} ${friend.friendLastName}", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 5), - Text( - friend.status.name, - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 5), - Text( - friend.lastInteraction ?? 'Aucune interaction récente', - style: const TextStyle( - fontStyle: FontStyle.italic, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ); - }, - ); - }, + const SizedBox(height: 24), + Text( + 'Aucun ami trouvé', + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Commencez à ajouter des amis pour voir leurs événements', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), ), + textAlign: TextAlign.center, ), ], ), @@ -178,23 +249,226 @@ class _FriendsScreenState extends State { ); } - /// Navigation vers l'écran de détails de l'ami - /// Permet de voir les informations complètes d'un ami lorsque l'utilisateur clique sur son avatar. - void _navigateToFriendDetail(BuildContext context, Friend friend) { - debugPrint("[LOG] Navigation : Détails de l'ami ${friend.friendFirstName} ${friend.friendLastName}"); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FriendDetailScreen( - friendFirstName: friend.friendFirstName, // Prénom de l'ami - friendLastName: friend.friendLastName, // Nom de l'ami - imageUrl: friend.imageUrl ?? '', // URL de l'image de l'ami (ou valeur par défaut) - friendId: friend.friendId, // Identifiant unique de l'ami - status: friend.status, // Statut de l'ami - lastInteraction: friend.lastInteraction ?? 'Aucune', // Dernière interaction (si disponible) - dateAdded: friend.dateAdded ?? 'Inconnu', // Date d'ajout de l'ami (si disponible) + /// Construit la liste des amis. + Widget _buildFriendsList(ThemeData theme, FriendsProvider provider) { + return RefreshIndicator( + onRefresh: _handleRefresh, + color: theme.colorScheme.primary, + child: GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.75, ), + itemCount: provider.friendsList.length, + itemBuilder: (context, index) { + final friend = provider.friendsList[index]; + return FriendCard(friend: friend); + }, ), ); } + + /// Construit le bouton flottant (compact). + Widget _buildFloatingActionButton(ThemeData theme) { + return FloatingActionButton( + onPressed: _handleAddFriend, + tooltip: 'Ajouter un ami', + child: const Icon(Icons.person_add), + ); + } + + /// Construit l'onglet des demandes en attente avec deux sections. + Widget _buildPendingRequestsTab(ThemeData theme) { + return Consumer( + builder: (context, provider, child) { + final isLoading = provider.isLoadingReceivedRequests || provider.isLoadingSentRequests; + final hasReceived = provider.receivedRequests.isNotEmpty; + final hasSent = provider.sentRequests.isNotEmpty; + + if (isLoading && !hasReceived && !hasSent) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Chargement des demandes...', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ); + } + + if (!hasReceived && !hasSent) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_add_disabled, + size: 64, + color: theme.colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Aucune demande en attente', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 8), + Text( + 'Les demandes d\'amitié apparaîtront ici', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + await Future.wait([ + provider.fetchReceivedRequests(), + provider.fetchSentRequests(), + ]); + }, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + // Section Demandes reçues + if (hasReceived) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Demandes reçues', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...provider.receivedRequests.map((request) => FriendRequestCard( + request: request, + onAccept: () => _handleAcceptRequest(provider, request.friendshipId), + onReject: () => _handleRejectRequest(provider, request.friendshipId), + )), + const SizedBox(height: 16), + ], + // Section Demandes envoyées + if (hasSent) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Demandes envoyées', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...provider.sentRequests.map((request) => FriendRequestCard( + request: request, + onAccept: null, // Pas d'accepter pour les demandes envoyées + onReject: () => _handleCancelRequest(provider, request.friendshipId), + isSentRequest: true, // Indique que c'est une demande envoyée + )), + ], + ], + ), + ); + }, + ); + } + + /// Gère l'acceptation d'une demande d'amitié. + Future _handleAcceptRequest(FriendsProvider provider, String friendshipId) async { + try { + await provider.acceptFriendRequest(friendshipId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Demande d\'amitié acceptée'), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + ), + ); + // Rafraîchir les deux onglets + _loadFriends(); + _loadReceivedRequests(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: ${e.toString()}'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + /// Gère le rejet d'une demande d'amitié. + Future _handleRejectRequest(FriendsProvider provider, String friendshipId) async { + try { + await provider.rejectFriendRequest(friendshipId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Demande d\'amitié rejetée'), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + ), + ); + _loadReceivedRequests(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: ${e.toString()}'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + /// Gère l'annulation d'une demande d'amitié envoyée. + Future _handleCancelRequest(FriendsProvider provider, String friendshipId) async { + try { + await provider.cancelFriendRequest(friendshipId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Demande d\'amitié annulée'), + backgroundColor: Colors.blue, + behavior: SnackBarBehavior.floating, + ), + ); + _loadSentRequests(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: ${e.toString()}'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } } diff --git a/lib/presentation/widgets/add_friend_dialog.dart b/lib/presentation/widgets/add_friend_dialog.dart new file mode 100644 index 0000000..d643994 --- /dev/null +++ b/lib/presentation/widgets/add_friend_dialog.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +import '../../core/constants/env_config.dart'; +import '../../core/constants/urls.dart'; +import '../../data/datasources/user_remote_data_source.dart'; +import '../../data/models/user_model.dart'; +import '../../data/providers/friends_provider.dart'; +import '../../data/services/secure_storage.dart'; +import '../../domain/entities/friend.dart'; + +/// Dialogue pour rechercher et ajouter un nouvel ami. +/// +/// Ce widget permet de rechercher des utilisateurs par email ou nom, +/// puis d'envoyer une demande d'ami. +class AddFriendDialog extends StatefulWidget { + const AddFriendDialog({ + required this.onFriendAdded, + super.key, + }); + + /// Callback appelé lorsqu'un ami est ajouté avec succès + final VoidCallback onFriendAdded; + + @override + State createState() => _AddFriendDialogState(); +} + +class _AddFriendDialogState extends State { + final TextEditingController _searchController = TextEditingController(); + final SecureStorage _secureStorage = SecureStorage(); + late final UserRemoteDataSource _userDataSource; + + List _searchResults = []; + bool _isSearching = false; + String? _errorMessage; + String? _currentUserId; + + @override + void initState() { + super.initState(); + _userDataSource = UserRemoteDataSource(http.Client()); + _loadCurrentUserId(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Charge l'ID de l'utilisateur actuel + Future _loadCurrentUserId() async { + final userId = await _secureStorage.getUserId(); + setState(() { + _currentUserId = userId; + }); + } + + /// Recherche des utilisateurs par email + Future _searchUsers(String query) async { + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _errorMessage = null; + }); + return; + } + + final email = query.trim().toLowerCase(); + + if (!_isValidEmail(email)) { + setState(() { + _searchResults = []; + _errorMessage = 'Veuillez entrer un email valide'; + _isSearching = false; + }); + return; + } + + setState(() { + _isSearching = true; + _errorMessage = null; + _searchResults = []; + }); + + try { + // Rechercher l'utilisateur par email via l'API + final user = await _userDataSource.searchUserByEmail(email); + + setState(() { + _searchResults = [user]; + _isSearching = false; + }); + } catch (e) { + if (EnvConfig.enableDetailedLogs) { + debugPrint('[AddFriendDialog] Erreur de recherche: $e'); + } + + String errorMessage; + if (e.toString().contains('UserNotFoundException') || e.toString().contains('non trouvé')) { + errorMessage = 'Aucun utilisateur trouvé avec cet email'; + } else { + errorMessage = 'Erreur lors de la recherche: ${e.toString()}'; + } + + setState(() { + _errorMessage = errorMessage; + _isSearching = false; + _searchResults = []; + }); + } + } + + /// Vérifie si l'email est valide + bool _isValidEmail(String email) { + return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email); + } + + /// Ajoute un ami par son UUID + Future _addFriend(String friendId) async { + if (_currentUserId == null || _currentUserId!.isEmpty) { + _showError('Vous devez être connecté pour ajouter un ami'); + return; + } + + if (friendId.trim().isEmpty) { + _showError('ID utilisateur invalide'); + return; + } + + String? friendEmail; + + try { + setState(() { + _isSearching = true; + _errorMessage = null; + }); + + // Récupérer les informations de l'utilisateur par son ID pour affichage + try { + final user = await _userDataSource.getUser(friendId); + friendEmail = user.email; + } catch (e) { + // Si l'utilisateur n'est pas trouvé, on continue quand même avec l'ID + if (EnvConfig.enableDetailedLogs) { + debugPrint('[AddFriendDialog] Impossible de récupérer les infos utilisateur: $e'); + } + } + + // Envoyer la demande d'ami via le provider (seulement l'UUID est nécessaire) + final friendsProvider = Provider.of(context, listen: false); + await friendsProvider.addFriend(friendId); + + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Demande d\'ami envoyée${friendEmail != null ? ' à $friendEmail' : ''}'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.green, + ), + ); + widget.onFriendAdded(); + } + } catch (e) { + if (EnvConfig.enableDetailedLogs) { + debugPrint('[AddFriendDialog] Erreur lors de l\'ajout: $e'); + debugPrint('[AddFriendDialog] Type d\'erreur: ${e.runtimeType}'); + } + + // Extraire un message d'erreur plus clair + String errorMessage; + if (e.toString().contains('ValidationException')) { + // Extraire le message après "ValidationException: " + final parts = e.toString().split('ValidationException: '); + errorMessage = parts.length > 1 ? parts[1] : 'Données invalides'; + } else if (e.toString().contains('ServerException')) { + final parts = e.toString().split('ServerException: '); + errorMessage = parts.length > 1 ? parts[1] : 'Erreur serveur'; + } else if (e.toString().contains('ConflictException')) { + final parts = e.toString().split('ConflictException: '); + errorMessage = parts.length > 1 ? parts[1] : 'Cet utilisateur est déjà votre ami'; + } else { + errorMessage = e.toString().replaceAll(RegExp(r'^[A-Za-z]+Exception: '), ''); + if (errorMessage.isEmpty || errorMessage == e.toString()) { + errorMessage = 'Erreur lors de l\'ajout de l\'ami. Veuillez réessayer.'; + } + } + + _showError(errorMessage); + } finally { + if (mounted) { + setState(() { + _isSearching = false; + }); + } + } + } + + /// Affiche une erreur + void _showError(String message) { + if (mounted) { + setState(() { + _errorMessage = message; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(theme), + const SizedBox(height: 16), + _buildSearchField(theme), + const SizedBox(height: 16), + if (_errorMessage != null) _buildErrorMessage(theme), + if (_isSearching) _buildLoadingIndicator(theme), + if (!_isSearching && _searchResults.isNotEmpty) + _buildSearchResults(theme), + const SizedBox(height: 16), + _buildActions(theme), + ], + ), + ), + ); + } + + /// Construit l'en-tête du dialogue + Widget _buildHeader(ThemeData theme) { + return Row( + children: [ + Icon( + Icons.person_add, + color: theme.colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Ajouter un ami', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ); + } + + /// Construit le champ de recherche + Widget _buildSearchField(ThemeData theme) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Entrez l\'email de l\'ami', + prefixIcon: const Icon(Icons.person_search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchResults = []; + _errorMessage = null; + }); + }, + ) + : null, + helperText: 'Recherchez un utilisateur par son adresse email', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (value) { + setState(() { + _errorMessage = null; + }); + // Recherche automatique après un délai + if (value.trim().isNotEmpty && _isValidEmail(value.trim())) { + Future.delayed(const Duration(milliseconds: 500), () { + if (_searchController.text == value) { + _searchUsers(value.trim()); + } + }); + } else { + setState(() { + _searchResults = []; + }); + } + }, + textInputAction: TextInputAction.search, + keyboardType: TextInputType.emailAddress, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + _searchUsers(value.trim()); + } + }, + ); + } + + /// Construit le message d'erreur + Widget _buildErrorMessage(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } + + /// Construit l'indicateur de chargement + Widget _buildLoadingIndicator(ThemeData theme) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + /// Construit les résultats de recherche + Widget _buildSearchResults(ThemeData theme) { + if (_isSearching) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (_searchResults.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final user = _searchResults[index]; + return _buildUserTile(theme, user); + }, + ), + ); + } + + /// Construit une tuile d'utilisateur + Widget _buildUserTile(ThemeData theme, UserModel user) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + backgroundImage: user.profileImageUrl.isNotEmpty && + user.profileImageUrl.startsWith('http') + ? NetworkImage(user.profileImageUrl) + : null, + onBackgroundImageError: (exception, stackTrace) { + // Ignorer les erreurs de chargement d'image + if (EnvConfig.enableDetailedLogs) { + debugPrint('[AddFriendDialog] Erreur de chargement d\'image: $exception'); + } + }, + child: user.profileImageUrl.isEmpty || + !user.profileImageUrl.startsWith('http') + ? Text( + user.userFirstName.isNotEmpty + ? user.userFirstName[0].toUpperCase() + : '?', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ) + : null, + ), + title: Text( + '${user.userFirstName} ${user.userLastName}'.trim(), + style: theme.textTheme.titleMedium, + ), + subtitle: Text( + user.email, + style: theme.textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon(Icons.person_add), + onPressed: _isSearching ? null : () => _addFriend(user.userId), + tooltip: 'Ajouter comme ami', + ), + ), + ); + } + + /// Construit les actions du dialogue + Widget _buildActions(ThemeData theme) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ], + ); + } +} + diff --git a/lib/presentation/widgets/friend_detail_screen.dart b/lib/presentation/widgets/friend_detail_screen.dart index 2e04aed..29f4f8c 100644 --- a/lib/presentation/widgets/friend_detail_screen.dart +++ b/lib/presentation/widgets/friend_detail_screen.dart @@ -7,6 +7,11 @@ import '../../domain/entities/friend.dart'; /// et une option pour envoyer un message. /// Utilisé lorsque l'utilisateur clique sur un ami pour voir plus de détails. class FriendDetailScreen extends StatelessWidget { + + /// Constructeur de la classe [FriendDetailScreen]. + FriendDetailScreen({ + required this.friendFirstName, required this.friendLastName, required this.imageUrl, required this.friendId, required this.status, required this.lastInteraction, required this.dateAdded, super.key, + }); final String friendFirstName; // Nom de l'ami final String friendLastName; final String imageUrl; // URL de l'image de profil de l'ami @@ -16,18 +21,6 @@ class FriendDetailScreen extends StatelessWidget { final String lastInteraction; final String dateAdded; - /// Constructeur de la classe [FriendDetailScreen]. - FriendDetailScreen({ - Key? key, - required this.friendFirstName, - required this.friendLastName, - required this.imageUrl, - required this.friendId, - required this.status, - required this.lastInteraction, - required this.dateAdded, - }) : super(key: key); - /// Méthode statique pour lancer l'écran des détails d'un ami. static void open( BuildContext context, @@ -37,7 +30,7 @@ class FriendDetailScreen extends StatelessWidget { String imageUrl, FriendStatus status, String lastInteraction, - String dateAdded) { + String dateAdded,) { Navigator.push( context, MaterialPageRoute( @@ -54,16 +47,44 @@ class FriendDetailScreen extends StatelessWidget { ); } + /// Vérifie si une URL est valide pour le chargement d'image réseau. + bool _isValidImageUrl(String? url) { + if (url == null || url.isEmpty) { + return false; + } + + try { + final uri = Uri.tryParse(url); + if (uri == null) { + return false; + } + + // Vérifier que c'est une URL HTTP/HTTPS valide + if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) { + return false; + } + + // Vérifier qu'il y a un host + if (uri.host.isEmpty) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + @override Widget build(BuildContext context) { _logger.i('[LOG] Affichage des détails de l\'ami : $friendFirstName (ID: $friendId)'); - // Utilise `AssetImage` si `imageUrl` est vide ou ne contient pas d'URL valide. - final imageProvider = - imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAbsolutePath == true + // Vérifier si l'URL est valide avant de créer le NetworkImage + final bool hasValidImageUrl = _isValidImageUrl(imageUrl); + final ImageProvider imageProvider = hasValidImageUrl ? NetworkImage(imageUrl) : const AssetImage('lib/assets/images/default_avatar.png') - as ImageProvider; + as ImageProvider; return Scaffold( appBar: AppBar( @@ -72,10 +93,9 @@ class FriendDetailScreen extends StatelessWidget { elevation: 6, // Ombre sous l'app bar pour plus de profondeur ), body: Padding( - padding: const EdgeInsets.all(16.0), // Espacement autour du contenu + padding: const EdgeInsets.all(16), // Espacement autour du contenu child: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, children: [ // Animation Hero pour une transition fluide lors de la navigation Hero( @@ -85,12 +105,12 @@ class FriendDetailScreen extends StatelessWidget { curve: Curves.easeInOut, child: CircleAvatar( radius: 80, - backgroundImage: imageProvider, + backgroundImage: hasValidImageUrl ? imageProvider : null, backgroundColor: Colors.grey.shade800, onBackgroundImageError: (error, stackTrace) { _logger.e('[ERROR] Erreur lors du chargement de l\'image pour $friendFirstName (ID: $friendId): $error'); }, - child: imageUrl.isEmpty + child: !hasValidImageUrl ? const Icon(Icons.person, size: 60, color: Colors.white) : null, ), @@ -156,9 +176,8 @@ class FriendDetailScreen extends StatelessWidget { /// Widget réutilisable pour afficher une ligne d'information avec un texte d'introduction et une valeur. Widget _buildInfoRow(String label, String value) { return Padding( - padding: const EdgeInsets.only(bottom: 12.0), + padding: const EdgeInsets.only(bottom: 12), child: Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Text( label, diff --git a/lib/presentation/widgets/friend_request_card.dart b/lib/presentation/widgets/friend_request_card.dart new file mode 100644 index 0000000..b459881 --- /dev/null +++ b/lib/presentation/widgets/friend_request_card.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +import '../../domain/entities/friend_request.dart'; + +/// Widget pour afficher une demande d'amitié en attente. +/// Permet d'accepter ou de rejeter la demande (reçue) ou d'annuler (envoyée). +class FriendRequestCard extends StatelessWidget { + const FriendRequestCard({ + required this.request, + required this.onAccept, + required this.onReject, + this.isSentRequest = false, + super.key, + }); + + final FriendRequest request; + final VoidCallback? onAccept; + final VoidCallback onReject; + final bool isSentRequest; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 28, + backgroundColor: theme.colorScheme.primaryContainer, + child: Text( + (isSentRequest ? request.friendFullName : request.userFullName).isNotEmpty + ? (isSentRequest ? request.friendFullName : request.userFullName)[0].toUpperCase() + : '?', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + + // Informations + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSentRequest + ? (request.friendFullName.isNotEmpty + ? request.friendFullName + : 'Utilisateur inconnu') + : (request.userFullName.isNotEmpty + ? request.userFullName + : 'Utilisateur inconnu'), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + isSentRequest + ? 'Demande envoyée' + : 'Demande d\'amitié', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + + // Boutons d'action + if (isSentRequest) + // Pour les demandes envoyées : seulement bouton Annuler + IconButton( + icon: const Icon(Icons.close), + color: Colors.orange, + onPressed: onReject, + tooltip: 'Annuler la demande', + ) + else + // Pour les demandes reçues : Accepter et Rejeter + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton Accepter + IconButton( + icon: const Icon(Icons.check_circle), + color: Colors.green, + onPressed: onAccept, + tooltip: 'Accepter', + ), + // Bouton Rejeter + IconButton( + icon: const Icon(Icons.cancel), + color: Colors.red, + onPressed: onReject, + tooltip: 'Rejeter', + ), + ], + ), + ], + ), + ), + ); + } +} +