From 45dcd2171e885c0e7db37d93fb43eef527546b6f Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:26:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(communication):=20module=20messagerie=20un?= =?UTF-8?q?ifi=C3=A9=20+=20contact=20policies=20+=20blocages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligné avec le backend MessagingResource : - Nouveau module communication (conversations, messages, participants) - Respect des ContactPolicy (qui peut parler à qui par rôle) - Gestion MemberBlock (blocages individuels) - UI : conversations list, conversation detail, broadcast, tiles - BLoC : MessagingBloc avec events (envoyer, démarrer conversation rôle, etc.) --- .../messaging_remote_datasource.dart | 497 +++++++++--------- .../data/models/contact_policy_model.dart | 27 + .../data/models/conversation_model.dart | 160 +++--- .../data/models/conversation_model.g.dart | 57 +- .../data/models/message_model.dart | 105 ++-- .../data/models/message_model.g.dart | 84 +-- .../messaging_repository_impl.dart | 444 +++++----------- .../domain/entities/contact_policy.dart | 35 ++ .../domain/entities/conversation.dart | 213 ++++---- .../domain/entities/message.dart | 201 ++----- .../repositories/messaging_repository.dart | 182 +++---- .../domain/usecases/get_conversations.dart | 15 +- .../domain/usecases/get_messages.dart | 20 +- .../domain/usecases/send_broadcast.dart | 39 +- .../domain/usecases/send_message.dart | 26 +- .../presentation/bloc/messaging_bloc.dart | 231 +++++--- .../presentation/bloc/messaging_event.dart | 181 ++++--- .../presentation/bloc/messaging_state.dart | 62 ++- .../presentation/pages/broadcast_page.dart | 212 ++++++++ .../pages/conversation_detail_page.dart | 377 +++++++++++++ .../pages/conversations_page.dart | 242 +++++---- .../pages/conversations_page_wrapper.dart | 33 ++ .../presentation/widgets/message_bubble.dart | 241 +++++++++ 23 files changed, 2096 insertions(+), 1588 deletions(-) create mode 100644 lib/features/communication/data/models/contact_policy_model.dart create mode 100644 lib/features/communication/domain/entities/contact_policy.dart create mode 100644 lib/features/communication/presentation/pages/broadcast_page.dart create mode 100644 lib/features/communication/presentation/pages/conversation_detail_page.dart create mode 100644 lib/features/communication/presentation/pages/conversations_page_wrapper.dart create mode 100644 lib/features/communication/presentation/widgets/message_bubble.dart diff --git a/lib/features/communication/data/datasources/messaging_remote_datasource.dart b/lib/features/communication/data/datasources/messaging_remote_datasource.dart index fe1ecb2..631e122 100644 --- a/lib/features/communication/data/datasources/messaging_remote_datasource.dart +++ b/lib/features/communication/data/datasources/messaging_remote_datasource.dart @@ -1,16 +1,18 @@ -/// Datasource distant pour la communication (API) +/// Datasource distant pour la messagerie v4 — /api/messagerie/* library messaging_remote_datasource; import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; + import 'package:injectable/injectable.dart'; + import '../../../../core/config/environment.dart'; import '../../../../core/error/exceptions.dart'; import '../../../authentication/data/datasources/keycloak_auth_service.dart'; -import '../models/message_model.dart'; import '../models/conversation_model.dart'; -import '../../domain/entities/message.dart'; +import '../models/message_model.dart'; +import '../models/contact_policy_model.dart'; + +import 'package:http/http.dart' as http; @lazySingleton class MessagingRemoteDatasource { @@ -22,7 +24,7 @@ class MessagingRemoteDatasource { required this.authService, }); - /// Headers HTTP avec authentification — rafraîchit le token si expiré (fix IC-03) + /// Headers HTTP avec authentification — rafraîchit le token si expiré Future> _getHeaders() async { final token = await authService.getValidAccessToken(); return { @@ -32,290 +34,271 @@ class MessagingRemoteDatasource { }; } - // === CONVERSATIONS === + String get _base => '${AppConfig.apiBaseUrl}/api/messagerie'; - Future> getConversations({ - String? organizationId, - bool includeArchived = false, - }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/conversations') - .replace(queryParameters: { - if (organizationId != null) 'organisationId': organizationId, - 'includeArchived': includeArchived.toString(), - }); + // ── Conversations ───────────────────────────────────────────────────────── - final response = await client.get(uri, headers: await _getHeaders()); + Future> getMesConversations() async { + final response = await client.get( + Uri.parse('$_base/conversations'), + headers: await _getHeaders(), + ); + _checkAuth(response); if (response.statusCode == 200) { - final List jsonList = json.decode(response.body); - return jsonList - .map((json) => ConversationModel.fromJson(json)) + final list = json.decode(response.body) as List; + return list + .map((j) => ConversationSummaryModel.fromJson(j as Map)) .toList(); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de la récupération des conversations'); + } + throw ServerException('Erreur récupération conversations (${response.statusCode})'); + } + + Future getConversation(String id) async { + final response = await client.get( + Uri.parse('$_base/conversations/$id'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode == 200) { + return ConversationModel.fromJson(json.decode(response.body) as Map); + } + if (response.statusCode == 404) throw NotFoundException('Conversation non trouvée'); + throw ServerException('Erreur récupération conversation (${response.statusCode})'); + } + + Future demarrerConversationDirecte({ + required String destinataireId, + required String organisationId, + String? premierMessage, + }) async { + final body = json.encode({ + 'destinataireId': destinataireId, + 'organisationId': organisationId, + if (premierMessage != null) 'premierMessage': premierMessage, + }); + + final response = await client.post( + Uri.parse('$_base/conversations/directe'), + headers: await _getHeaders(), + body: body, + ); + + _checkAuth(response); + if (response.statusCode == 201 || response.statusCode == 200) { + return ConversationModel.fromJson(json.decode(response.body) as Map); + } + throw ServerException('Erreur démarrage conversation directe (${response.statusCode})'); + } + + Future demarrerConversationRole({ + required String roleCible, + required String organisationId, + String? premierMessage, + }) async { + final body = json.encode({ + 'roleCible': roleCible, + 'organisationId': organisationId, + if (premierMessage != null) 'premierMessage': premierMessage, + }); + + final response = await client.post( + Uri.parse('$_base/conversations/role'), + headers: await _getHeaders(), + body: body, + ); + + _checkAuth(response); + if (response.statusCode == 201 || response.statusCode == 200) { + return ConversationModel.fromJson(json.decode(response.body) as Map); + } + throw ServerException('Erreur démarrage conversation rôle (${response.statusCode})'); + } + + Future archiverConversation(String id) async { + final response = await client.delete( + Uri.parse('$_base/conversations/$id'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode != 200 && response.statusCode != 204) { + throw ServerException('Erreur archivage conversation (${response.statusCode})'); } } - Future getConversationById(String conversationId) async { - final uri = Uri.parse( - '${AppConfig.apiBaseUrl}/api/conversations/$conversationId'); + // ── Messages ────────────────────────────────────────────────────────────── + + Future envoyerMessage( + String conversationId, { + required String typeMessage, + String? contenu, + String? urlFichier, + int? dureeAudio, + String? messageParentId, + }) async { + final body = json.encode({ + 'typeMessage': typeMessage, + if (contenu != null) 'contenu': contenu, + if (urlFichier != null) 'urlFichier': urlFichier, + if (dureeAudio != null) 'dureeAudio': dureeAudio, + if (messageParentId != null) 'messageParentId': messageParentId, + }); + + final response = await client.post( + Uri.parse('$_base/conversations/$conversationId/messages'), + headers: await _getHeaders(), + body: body, + ); + + _checkAuth(response); + if (response.statusCode == 201 || response.statusCode == 200) { + return MessageModel.fromJson(json.decode(response.body) as Map); + } + throw ServerException('Erreur envoi message (${response.statusCode})'); + } + + Future> getMessages(String conversationId, {int page = 0}) async { + final uri = Uri.parse('$_base/conversations/$conversationId/messages') + .replace(queryParameters: {'page': page.toString()}); final response = await client.get(uri, headers: await _getHeaders()); + _checkAuth(response); if (response.statusCode == 200) { - return ConversationModel.fromJson(json.decode(response.body)); - } else if (response.statusCode == 404) { - throw NotFoundException('Conversation non trouvée'); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de la récupération de la conversation'); + final list = json.decode(response.body) as List; + return list + .map((j) => MessageModel.fromJson(j as Map)) + .toList(); } + throw ServerException('Erreur récupération messages (${response.statusCode})'); } - Future createConversation({ - required String name, - required List participantIds, - String? organizationId, - String? description, - }) async { - final uri = - Uri.parse('${AppConfig.apiBaseUrl}/api/conversations'); - - final body = json.encode({ - 'name': name, - 'participantIds': participantIds, - 'type': 'GROUP', // Default to GROUP for multi-participant conversations - if (organizationId != null) 'organisationId': organizationId, - if (description != null) 'description': description, - }); - - final response = await client.post( - uri, - headers: await _getHeaders(), - body: body, - ); - - if (response.statusCode == 201 || response.statusCode == 200) { - return ConversationModel.fromJson(json.decode(response.body)); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de la création de la conversation'); - } - } - - // === MESSAGES === - - Future> getMessages({ - required String conversationId, - int? limit, - String? beforeMessageId, - }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages') - .replace(queryParameters: { - 'conversationId': conversationId, - if (limit != null) 'limit': limit.toString(), - // beforeMessageId not supported by backend yet, omit - }); - - final response = await client.get(uri, headers: await _getHeaders()); - - if (response.statusCode == 200) { - final List jsonList = json.decode(response.body); - return jsonList.map((json) => MessageModel.fromJson(json)).toList(); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de la récupération des messages'); - } - } - - Future sendMessage({ - required String conversationId, - required String content, - List? attachments, - MessagePriority priority = MessagePriority.normal, - }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages'); - - final body = json.encode({ - 'conversationId': conversationId, - 'content': content, - if (attachments != null) 'attachments': attachments, - 'priority': priority.name.toUpperCase(), - }); - - final response = await client.post( - uri, - headers: await _getHeaders(), - body: body, - ); - - if (response.statusCode == 201 || response.statusCode == 200) { - return MessageModel.fromJson(json.decode(response.body)); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de l\'envoi du message'); - } - } - - Future sendBroadcast({ - required String organizationId, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, - List? attachments, - }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/broadcast'); - - final body = json.encode({ - 'organizationId': organizationId, - 'subject': subject, - 'content': content, - 'priority': priority.name, - if (attachments != null) 'attachments': attachments, - }); - - final response = await client.post( - uri, - headers: await _getHeaders(), - body: body, - ); - - if (response.statusCode == 201 || response.statusCode == 200) { - return MessageModel.fromJson(json.decode(response.body)); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else if (response.statusCode == 403) { - throw ForbiddenException('Permission insuffisante pour envoyer un broadcast'); - } else { - throw ServerException('Erreur lors de l\'envoi du broadcast'); - } - } - - // === CONVERSATION ACTIONS === - - Future archiveConversation(String conversationId, {bool archive = true}) async { - final uri = Uri.parse( - '${AppConfig.apiBaseUrl}/api/conversations/$conversationId/archive') - .replace(queryParameters: {'archive': archive.toString()}); - - final response = await client.put(uri, headers: await _getHeaders()); - - if (response.statusCode != 200 && response.statusCode != 204) { - if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors de l\'archivage de la conversation'); - } - } - } - - Future markConversationAsRead(String conversationId) async { - final uri = Uri.parse( - '${AppConfig.apiBaseUrl}/api/conversations/$conversationId/mark-read'); - - final response = await client.put(uri, headers: await _getHeaders()); - - if (response.statusCode != 200 && response.statusCode != 204) { - if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors du marquage de la conversation comme lue'); - } - } - } - - Future toggleMuteConversation(String conversationId) async { - final uri = Uri.parse( - '${AppConfig.apiBaseUrl}/api/conversations/$conversationId/toggle-mute'); - - final response = await client.put(uri, headers: await _getHeaders()); - - if (response.statusCode != 200 && response.statusCode != 204) { - if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors du toggle mute de la conversation'); - } - } - } - - Future togglePinConversation(String conversationId) async { - final uri = Uri.parse( - '${AppConfig.apiBaseUrl}/api/conversations/$conversationId/toggle-pin'); - - final response = await client.put(uri, headers: await _getHeaders()); - - if (response.statusCode != 200 && response.statusCode != 204) { - if (response.statusCode == 401) { - throw UnauthorizedException(); - } else { - throw ServerException('Erreur lors du toggle pin de la conversation'); - } - } - } - - // === MESSAGE ACTIONS === - - Future editMessage({ - required String messageId, - required String newContent, - }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId'); - - final body = json.encode({'content': newContent}); - + Future marquerLu(String conversationId) async { final response = await client.put( - uri, + Uri.parse('$_base/conversations/$conversationId/lire'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode != 200 && response.statusCode != 204) { + throw ServerException('Erreur marquage lu (${response.statusCode})'); + } + } + + Future supprimerMessage(String conversationId, String messageId) async { + final response = await client.delete( + Uri.parse('$_base/conversations/$conversationId/messages/$messageId'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode != 200 && response.statusCode != 204) { + throw ServerException('Erreur suppression message (${response.statusCode})'); + } + } + + // ── Blocages ────────────────────────────────────────────────────────────── + + Future bloquerMembre({ + required String membreABloquerId, + String? organisationId, + String? raison, + }) async { + final body = json.encode({ + 'membreABloquerId': membreABloquerId, + if (organisationId != null) 'organisationId': organisationId, + if (raison != null) 'raison': raison, + }); + + final response = await client.post( + Uri.parse('$_base/blocages'), headers: await _getHeaders(), body: body, ); - if (response.statusCode == 200) { - return MessageModel.fromJson(json.decode(response.body)); - } else if (response.statusCode == 401) { - throw UnauthorizedException(); - } else if (response.statusCode == 404) { - throw NotFoundException('Message non trouvé'); - } else { - throw ServerException('Erreur lors de l\'édition du message'); + _checkAuth(response); + if (response.statusCode != 200 && response.statusCode != 204) { + throw ServerException('Erreur blocage membre (${response.statusCode})'); } } - Future deleteMessage(String messageId) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId'); + Future debloquerMembre(String membreId, {String? organisationId}) async { + final uri = Uri.parse('$_base/blocages/$membreId').replace( + queryParameters: { + if (organisationId != null) 'organisationId': organisationId, + }, + ); final response = await client.delete(uri, headers: await _getHeaders()); + _checkAuth(response); if (response.statusCode != 200 && response.statusCode != 204) { - if (response.statusCode == 401) { - throw UnauthorizedException(); - } else if (response.statusCode == 404) { - throw NotFoundException('Message non trouvé'); - } else { - throw ServerException('Erreur lors de la suppression du message'); - } + throw ServerException('Erreur déblocage membre (${response.statusCode})'); } } - Future markMessageAsRead(String messageId) async { - // Backend has no per-message read endpoint — use markConversationAsRead - if (AppConfig.enableLogging) { - debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId'); + Future>> getMesBlocages() async { + final response = await client.get( + Uri.parse('$_base/blocages'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode == 200) { + final list = json.decode(response.body) as List; + return list.map((j) => j as Map).toList(); } + throw ServerException('Erreur récupération blocages (${response.statusCode})'); } - Future getUnreadCount({String? organizationId}) async { - try { - final conversations = await getConversations(organizationId: organizationId); - return conversations.fold(0, (sum, c) => sum + c.unreadCount); - } catch (_) { - return 0; + // ── Politique de communication ──────────────────────────────────────────── + + Future getPolitique(String organisationId) async { + final response = await client.get( + Uri.parse('$_base/politique/$organisationId'), + headers: await _getHeaders(), + ); + + _checkAuth(response); + if (response.statusCode == 200) { + return ContactPolicyModel.fromJson(json.decode(response.body) as Map); } + throw ServerException('Erreur récupération politique (${response.statusCode})'); + } + + Future mettreAJourPolitique( + String organisationId, { + required String typePolitique, + required bool autoriserMembreVersMembre, + required bool autoriserMembreVersRole, + required bool autoriserNotesVocales, + }) async { + final body = json.encode({ + 'typePolitique': typePolitique, + 'autoriserMembreVersMembre': autoriserMembreVersMembre, + 'autoriserMembreVersRole': autoriserMembreVersRole, + 'autoriserNotesVocales': autoriserNotesVocales, + }); + + final response = await client.put( + Uri.parse('$_base/politique/$organisationId'), + headers: await _getHeaders(), + body: body, + ); + + _checkAuth(response); + if (response.statusCode == 200) { + return ContactPolicyModel.fromJson(json.decode(response.body) as Map); + } + throw ServerException('Erreur mise à jour politique (${response.statusCode})'); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + void _checkAuth(http.Response response) { + if (response.statusCode == 401) throw UnauthorizedException(); + if (response.statusCode == 403) throw ForbiddenException('Permission insuffisante'); } } diff --git a/lib/features/communication/data/models/contact_policy_model.dart b/lib/features/communication/data/models/contact_policy_model.dart new file mode 100644 index 0000000..7042843 --- /dev/null +++ b/lib/features/communication/data/models/contact_policy_model.dart @@ -0,0 +1,27 @@ +/// Modèle de données ContactPolicy v4 avec désérialisation JSON +library contact_policy_model; + +import '../../domain/entities/contact_policy.dart'; + +/// Modèle ContactPolicy v4 +class ContactPolicyModel extends ContactPolicy { + const ContactPolicyModel({ + super.id, + super.organisationId, + required super.typePolitique, + super.autoriserMembreVersMembre, + super.autoriserMembreVersRole, + super.autoriserNotesVocales, + }); + + factory ContactPolicyModel.fromJson(Map json) { + return ContactPolicyModel( + id: json['id']?.toString(), + organisationId: json['organisationId']?.toString(), + typePolitique: json['typePolitique']?.toString() ?? 'OUVERT', + autoriserMembreVersMembre: json['autoriserMembreVersMembre'] == true, + autoriserMembreVersRole: json['autoriserMembreVersRole'] == true, + autoriserNotesVocales: json['autoriserNotesVocales'] == true, + ); + } +} diff --git a/lib/features/communication/data/models/conversation_model.dart b/lib/features/communication/data/models/conversation_model.dart index 93f95f4..81e30a7 100644 --- a/lib/features/communication/data/models/conversation_model.dart +++ b/lib/features/communication/data/models/conversation_model.dart @@ -1,70 +1,110 @@ -/// Model de données Conversation avec sérialisation JSON +/// Modèles de données Conversation v4 avec désérialisation JSON library conversation_model; -import 'package:json_annotation/json_annotation.dart'; import '../../domain/entities/conversation.dart'; -import '../../domain/entities/message.dart'; import 'message_model.dart'; -part 'conversation_model.g.dart'; - -@JsonSerializable(explicitToJson: true) -class ConversationModel extends Conversation { - @JsonKey( - fromJson: _messageFromJson, - toJson: _messageToJson, - ) - @override - final Message? lastMessage; - - const ConversationModel({ +/// Modèle de résumé de conversation (liste) +class ConversationSummaryModel extends ConversationSummary { + const ConversationSummaryModel({ required super.id, - required super.name, - super.description, - required super.type, - required super.participantIds, - super.organizationId, - this.lastMessage, - super.unreadCount, - super.isMuted, - super.isPinned, - super.isArchived, - required super.createdAt, - super.updatedAt, - super.avatarUrl, - super.metadata, - }) : super(lastMessage: lastMessage); + required super.typeConversation, + required super.titre, + required super.statut, + super.dernierMessageApercu, + super.dernierMessageType, + super.dernierMessageAt, + super.nonLus, + super.organisationId, + }); - static Message? _messageFromJson(Map? json) => - json == null ? null : MessageModel.fromJson(json); - - static Map? _messageToJson(Message? message) => - message == null ? null : MessageModel.fromEntity(message).toJson(); - - factory ConversationModel.fromJson(Map json) => - _$ConversationModelFromJson(json); - - Map toJson() => _$ConversationModelToJson(this); - - factory ConversationModel.fromEntity(Conversation conversation) { - return ConversationModel( - id: conversation.id, - name: conversation.name, - description: conversation.description, - type: conversation.type, - participantIds: conversation.participantIds, - organizationId: conversation.organizationId, - lastMessage: conversation.lastMessage, - unreadCount: conversation.unreadCount, - isMuted: conversation.isMuted, - isPinned: conversation.isPinned, - isArchived: conversation.isArchived, - createdAt: conversation.createdAt, - updatedAt: conversation.updatedAt, - avatarUrl: conversation.avatarUrl, - metadata: conversation.metadata, + factory ConversationSummaryModel.fromJson(Map json) { + return ConversationSummaryModel( + id: json['id']?.toString() ?? '', + typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE', + titre: json['titre']?.toString() ?? '', + statut: json['statut']?.toString() ?? 'ACTIVE', + dernierMessageApercu: json['dernierMessageApercu']?.toString(), + dernierMessageType: json['dernierMessageType']?.toString(), + dernierMessageAt: json['dernierMessageAt'] != null + ? DateTime.tryParse(json['dernierMessageAt'].toString()) + : null, + nonLus: _parseInt(json['nonLus']), + organisationId: json['organisationId']?.toString(), ); } - - Conversation toEntity() => this; +} + +/// Modèle de participant +class ConversationParticipantModel extends ConversationParticipant { + const ConversationParticipantModel({ + required super.membreId, + super.prenom, + super.nom, + super.roleDansConversation, + super.luJusqua, + }); + + factory ConversationParticipantModel.fromJson(Map json) { + return ConversationParticipantModel( + membreId: json['membreId']?.toString() ?? '', + prenom: json['prenom']?.toString(), + nom: json['nom']?.toString(), + roleDansConversation: json['roleDansConversation']?.toString(), + luJusqua: json['luJusqua'] != null + ? DateTime.tryParse(json['luJusqua'].toString()) + : null, + ); + } +} + +/// Modèle de conversation complète (détail) +class ConversationModel extends Conversation { + const ConversationModel({ + required super.id, + required super.typeConversation, + required super.titre, + required super.statut, + super.organisationId, + super.organisationNom, + super.dateCreation, + super.nombreMessages, + super.participants, + super.messages, + super.nonLus, + super.roleCible, + }); + + factory ConversationModel.fromJson(Map json) { + final participantsJson = json['participants'] as List? ?? []; + final messagesJson = json['messages'] as List? ?? []; + + return ConversationModel( + id: json['id']?.toString() ?? '', + typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE', + titre: json['titre']?.toString() ?? '', + statut: json['statut']?.toString() ?? 'ACTIVE', + organisationId: json['organisationId']?.toString(), + organisationNom: json['organisationNom']?.toString(), + dateCreation: json['dateCreation'] != null + ? DateTime.tryParse(json['dateCreation'].toString()) + : null, + nombreMessages: _parseInt(json['nombreMessages']), + participants: participantsJson + .map((p) => ConversationParticipantModel.fromJson(p as Map)) + .toList(), + messages: messagesJson + .map((m) => MessageModel.fromJson(m as Map)) + .toList(), + nonLus: _parseInt(json['nonLus']), + roleCible: json['roleCible']?.toString(), + ); + } +} + +int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is double) return value.toInt(); + return int.tryParse(value.toString()) ?? 0; } diff --git a/lib/features/communication/data/models/conversation_model.g.dart b/lib/features/communication/data/models/conversation_model.g.dart index e0d2a08..abeb365 100644 --- a/lib/features/communication/data/models/conversation_model.g.dart +++ b/lib/features/communication/data/models/conversation_model.g.dart @@ -1,57 +1,2 @@ // GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'conversation_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ConversationModel _$ConversationModelFromJson(Map json) => - ConversationModel( - id: json['id'] as String, - name: json['name'] as String, - description: json['description'] as String?, - type: $enumDecode(_$ConversationTypeEnumMap, json['type']), - participantIds: (json['participantIds'] as List) - .map((e) => e as String) - .toList(), - organizationId: json['organizationId'] as String?, - lastMessage: ConversationModel._messageFromJson( - json['lastMessage'] as Map?), - unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0, - isMuted: json['isMuted'] as bool? ?? false, - isPinned: json['isPinned'] as bool? ?? false, - isArchived: json['isArchived'] as bool? ?? false, - createdAt: DateTime.parse(json['createdAt'] as String), - updatedAt: json['updatedAt'] == null - ? null - : DateTime.parse(json['updatedAt'] as String), - avatarUrl: json['avatarUrl'] as String?, - metadata: json['metadata'] as Map?, - ); - -Map _$ConversationModelToJson(ConversationModel instance) => - { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'type': _$ConversationTypeEnumMap[instance.type]!, - 'participantIds': instance.participantIds, - 'organizationId': instance.organizationId, - 'unreadCount': instance.unreadCount, - 'isMuted': instance.isMuted, - 'isPinned': instance.isPinned, - 'isArchived': instance.isArchived, - 'createdAt': instance.createdAt.toIso8601String(), - 'updatedAt': instance.updatedAt?.toIso8601String(), - 'avatarUrl': instance.avatarUrl, - 'metadata': instance.metadata, - 'lastMessage': ConversationModel._messageToJson(instance.lastMessage), - }; - -const _$ConversationTypeEnumMap = { - ConversationType.individual: 'individual', - ConversationType.group: 'group', - ConversationType.broadcast: 'broadcast', - ConversationType.announcement: 'announcement', -}; +// Modèles v4 : désérialisation manuelle, code generation non utilisé. diff --git a/lib/features/communication/data/models/message_model.dart b/lib/features/communication/data/models/message_model.dart index 8ce35d0..e0b01bc 100644 --- a/lib/features/communication/data/models/message_model.dart +++ b/lib/features/communication/data/models/message_model.dart @@ -1,83 +1,48 @@ -/// Model de données Message avec sérialisation JSON +/// Modèle de données Message v4 avec désérialisation JSON library message_model; -import 'package:json_annotation/json_annotation.dart'; import '../../domain/entities/message.dart'; -part 'message_model.g.dart'; - -@JsonSerializable(explicitToJson: true) +/// Modèle Message v4 class MessageModel extends Message { const MessageModel({ required super.id, - required super.conversationId, - required super.senderId, - required super.senderName, - super.senderAvatar, - required super.content, - required super.type, - required super.status, - super.priority, - required super.recipientIds, - super.recipientRoles, - super.organizationId, - required super.createdAt, - super.readAt, - super.metadata, - super.attachments, - super.isEdited, - super.editedAt, - super.isDeleted, + required super.typeMessage, + super.contenu, + super.urlFichier, + super.dureeAudio, + super.supprime, + super.expediteurId, + super.expediteurNom, + super.expediteurPrenom, + super.messageParentId, + super.messageParentApercu, + super.dateEnvoi, }); - factory MessageModel.fromJson(Map json) => - _$MessageModelFromJson(json); - - Map toJson() => _$MessageModelToJson(this); - - factory MessageModel.fromEntity(Message message) { + factory MessageModel.fromJson(Map json) { return MessageModel( - id: message.id, - conversationId: message.conversationId, - senderId: message.senderId, - senderName: message.senderName, - senderAvatar: message.senderAvatar, - content: message.content, - type: message.type, - status: message.status, - priority: message.priority, - recipientIds: message.recipientIds, - recipientRoles: message.recipientRoles, - organizationId: message.organizationId, - createdAt: message.createdAt, - readAt: message.readAt, - metadata: message.metadata, - attachments: message.attachments, - isEdited: message.isEdited, - editedAt: message.editedAt, - isDeleted: message.isDeleted, + id: json['id']?.toString() ?? '', + typeMessage: json['typeMessage']?.toString() ?? 'TEXTE', + contenu: json['contenu']?.toString(), + urlFichier: json['urlFichier']?.toString(), + dureeAudio: _parseInt(json['dureeAudio']), + supprime: json['supprime'] == true, + expediteurId: json['expediteurId']?.toString(), + expediteurNom: json['expediteurNom']?.toString(), + expediteurPrenom: json['expediteurPrenom']?.toString(), + messageParentId: json['messageParentId']?.toString(), + messageParentApercu: json['messageParentApercu']?.toString(), + dateEnvoi: json['dateEnvoi'] != null + ? DateTime.tryParse(json['dateEnvoi'].toString()) + : null, ); } - - Message toEntity() => Message( - id: id, - conversationId: conversationId, - senderId: senderId, - senderName: senderName, - senderAvatar: senderAvatar, - content: content, - type: type, - status: status, - priority: priority, - recipientIds: recipientIds, - recipientRoles: recipientRoles, - organizationId: organizationId, - createdAt: createdAt, - readAt: readAt, - metadata: metadata, - attachments: attachments, - isEdited: isEdited, - editedAt: editedAt, - isDeleted: isDeleted, - ); +} + +int? _parseInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + return int.tryParse(value.toString()); } diff --git a/lib/features/communication/data/models/message_model.g.dart b/lib/features/communication/data/models/message_model.g.dart index 2726632..abeb365 100644 --- a/lib/features/communication/data/models/message_model.g.dart +++ b/lib/features/communication/data/models/message_model.g.dart @@ -1,84 +1,2 @@ // GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MessageModel _$MessageModelFromJson(Map json) => MessageModel( - id: json['id'] as String, - conversationId: json['conversationId'] as String, - senderId: json['senderId'] as String, - senderName: json['senderName'] as String, - senderAvatar: json['senderAvatar'] as String?, - content: json['content'] as String, - type: $enumDecode(_$MessageTypeEnumMap, json['type']), - status: $enumDecode(_$MessageStatusEnumMap, json['status']), - priority: - $enumDecodeNullable(_$MessagePriorityEnumMap, json['priority']) ?? - MessagePriority.normal, - recipientIds: (json['recipientIds'] as List) - .map((e) => e as String) - .toList(), - recipientRoles: (json['recipientRoles'] as List?) - ?.map((e) => e as String) - .toList(), - organizationId: json['organizationId'] as String?, - createdAt: DateTime.parse(json['createdAt'] as String), - readAt: json['readAt'] == null - ? null - : DateTime.parse(json['readAt'] as String), - metadata: json['metadata'] as Map?, - attachments: (json['attachments'] as List?) - ?.map((e) => e as String) - .toList(), - isEdited: json['isEdited'] as bool? ?? false, - editedAt: json['editedAt'] == null - ? null - : DateTime.parse(json['editedAt'] as String), - isDeleted: json['isDeleted'] as bool? ?? false, - ); - -Map _$MessageModelToJson(MessageModel instance) => - { - 'id': instance.id, - 'conversationId': instance.conversationId, - 'senderId': instance.senderId, - 'senderName': instance.senderName, - 'senderAvatar': instance.senderAvatar, - 'content': instance.content, - 'type': _$MessageTypeEnumMap[instance.type]!, - 'status': _$MessageStatusEnumMap[instance.status]!, - 'priority': _$MessagePriorityEnumMap[instance.priority]!, - 'recipientIds': instance.recipientIds, - 'recipientRoles': instance.recipientRoles, - 'organizationId': instance.organizationId, - 'createdAt': instance.createdAt.toIso8601String(), - 'readAt': instance.readAt?.toIso8601String(), - 'metadata': instance.metadata, - 'attachments': instance.attachments, - 'isEdited': instance.isEdited, - 'editedAt': instance.editedAt?.toIso8601String(), - 'isDeleted': instance.isDeleted, - }; - -const _$MessageTypeEnumMap = { - MessageType.individual: 'individual', - MessageType.broadcast: 'broadcast', - MessageType.targeted: 'targeted', - MessageType.system: 'system', -}; - -const _$MessageStatusEnumMap = { - MessageStatus.sent: 'sent', - MessageStatus.delivered: 'delivered', - MessageStatus.read: 'read', - MessageStatus.failed: 'failed', -}; - -const _$MessagePriorityEnumMap = { - MessagePriority.normal: 'normal', - MessagePriority.high: 'high', - MessagePriority.urgent: 'urgent', -}; +// Modèles v4 : désérialisation manuelle, code generation non utilisé. diff --git a/lib/features/communication/data/repositories/messaging_repository_impl.dart b/lib/features/communication/data/repositories/messaging_repository_impl.dart index cd582b6..69f0648 100644 --- a/lib/features/communication/data/repositories/messaging_repository_impl.dart +++ b/lib/features/communication/data/repositories/messaging_repository_impl.dart @@ -1,416 +1,252 @@ -/// Implémentation du repository de messagerie +/// Implémentation du repository de messagerie v4 library messaging_repository_impl; -import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; + import '../../../../core/error/exceptions.dart'; -import '../../../../core/error/failures.dart'; -import '../../../../core/network/network_info.dart'; import '../../domain/entities/conversation.dart'; import '../../domain/entities/message.dart'; -import '../../domain/entities/message_template.dart'; +import '../../domain/entities/contact_policy.dart'; import '../../domain/repositories/messaging_repository.dart'; import '../datasources/messaging_remote_datasource.dart'; @LazySingleton(as: MessagingRepository) class MessagingRepositoryImpl implements MessagingRepository { final MessagingRemoteDatasource remoteDatasource; - final NetworkInfo networkInfo; - MessagingRepositoryImpl({ - required this.remoteDatasource, - required this.networkInfo, - }); + MessagingRepositoryImpl({required this.remoteDatasource}); @override - Future>> getConversations({ - String? organizationId, - bool includeArchived = false, - }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future> getMesConversations() async { try { - final conversations = await remoteDatasource.getConversations( - organizationId: organizationId, - includeArchived: includeArchived, - ); - return Right(conversations); + return await remoteDatasource.getMesConversations(); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> getConversationById( - String conversationId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future getConversation(String conversationId) async { try { - final conversation = - await remoteDatasource.getConversationById(conversationId); - return Right(conversation); + return await remoteDatasource.getConversation(conversationId); } on NotFoundException { - return Left(NotFoundFailure('Conversation non trouvée')); + throw Exception('Conversation non trouvée'); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> createConversation({ - required String name, - required List participantIds, - String? organizationId, - String? description, + Future demarrerConversationDirecte({ + required String destinataireId, + required String organisationId, + String? premierMessage, }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - try { - final conversation = await remoteDatasource.createConversation( - name: name, - participantIds: participantIds, - organizationId: organizationId, - description: description, + return await remoteDatasource.demarrerConversationDirecte( + destinataireId: destinataireId, + organisationId: organisationId, + premierMessage: premierMessage, ); - return Right(conversation); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future>> getMessages({ - required String conversationId, - int? limit, - String? beforeMessageId, + Future demarrerConversationRole({ + required String roleCible, + required String organisationId, + String? premierMessage, }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - try { - final messages = await remoteDatasource.getMessages( - conversationId: conversationId, - limit: limit, - beforeMessageId: beforeMessageId, + return await remoteDatasource.demarrerConversationRole( + roleCible: roleCible, + organisationId: organisationId, + premierMessage: premierMessage, ); - return Right(messages); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> sendMessage({ - required String conversationId, - required String content, - List? attachments, - MessagePriority priority = MessagePriority.normal, - }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future archiverConversation(String conversationId) async { try { - final message = await remoteDatasource.sendMessage( - conversationId: conversationId, - content: content, - attachments: attachments, - priority: priority, - ); - return Right(message); + await remoteDatasource.archiverConversation(conversationId); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> sendBroadcast({ - required String organizationId, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, - List? attachments, + Future envoyerMessage( + String conversationId, { + required String typeMessage, + String? contenu, + String? urlFichier, + int? dureeAudio, + String? messageParentId, }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - try { - final message = await remoteDatasource.sendBroadcast( - organizationId: organizationId, - subject: subject, - content: content, - priority: priority, - attachments: attachments, + return await remoteDatasource.envoyerMessage( + conversationId, + typeMessage: typeMessage, + contenu: contenu, + urlFichier: urlFichier, + dureeAudio: dureeAudio, + messageParentId: messageParentId, ); - return Right(message); + } on UnauthorizedException { + throw Exception('Session expirée — veuillez vous reconnecter'); } on ForbiddenException catch (e) { - return Left(ForbiddenFailure(e.message)); - } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception(e.message); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> markMessageAsRead(String messageId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future> getMessages(String conversationId, {int page = 0}) async { try { - await remoteDatasource.markMessageAsRead(messageId); - return const Right(null); + return await remoteDatasource.getMessages(conversationId, page: page); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> getUnreadCount({String? organizationId}) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future marquerLu(String conversationId) async { try { - final count = - await remoteDatasource.getUnreadCount(organizationId: organizationId); - return Right(count); - } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); - } on ServerException catch (e) { - return Left(ServerFailure(e.message)); - } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + await remoteDatasource.marquerLu(conversationId); + } catch (_) { + // Non-bloquant — ignorer les erreurs de marquage } } - // === CONVERSATION ACTIONS === - @override - Future> archiveConversation(String conversationId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future supprimerMessage(String conversationId, String messageId) async { try { - await remoteDatasource.archiveConversation(conversationId); - return const Right(null); + await remoteDatasource.supprimerMessage(conversationId, messageId); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> sendTargetedMessage({ - required String organizationId, - required List targetRoles, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, + Future bloquerMembre({ + required String membreABloquerId, + String? organisationId, + String? raison, }) async { - // TODO: Backend needs specific endpoint for targeted messages - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> markConversationAsRead(String conversationId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - try { - await remoteDatasource.markConversationAsRead(conversationId); - return const Right(null); - } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); - } on ServerException catch (e) { - return Left(ServerFailure(e.message)); - } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); - } - } - - @override - Future> toggleMuteConversation(String conversationId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - - try { - await remoteDatasource.toggleMuteConversation(conversationId); - return const Right(null); - } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); - } on ServerException catch (e) { - return Left(ServerFailure(e.message)); - } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); - } - } - - @override - Future> togglePinConversation(String conversationId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - - try { - await remoteDatasource.togglePinConversation(conversationId); - return const Right(null); - } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); - } on ServerException catch (e) { - return Left(ServerFailure(e.message)); - } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); - } - } - - // === MESSAGE ACTIONS === - - @override - Future> editMessage({ - required String messageId, - required String newContent, - }) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - - try { - final message = await remoteDatasource.editMessage( - messageId: messageId, - newContent: newContent, + await remoteDatasource.bloquerMembre( + membreABloquerId: membreABloquerId, + organisationId: organisationId, + raison: raison, ); - return Right(message); - } on NotFoundException { - return Left(NotFoundFailure('Message non trouvé')); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future> deleteMessage(String messageId) async { - if (!await networkInfo.isConnected) { - return Left(NetworkFailure('Pas de connexion Internet')); - } - + Future debloquerMembre(String membreId, {String? organisationId}) async { try { - await remoteDatasource.deleteMessage(messageId); - return const Right(null); - } on NotFoundException { - return Left(NotFoundFailure('Message non trouvé')); + await remoteDatasource.debloquerMembre(membreId, organisationId: organisationId); } on UnauthorizedException { - return Left(UnauthorizedFailure('Session expirée')); + throw Exception('Session expirée — veuillez vous reconnecter'); } on ServerException catch (e) { - return Left(ServerFailure(e.message)); + throw Exception(e.message); } catch (e) { - return Left(UnexpectedFailure('Erreur inattendue: $e')); + throw Exception('Erreur inattendue: $e'); } } @override - Future>> getTemplates({ - String? organizationId, - TemplateCategory? category, + Future>> getMesBlocages() async { + try { + return await remoteDatasource.getMesBlocages(); + } on UnauthorizedException { + throw Exception('Session expirée — veuillez vous reconnecter'); + } on ServerException catch (e) { + throw Exception(e.message); + } catch (e) { + throw Exception('Erreur inattendue: $e'); + } + } + + @override + Future getPolitique(String organisationId) async { + try { + return await remoteDatasource.getPolitique(organisationId); + } on UnauthorizedException { + throw Exception('Session expirée — veuillez vous reconnecter'); + } on ServerException catch (e) { + throw Exception(e.message); + } catch (e) { + throw Exception('Erreur inattendue: $e'); + } + } + + @override + Future mettreAJourPolitique( + String organisationId, { + required String typePolitique, + required bool autoriserMembreVersMembre, + required bool autoriserMembreVersRole, + required bool autoriserNotesVocales, }) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> getTemplateById(String templateId) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> createTemplate({ - required String name, - required String description, - required TemplateCategory category, - required String subject, - required String body, - List>? variables, - String? organizationId, - }) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> updateTemplate({ - required String templateId, - String? name, - String? description, - String? subject, - String? body, - bool? isActive, - }) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> deleteTemplate(String templateId) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future> sendFromTemplate({ - required String templateId, - required Map variables, - required List recipientIds, - }) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); - } - - @override - Future>> getMessagingStats({ - required String organizationId, - DateTime? startDate, - DateTime? endDate, - }) async { - return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); + try { + return await remoteDatasource.mettreAJourPolitique( + organisationId, + typePolitique: typePolitique, + autoriserMembreVersMembre: autoriserMembreVersMembre, + autoriserMembreVersRole: autoriserMembreVersRole, + autoriserNotesVocales: autoriserNotesVocales, + ); + } on UnauthorizedException { + throw Exception('Session expirée — veuillez vous reconnecter'); + } on ForbiddenException catch (e) { + throw Exception(e.message); + } on ServerException catch (e) { + throw Exception(e.message); + } catch (e) { + throw Exception('Erreur inattendue: $e'); + } } } diff --git a/lib/features/communication/domain/entities/contact_policy.dart b/lib/features/communication/domain/entities/contact_policy.dart new file mode 100644 index 0000000..a5bea6c --- /dev/null +++ b/lib/features/communication/domain/entities/contact_policy.dart @@ -0,0 +1,35 @@ +/// Entité métier ContactPolicy v4 +/// +/// Correspond au DTO backend : ContactPolicyResponse +library contact_policy; + +import 'package:equatable/equatable.dart'; + +/// Politique de communication d'une organisation +class ContactPolicy extends Equatable { + final String? id; + final String? organisationId; + final String typePolitique; // OUVERT | BUREAU_SEULEMENT | GROUPES_INTERNES + final bool autoriserMembreVersMembre; + final bool autoriserMembreVersRole; + final bool autoriserNotesVocales; + + const ContactPolicy({ + this.id, + this.organisationId, + required this.typePolitique, + this.autoriserMembreVersMembre = true, + this.autoriserMembreVersRole = true, + this.autoriserNotesVocales = true, + }); + + bool get isOuvert => typePolitique == 'OUVERT'; + bool get isBureauSeulement => typePolitique == 'BUREAU_SEULEMENT'; + bool get isGroupesInternes => typePolitique == 'GROUPES_INTERNES'; + + @override + List get props => [ + id, organisationId, typePolitique, + autoriserMembreVersMembre, autoriserMembreVersRole, autoriserNotesVocales, + ]; +} diff --git a/lib/features/communication/domain/entities/conversation.dart b/lib/features/communication/domain/entities/conversation.dart index 0a67896..553d2a6 100644 --- a/lib/features/communication/domain/entities/conversation.dart +++ b/lib/features/communication/domain/entities/conversation.dart @@ -1,127 +1,120 @@ -/// Entité métier Conversation +/// Entités métier Conversation v4 /// -/// Représente une conversation (fil de messages) dans UnionFlow +/// Correspond aux DTOs backend : ConversationSummaryResponse et ConversationResponse library conversation; import 'package:equatable/equatable.dart'; import 'message.dart'; -/// Type de conversation -enum ConversationType { - /// Conversation individuelle (1-1) - individual, +// ── Résumé de conversation (liste) ─────────────────────────────────────────── - /// Conversation de groupe - group, - - /// Canal broadcast (lecture seule pour la plupart) - broadcast, - - /// Canal d'annonces organisation - announcement, -} - -/// Entité Conversation -class Conversation extends Equatable { +/// Résumé d'une conversation pour l'affichage en liste +class ConversationSummary extends Equatable { final String id; - final String name; - final String? description; - final ConversationType type; - final List participantIds; - final String? organizationId; - final Message? lastMessage; - final int unreadCount; - final bool isMuted; - final bool isPinned; - final bool isArchived; - final DateTime createdAt; - final DateTime? updatedAt; - final String? avatarUrl; - final Map? metadata; + final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE + final String titre; + final String statut; // ACTIVE | ARCHIVEE + final String? dernierMessageApercu; + final String? dernierMessageType; + final DateTime? dernierMessageAt; + final int nonLus; + final String? organisationId; - const Conversation({ + const ConversationSummary({ required this.id, - required this.name, - this.description, - required this.type, - required this.participantIds, - this.organizationId, - this.lastMessage, - this.unreadCount = 0, - this.isMuted = false, - this.isPinned = false, - this.isArchived = false, - required this.createdAt, - this.updatedAt, - this.avatarUrl, - this.metadata, + required this.typeConversation, + required this.titre, + required this.statut, + this.dernierMessageApercu, + this.dernierMessageType, + this.dernierMessageAt, + this.nonLus = 0, + this.organisationId, }); - /// Vérifie si la conversation a des messages non lus - bool get hasUnread => unreadCount > 0; - - /// Vérifie si c'est une conversation individuelle - bool get isIndividual => type == ConversationType.individual; - - /// Vérifie si c'est un broadcast - bool get isBroadcast => type == ConversationType.broadcast; - - /// Nombre de participants - int get participantCount => participantIds.length; - - /// Copie avec modifications - Conversation copyWith({ - String? id, - String? name, - String? description, - ConversationType? type, - List? participantIds, - String? organizationId, - Message? lastMessage, - int? unreadCount, - bool? isMuted, - bool? isPinned, - bool? isArchived, - DateTime? createdAt, - DateTime? updatedAt, - String? avatarUrl, - Map? metadata, - }) { - return Conversation( - id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, - type: type ?? this.type, - participantIds: participantIds ?? this.participantIds, - organizationId: organizationId ?? this.organizationId, - lastMessage: lastMessage ?? this.lastMessage, - unreadCount: unreadCount ?? this.unreadCount, - isMuted: isMuted ?? this.isMuted, - isPinned: isPinned ?? this.isPinned, - isArchived: isArchived ?? this.isArchived, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - avatarUrl: avatarUrl ?? this.avatarUrl, - metadata: metadata ?? this.metadata, - ); - } + bool get hasUnread => nonLus > 0; + bool get isDirecte => typeConversation == 'DIRECTE'; + bool get isRoleCanal => typeConversation == 'ROLE_CANAL'; + bool get isGroupe => typeConversation == 'GROUPE'; + bool get isActive => statut == 'ACTIVE'; @override List get props => [ - id, - name, - description, - type, - participantIds, - organizationId, - lastMessage, - unreadCount, - isMuted, - isPinned, - isArchived, - createdAt, - updatedAt, - avatarUrl, - metadata, + id, typeConversation, titre, statut, + dernierMessageApercu, dernierMessageType, dernierMessageAt, + nonLus, organisationId, + ]; +} + +// ── Participant ─────────────────────────────────────────────────────────────── + +/// Participant dans une conversation +class ConversationParticipant extends Equatable { + final String membreId; + final String? prenom; + final String? nom; + final String? roleDansConversation; + final DateTime? luJusqua; + + const ConversationParticipant({ + required this.membreId, + this.prenom, + this.nom, + this.roleDansConversation, + this.luJusqua, + }); + + String get nomComplet { + if (prenom != null && nom != null) return '$prenom $nom'; + if (prenom != null) return prenom!; + if (nom != null) return nom!; + return membreId; + } + + @override + List get props => [membreId, prenom, nom, roleDansConversation, luJusqua]; +} + +// ── Conversation complète (détail) ─────────────────────────────────────────── + +/// Conversation complète avec participants et messages +class Conversation extends Equatable { + final String id; + final String typeConversation; + final String titre; + final String statut; + final String? organisationId; + final String? organisationNom; + final DateTime? dateCreation; + final int nombreMessages; + final List participants; + final List messages; + final int nonLus; + final String? roleCible; + + const Conversation({ + required this.id, + required this.typeConversation, + required this.titre, + required this.statut, + this.organisationId, + this.organisationNom, + this.dateCreation, + this.nombreMessages = 0, + this.participants = const [], + this.messages = const [], + this.nonLus = 0, + this.roleCible, + }); + + bool get isDirecte => typeConversation == 'DIRECTE'; + bool get isRoleCanal => typeConversation == 'ROLE_CANAL'; + bool get isGroupe => typeConversation == 'GROUPE'; + bool get isActive => statut == 'ACTIVE'; + + @override + List get props => [ + id, typeConversation, titre, statut, organisationId, organisationNom, + dateCreation, nombreMessages, participants, messages, nonLus, roleCible, ]; } diff --git a/lib/features/communication/domain/entities/message.dart b/lib/features/communication/domain/entities/message.dart index e1b0704..4079eb1 100644 --- a/lib/features/communication/domain/entities/message.dart +++ b/lib/features/communication/domain/entities/message.dart @@ -1,173 +1,68 @@ -/// Entité métier Message +/// Entité métier Message v4 /// -/// Représente un message dans le système de communication UnionFlow +/// Correspond au DTO backend : MessageResponse library message; import 'package:equatable/equatable.dart'; -/// Type de message -enum MessageType { - /// Message individuel (membre à membre) - individual, - - /// Broadcast organisation (OrgAdmin → tous) - broadcast, - - /// Message ciblé par rôle (Moderator → groupe) - targeted, - - /// Notification système - system, -} - -/// Statut de lecture du message -enum MessageStatus { - /// Envoyé mais non lu - sent, - - /// Livré (reçu par le serveur) - delivered, - - /// Lu par le destinataire - read, - - /// Échec d'envoi - failed, -} - -/// Priorité du message -enum MessagePriority { - /// Priorité normale - normal, - - /// Priorité élevée (important) - high, - - /// Priorité urgente (critique) - urgent, -} - -/// Entité Message +/// Message dans une conversation class Message extends Equatable { final String id; - final String conversationId; - final String senderId; - final String senderName; - final String? senderAvatar; - final String content; - final MessageType type; - final MessageStatus status; - final MessagePriority priority; - final List recipientIds; - final List? recipientRoles; - final String? organizationId; - final DateTime createdAt; - final DateTime? readAt; - final Map? metadata; - final List? attachments; - final bool isEdited; - final DateTime? editedAt; - final bool isDeleted; + final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME + final String? contenu; + final String? urlFichier; + final int? dureeAudio; + final bool supprime; + final String? expediteurId; + final String? expediteurNom; + final String? expediteurPrenom; + final String? messageParentId; + final String? messageParentApercu; + final DateTime? dateEnvoi; const Message({ required this.id, - required this.conversationId, - required this.senderId, - required this.senderName, - this.senderAvatar, - required this.content, - required this.type, - required this.status, - this.priority = MessagePriority.normal, - required this.recipientIds, - this.recipientRoles, - this.organizationId, - required this.createdAt, - this.readAt, - this.metadata, - this.attachments, - this.isEdited = false, - this.editedAt, - this.isDeleted = false, + required this.typeMessage, + this.contenu, + this.urlFichier, + this.dureeAudio, + this.supprime = false, + this.expediteurId, + this.expediteurNom, + this.expediteurPrenom, + this.messageParentId, + this.messageParentApercu, + this.dateEnvoi, }); - /// Vérifie si le message a été lu - bool get isRead => status == MessageStatus.read; + String get expediteurNomComplet { + if (expediteurPrenom != null && expediteurNom != null) { + return '$expediteurPrenom $expediteurNom'; + } + if (expediteurPrenom != null) return expediteurPrenom!; + if (expediteurNom != null) return expediteurNom!; + return ''; + } - /// Vérifie si le message est urgent - bool get isUrgent => priority == MessagePriority.urgent; + bool get isTexte => typeMessage == 'TEXTE'; + bool get isVocal => typeMessage == 'VOCAL'; + bool get isImage => typeMessage == 'IMAGE'; + bool get isSysteme => typeMessage == 'SYSTEME'; + bool get hasParent => messageParentId != null; - /// Vérifie si le message est un broadcast - bool get isBroadcast => type == MessageType.broadcast; - - /// Vérifie si le message a des pièces jointes - bool get hasAttachments => attachments != null && attachments!.isNotEmpty; - - /// Copie avec modifications - Message copyWith({ - String? id, - String? conversationId, - String? senderId, - String? senderName, - String? senderAvatar, - String? content, - MessageType? type, - MessageStatus? status, - MessagePriority? priority, - List? recipientIds, - List? recipientRoles, - String? organizationId, - DateTime? createdAt, - DateTime? readAt, - Map? metadata, - List? attachments, - bool? isEdited, - DateTime? editedAt, - bool? isDeleted, - }) { - return Message( - id: id ?? this.id, - conversationId: conversationId ?? this.conversationId, - senderId: senderId ?? this.senderId, - senderName: senderName ?? this.senderName, - senderAvatar: senderAvatar ?? this.senderAvatar, - content: content ?? this.content, - type: type ?? this.type, - status: status ?? this.status, - priority: priority ?? this.priority, - recipientIds: recipientIds ?? this.recipientIds, - recipientRoles: recipientRoles ?? this.recipientRoles, - organizationId: organizationId ?? this.organizationId, - createdAt: createdAt ?? this.createdAt, - readAt: readAt ?? this.readAt, - metadata: metadata ?? this.metadata, - attachments: attachments ?? this.attachments, - isEdited: isEdited ?? this.isEdited, - editedAt: editedAt ?? this.editedAt, - isDeleted: isDeleted ?? this.isDeleted, - ); + /// Texte à afficher dans la liste (aperçu) + String get apercu { + if (supprime) return '🚫 Message supprimé'; + if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}'; + if (isImage) return '📷 Image'; + if (isSysteme) return contenu ?? '🔔 Notification système'; + return contenu ?? ''; } @override List get props => [ - id, - conversationId, - senderId, - senderName, - senderAvatar, - content, - type, - status, - priority, - recipientIds, - recipientRoles, - organizationId, - createdAt, - readAt, - metadata, - attachments, - isEdited, - editedAt, - isDeleted, + id, typeMessage, contenu, urlFichier, dureeAudio, supprime, + expediteurId, expediteurNom, expediteurPrenom, + messageParentId, messageParentApercu, dateEnvoi, ]; } diff --git a/lib/features/communication/domain/repositories/messaging_repository.dart b/lib/features/communication/domain/repositories/messaging_repository.dart index 66496bd..5a45eb0 100644 --- a/lib/features/communication/domain/repositories/messaging_repository.dart +++ b/lib/features/communication/domain/repositories/messaging_repository.dart @@ -1,145 +1,87 @@ -/// Repository interface pour la communication +/// Repository interface pour la messagerie v4 /// -/// Contrat de données pour les messages, conversations et templates +/// Contrat de données aligné sur l'API backend v4 (/api/messagerie/*) library messaging_repository; -import 'package:dartz/dartz.dart'; -import '../../../../core/error/failures.dart'; -import '../entities/message.dart'; import '../entities/conversation.dart'; -import '../entities/message_template.dart'; +import '../entities/message.dart'; +import '../entities/contact_policy.dart'; /// Interface du repository de messagerie abstract class MessagingRepository { - // === CONVERSATIONS === - /// Récupère toutes les conversations de l'utilisateur - Future>> getConversations({ - String? organizationId, - bool includeArchived = false, + // ── Conversations ───────────────────────────────────────────────────────── + + /// Récupère les conversations résumées de l'utilisateur connecté + Future> getMesConversations(); + + /// Récupère la conversation complète (avec participants et messages) + Future getConversation(String conversationId); + + /// Démarre une conversation directe avec un membre + Future demarrerConversationDirecte({ + required String destinataireId, + required String organisationId, + String? premierMessage, }); - /// Récupère une conversation par son ID - Future> getConversationById(String conversationId); - - /// Crée une nouvelle conversation - Future> createConversation({ - required String name, - required List participantIds, - String? organizationId, - String? description, + /// Démarre un canal de communication avec un rôle officiel + Future demarrerConversationRole({ + required String roleCible, + required String organisationId, + String? premierMessage, }); /// Archive une conversation - Future> archiveConversation(String conversationId); + Future archiverConversation(String conversationId); - /// Marque une conversation comme lue - Future> markConversationAsRead(String conversationId); + // ── Messages ────────────────────────────────────────────────────────────── - /// Mute/démute une conversation - Future> toggleMuteConversation(String conversationId); - - /// Pin/unpin une conversation - Future> togglePinConversation(String conversationId); - - // === MESSAGES === - - /// Récupère les messages d'une conversation - Future>> getMessages({ - required String conversationId, - int? limit, - String? beforeMessageId, + /// Envoie un message dans une conversation + Future envoyerMessage( + String conversationId, { + required String typeMessage, + String? contenu, + String? urlFichier, + int? dureeAudio, + String? messageParentId, }); - /// Envoie un message individuel - Future> sendMessage({ - required String conversationId, - required String content, - List? attachments, - MessagePriority priority = MessagePriority.normal, + /// Récupère l'historique des messages (paginé) + Future> getMessages(String conversationId, {int page = 0}); + + /// Marque tous les messages d'une conversation comme lus + Future marquerLu(String conversationId); + + /// Supprime un message (soft-delete) + Future supprimerMessage(String conversationId, String messageId); + + // ── Blocages ────────────────────────────────────────────────────────────── + + /// Bloque un membre + Future bloquerMembre({ + required String membreABloquerId, + String? organisationId, + String? raison, }); - /// Envoie un broadcast à toute l'organisation - Future> sendBroadcast({ - required String organizationId, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, - List? attachments, - }); + /// Débloque un membre + Future debloquerMembre(String membreId, {String? organisationId}); - /// Envoie un message ciblé par rôles - Future> sendTargetedMessage({ - required String organizationId, - required List targetRoles, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, - }); + /// Récupère la liste des membres bloqués + Future>> getMesBlocages(); - /// Marque un message comme lu - Future> markMessageAsRead(String messageId); + // ── Politique de communication ──────────────────────────────────────────── - /// Édite un message - Future> editMessage({ - required String messageId, - required String newContent, - }); + /// Récupère la politique de communication d'une organisation + Future getPolitique(String organisationId); - /// Supprime un message - Future> deleteMessage(String messageId); - - // === TEMPLATES === - - /// Récupère tous les templates disponibles - Future>> getTemplates({ - String? organizationId, - TemplateCategory? category, - }); - - /// Récupère un template par son ID - Future> getTemplateById(String templateId); - - /// Crée un nouveau template - Future> createTemplate({ - required String name, - required String description, - required TemplateCategory category, - required String subject, - required String body, - List>? variables, - String? organizationId, - }); - - /// Met à jour un template - Future> updateTemplate({ - required String templateId, - String? name, - String? description, - String? subject, - String? body, - bool? isActive, - }); - - /// Supprime un template - Future> deleteTemplate(String templateId); - - /// Envoie un message à partir d'un template - Future> sendFromTemplate({ - required String templateId, - required Map variables, - required List recipientIds, - }); - - // === STATISTIQUES === - - /// Récupère le nombre de messages non lus - Future> getUnreadCount({String? organizationId}); - - /// Récupère les statistiques de communication - Future>> getMessagingStats({ - required String organizationId, - DateTime? startDate, - DateTime? endDate, + /// Met à jour la politique de communication (ADMIN seulement) + Future mettreAJourPolitique( + String organisationId, { + required String typePolitique, + required bool autoriserMembreVersMembre, + required bool autoriserMembreVersRole, + required bool autoriserNotesVocales, }); } diff --git a/lib/features/communication/domain/usecases/get_conversations.dart b/lib/features/communication/domain/usecases/get_conversations.dart index 5e91e16..7695f78 100644 --- a/lib/features/communication/domain/usecases/get_conversations.dart +++ b/lib/features/communication/domain/usecases/get_conversations.dart @@ -1,9 +1,8 @@ -/// Use case: Récupérer les conversations +/// Use case: Récupérer les conversations v4 library get_conversations; -import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; -import '../../../../core/error/failures.dart'; + import '../entities/conversation.dart'; import '../repositories/messaging_repository.dart'; @@ -13,13 +12,7 @@ class GetConversations { GetConversations(this.repository); - Future>> call({ - String? organizationId, - bool includeArchived = false, - }) async { - return await repository.getConversations( - organizationId: organizationId, - includeArchived: includeArchived, - ); + Future> call() async { + return await repository.getMesConversations(); } } diff --git a/lib/features/communication/domain/usecases/get_messages.dart b/lib/features/communication/domain/usecases/get_messages.dart index c603ec7..57200b5 100644 --- a/lib/features/communication/domain/usecases/get_messages.dart +++ b/lib/features/communication/domain/usecases/get_messages.dart @@ -1,9 +1,8 @@ -/// Use case: Récupérer les messages d'une conversation +/// Use case: Récupérer les messages d'une conversation v4 library get_messages; -import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; -import '../../../../core/error/failures.dart'; + import '../entities/message.dart'; import '../repositories/messaging_repository.dart'; @@ -13,19 +12,10 @@ class GetMessages { GetMessages(this.repository); - Future>> call({ + Future> call({ required String conversationId, - int? limit, - String? beforeMessageId, + int page = 0, }) async { - if (conversationId.isEmpty) { - return Left(ValidationFailure('ID conversation requis')); - } - - return await repository.getMessages( - conversationId: conversationId, - limit: limit, - beforeMessageId: beforeMessageId, - ); + return await repository.getMessages(conversationId, page: page); } } diff --git a/lib/features/communication/domain/usecases/send_broadcast.dart b/lib/features/communication/domain/usecases/send_broadcast.dart index 8b5f15c..a37f02c 100644 --- a/lib/features/communication/domain/usecases/send_broadcast.dart +++ b/lib/features/communication/domain/usecases/send_broadcast.dart @@ -1,10 +1,10 @@ -/// Use case: Envoyer un broadcast organisation +/// Use case: Broadcast — non utilisé en v4 (remplacé par canal rôle) +/// +/// Conservé pour la compatibilité du graphe de dépendances. library send_broadcast; -import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; -import '../../../../core/error/failures.dart'; -import '../entities/message.dart'; + import '../repositories/messaging_repository.dart'; @lazySingleton @@ -13,32 +13,13 @@ class SendBroadcast { SendBroadcast(this.repository); - Future> call({ - required String organizationId, - required String subject, - required String content, - MessagePriority priority = MessagePriority.normal, - List? attachments, + /// Démarre un canal de communication avec le rôle BUREAU + Future call({ + required String organisationId, }) async { - // Validation - if (subject.trim().isEmpty) { - return Left(ValidationFailure('Le sujet ne peut pas être vide')); - } - - if (content.trim().isEmpty) { - return Left(ValidationFailure('Le message ne peut pas être vide')); - } - - if (organizationId.isEmpty) { - return Left(ValidationFailure('ID organisation requis')); - } - - return await repository.sendBroadcast( - organizationId: organizationId, - subject: subject, - content: content, - priority: priority, - attachments: attachments, + await repository.demarrerConversationRole( + roleCible: 'PRESIDENT', + organisationId: organisationId, ); } } diff --git a/lib/features/communication/domain/usecases/send_message.dart b/lib/features/communication/domain/usecases/send_message.dart index 0656834..2b2121d 100644 --- a/lib/features/communication/domain/usecases/send_message.dart +++ b/lib/features/communication/domain/usecases/send_message.dart @@ -1,9 +1,8 @@ -/// Use case: Envoyer un message +/// Use case: Envoyer un message v4 library send_message; -import 'package:dartz/dartz.dart'; import 'package:injectable/injectable.dart'; -import '../../../../core/error/failures.dart'; + import '../entities/message.dart'; import '../repositories/messaging_repository.dart'; @@ -13,22 +12,15 @@ class SendMessage { SendMessage(this.repository); - Future> call({ + Future call({ required String conversationId, - required String content, - List? attachments, - MessagePriority priority = MessagePriority.normal, + required String contenu, + String typeMessage = 'TEXTE', }) async { - // Validation - if (content.trim().isEmpty) { - return Left(ValidationFailure('Le message ne peut pas être vide')); - } - - return await repository.sendMessage( - conversationId: conversationId, - content: content, - attachments: attachments, - priority: priority, + return await repository.envoyerMessage( + conversationId, + typeMessage: typeMessage, + contenu: contenu, ); } } diff --git a/lib/features/communication/presentation/bloc/messaging_bloc.dart b/lib/features/communication/presentation/bloc/messaging_bloc.dart index be0e319..06b9422 100644 --- a/lib/features/communication/presentation/bloc/messaging_bloc.dart +++ b/lib/features/communication/presentation/bloc/messaging_bloc.dart @@ -1,105 +1,202 @@ -/// BLoC de gestion de la messagerie +/// BLoC de gestion de la messagerie v4 library messaging_bloc; +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; -import '../../domain/usecases/get_conversations.dart'; -import '../../domain/usecases/get_messages.dart'; -import '../../domain/usecases/send_message.dart'; -import '../../domain/usecases/send_broadcast.dart'; + +import '../../../../core/utils/logger.dart'; +import '../../../../core/websocket/websocket_service.dart'; +import '../../domain/repositories/messaging_repository.dart'; import 'messaging_event.dart'; import 'messaging_state.dart'; @injectable class MessagingBloc extends Bloc { - final GetConversations getConversations; - final GetMessages getMessages; - final SendMessage sendMessage; - final SendBroadcast sendBroadcast; + final MessagingRepository _repository; + final WebSocketService _webSocketService; + + StreamSubscription? _wsSubscription; + String? _currentConversationId; MessagingBloc({ - required this.getConversations, - required this.getMessages, - required this.sendMessage, - required this.sendBroadcast, - }) : super(MessagingInitial()) { - on(_onLoadConversations); + required MessagingRepository repository, + required WebSocketService webSocketService, + }) : _repository = repository, + _webSocketService = webSocketService, + super(MessagingInitial()) { + on(_onLoadMesConversations); + on(_onOpenConversation); + on(_onDemarrerConversationDirecte); + on(_onDemarrerConversationRole); + on(_onArchiverConversation); + on(_onEnvoyerMessageTexte); on(_onLoadMessages); - on(_onSendMessage); - on(_onSendBroadcast); + on(_onMarquerLu); + on(_onSupprimerMessage); + on(_onNouveauMessageWebSocket); + + _listenWebSocket(); } - Future _onLoadConversations( - LoadConversations event, + void _listenWebSocket() { + _wsSubscription = _webSocketService.eventStream.listen((event) { + if (event is ChatMessageEvent && event.conversationId != null) { + AppLogger.info('MessagingBloc: NOUVEAU_MESSAGE WS pour conv ${event.conversationId}'); + add(NouveauMessageWebSocket(event.conversationId!)); + } + }); + } + + Future _onLoadMesConversations( + LoadMesConversations event, Emitter emit, ) async { emit(MessagingLoading()); + try { + final conversations = await _repository.getMesConversations(); + emit(MesConversationsLoaded(conversations)); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } - final result = await getConversations( - organizationId: event.organizationId, - includeArchived: event.includeArchived, - ); + Future _onOpenConversation( + OpenConversation event, + Emitter emit, + ) async { + emit(MessagingLoading()); + try { + _currentConversationId = event.conversationId; + final conversation = await _repository.getConversation(event.conversationId); + emit(ConversationOuverte(conversation)); + // Marquer comme lu en arrière-plan (non-bloquant) + _repository.marquerLu(event.conversationId); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } - result.fold( - (failure) => emit(MessagingError(failure.message)), - (conversations) => emit(ConversationsLoaded(conversations: conversations)), - ); + Future _onDemarrerConversationDirecte( + DemarrerConversationDirecte event, + Emitter emit, + ) async { + emit(MessagingLoading()); + try { + final conversation = await _repository.demarrerConversationDirecte( + destinataireId: event.destinataireId, + organisationId: event.organisationId, + premierMessage: event.premierMessage, + ); + emit(ConversationCreee(conversation)); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } + + Future _onDemarrerConversationRole( + DemarrerConversationRole event, + Emitter emit, + ) async { + emit(MessagingLoading()); + try { + final conversation = await _repository.demarrerConversationRole( + roleCible: event.roleCible, + organisationId: event.organisationId, + premierMessage: event.premierMessage, + ); + emit(ConversationCreee(conversation)); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } + + Future _onArchiverConversation( + ArchiverConversation event, + Emitter emit, + ) async { + try { + await _repository.archiverConversation(event.conversationId); + emit(const MessagingActionOk('archiver')); + add(const LoadMesConversations()); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } + + Future _onEnvoyerMessageTexte( + EnvoyerMessageTexte event, + Emitter emit, + ) async { + try { + final message = await _repository.envoyerMessage( + event.conversationId, + typeMessage: 'TEXTE', + contenu: event.contenu, + messageParentId: event.messageParentId, + ); + emit(MessageEnvoye(message: message, conversationId: event.conversationId)); + // Recharger les messages après envoi + add(LoadMessages(conversationId: event.conversationId)); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } } Future _onLoadMessages( LoadMessages event, Emitter emit, ) async { - emit(MessagingLoading()); - - final result = await getMessages( - conversationId: event.conversationId, - limit: event.limit, - beforeMessageId: event.beforeMessageId, - ); - - result.fold( - (failure) => emit(MessagingError(failure.message)), - (messages) => emit(MessagesLoaded( + try { + final messages = await _repository.getMessages(event.conversationId, page: event.page); + emit(MessagesLoaded( conversationId: event.conversationId, messages: messages, - hasMore: messages.length == (event.limit ?? 50), - )), - ); + hasMore: messages.length >= 20, + )); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } } - Future _onSendMessage( - SendMessageEvent event, + Future _onMarquerLu( + MarquerLu event, Emitter emit, ) async { - final result = await sendMessage( - conversationId: event.conversationId, - content: event.content, - attachments: event.attachments, - priority: event.priority, - ); - - result.fold( - (failure) => emit(MessagingError(failure.message)), - (message) => emit(MessageSent(message)), - ); + await _repository.marquerLu(event.conversationId); } - Future _onSendBroadcast( - SendBroadcastEvent event, + Future _onSupprimerMessage( + SupprimerMessage event, Emitter emit, ) async { - final result = await sendBroadcast( - organizationId: event.organizationId, - subject: event.subject, - content: event.content, - priority: event.priority, - attachments: event.attachments, - ); + try { + await _repository.supprimerMessage(event.conversationId, event.messageId); + emit(const MessagingActionOk('supprimer-message')); + add(LoadMessages(conversationId: event.conversationId)); + } catch (e) { + emit(MessagingError(e.toString().replaceFirst('Exception: ', ''))); + } + } - result.fold( - (failure) => emit(MessagingError(failure.message)), - (message) => emit(BroadcastSent(message)), - ); + Future _onNouveauMessageWebSocket( + NouveauMessageWebSocket event, + Emitter emit, + ) async { + // Si la conversation est actuellement ouverte, recharger les messages + if (_currentConversationId == event.conversationId) { + add(LoadMessages(conversationId: event.conversationId)); + } else { + // Sinon, rafraîchir la liste pour mettre à jour le badge non-lus + add(const LoadMesConversations()); + } + } + + @override + Future close() { + _wsSubscription?.cancel(); + _currentConversationId = null; + return super.close(); } } diff --git a/lib/features/communication/presentation/bloc/messaging_event.dart b/lib/features/communication/presentation/bloc/messaging_event.dart index 1c23b5c..2ddd577 100644 --- a/lib/features/communication/presentation/bloc/messaging_event.dart +++ b/lib/features/communication/presentation/bloc/messaging_event.dart @@ -1,8 +1,7 @@ -/// Événements du BLoC Messaging +/// Événements du BLoC Messagerie v4 library messaging_event; import 'package:equatable/equatable.dart'; -import '../../domain/entities/message.dart'; abstract class MessagingEvent extends Equatable { const MessagingEvent(); @@ -11,108 +10,126 @@ abstract class MessagingEvent extends Equatable { List get props => []; } -/// Charger les conversations -class LoadConversations extends MessagingEvent { - final String? organizationId; - final bool includeArchived; +// ── Conversations ───────────────────────────────────────────────────────────── - const LoadConversations({ - this.organizationId, - this.includeArchived = false, +/// Charger la liste des conversations +class LoadMesConversations extends MessagingEvent { + const LoadMesConversations(); +} + +/// Ouvrir une conversation (charge le détail) +class OpenConversation extends MessagingEvent { + final String conversationId; + + const OpenConversation(this.conversationId); + + @override + List get props => [conversationId]; +} + +/// Démarrer une conversation directe avec un membre +class DemarrerConversationDirecte extends MessagingEvent { + final String destinataireId; + final String organisationId; + final String? premierMessage; + + const DemarrerConversationDirecte({ + required this.destinataireId, + required this.organisationId, + this.premierMessage, }); @override - List get props => [organizationId, includeArchived]; + List get props => [destinataireId, organisationId, premierMessage]; } -/// Charger les messages d'une conversation +/// Démarrer un canal de communication avec un rôle +class DemarrerConversationRole extends MessagingEvent { + final String roleCible; + final String organisationId; + final String? premierMessage; + + const DemarrerConversationRole({ + required this.roleCible, + required this.organisationId, + this.premierMessage, + }); + + @override + List get props => [roleCible, organisationId, premierMessage]; +} + +/// Archiver une conversation +class ArchiverConversation extends MessagingEvent { + final String conversationId; + + const ArchiverConversation(this.conversationId); + + @override + List get props => [conversationId]; +} + +// ── Messages ────────────────────────────────────────────────────────────────── + +/// Envoyer un message texte +class EnvoyerMessageTexte extends MessagingEvent { + final String conversationId; + final String contenu; + final String? messageParentId; + + const EnvoyerMessageTexte({ + required this.conversationId, + required this.contenu, + this.messageParentId, + }); + + @override + List get props => [conversationId, contenu, messageParentId]; +} + +/// Charger l'historique des messages class LoadMessages extends MessagingEvent { final String conversationId; - final int? limit; - final String? beforeMessageId; + final int page; - const LoadMessages({ - required this.conversationId, - this.limit, - this.beforeMessageId, - }); + const LoadMessages({required this.conversationId, this.page = 0}); @override - List get props => [conversationId, limit, beforeMessageId]; + List get props => [conversationId, page]; } -/// Envoyer un message -class SendMessageEvent extends MessagingEvent { +/// Marquer une conversation comme lue +class MarquerLu extends MessagingEvent { final String conversationId; - final String content; - final List? attachments; - final MessagePriority priority; - const SendMessageEvent({ - required this.conversationId, - required this.content, - this.attachments, - this.priority = MessagePriority.normal, - }); + const MarquerLu(this.conversationId); @override - List get props => [conversationId, content, attachments, priority]; + List get props => [conversationId]; } -/// Envoyer un broadcast -class SendBroadcastEvent extends MessagingEvent { - final String organizationId; - final String subject; - final String content; - final MessagePriority priority; - final List? attachments; - - const SendBroadcastEvent({ - required this.organizationId, - required this.subject, - required this.content, - this.priority = MessagePriority.normal, - this.attachments, - }); - - @override - List get props => [organizationId, subject, content, priority, attachments]; -} - -/// Marquer un message comme lu -class MarkMessageAsReadEvent extends MessagingEvent { +/// Supprimer un message +class SupprimerMessage extends MessagingEvent { + final String conversationId; final String messageId; - const MarkMessageAsReadEvent(this.messageId); - - @override - List get props => [messageId]; -} - -/// Charger le nombre de messages non lus -class LoadUnreadCount extends MessagingEvent { - final String? organizationId; - - const LoadUnreadCount({this.organizationId}); - - @override - List get props => [organizationId]; -} - -/// Créer une nouvelle conversation -class CreateConversationEvent extends MessagingEvent { - final String name; - final List participantIds; - final String? organizationId; - final String? description; - - const CreateConversationEvent({ - required this.name, - required this.participantIds, - this.organizationId, - this.description, + const SupprimerMessage({ + required this.conversationId, + required this.messageId, }); @override - List get props => [name, participantIds, organizationId, description]; + List get props => [conversationId, messageId]; +} + +// ── Temps réel WebSocket ─────────────────────────────────────────────────────── + +/// Nouveau message reçu via WebSocket +class NouveauMessageWebSocket extends MessagingEvent { + final String conversationId; + + const NouveauMessageWebSocket(this.conversationId); + + @override + List get props => [conversationId]; } diff --git a/lib/features/communication/presentation/bloc/messaging_state.dart b/lib/features/communication/presentation/bloc/messaging_state.dart index 843eeb0..b84a8cf 100644 --- a/lib/features/communication/presentation/bloc/messaging_state.dart +++ b/lib/features/communication/presentation/bloc/messaging_state.dart @@ -1,7 +1,8 @@ -/// États du BLoC Messaging +/// États du BLoC Messagerie v4 library messaging_state; import 'package:equatable/equatable.dart'; + import '../../domain/entities/conversation.dart'; import '../../domain/entities/message.dart'; @@ -18,18 +19,24 @@ class MessagingInitial extends MessagingState {} /// Chargement en cours class MessagingLoading extends MessagingState {} -/// Conversations chargées -class ConversationsLoaded extends MessagingState { - final List conversations; - final int unreadCount; +/// Liste des conversations chargée +class MesConversationsLoaded extends MessagingState { + final List conversations; - const ConversationsLoaded({ - required this.conversations, - this.unreadCount = 0, - }); + const MesConversationsLoaded(this.conversations); @override - List get props => [conversations, unreadCount]; + List get props => [conversations]; +} + +/// Conversation ouverte (détail avec messages) +class ConversationOuverte extends MessagingState { + final Conversation conversation; + + const ConversationOuverte(this.conversation); + + @override + List get props => [conversation]; } /// Messages d'une conversation chargés @@ -48,44 +55,35 @@ class MessagesLoaded extends MessagingState { List get props => [conversationId, messages, hasMore]; } -/// Message envoyé avec succès -class MessageSent extends MessagingState { +/// Message envoyé avec succès — la conversation s'actualise +class MessageEnvoye extends MessagingState { final Message message; + final String conversationId; - const MessageSent(this.message); + const MessageEnvoye({required this.message, required this.conversationId}); @override - List get props => [message]; + List get props => [message, conversationId]; } -/// Broadcast envoyé avec succès -class BroadcastSent extends MessagingState { - final Message message; - - const BroadcastSent(this.message); - - @override - List get props => [message]; -} - -/// Conversation créée -class ConversationCreated extends MessagingState { +/// Conversation créée (après démarrage direct/rôle) +class ConversationCreee extends MessagingState { final Conversation conversation; - const ConversationCreated(this.conversation); + const ConversationCreee(this.conversation); @override List get props => [conversation]; } -/// Compteur de non lus chargé -class UnreadCountLoaded extends MessagingState { - final int count; +/// Action silencieuse réussie (marquer lu, supprimer message, etc.) +class MessagingActionOk extends MessagingState { + final String action; - const UnreadCountLoaded(this.count); + const MessagingActionOk(this.action); @override - List get props => [count]; + List get props => [action]; } /// Erreur diff --git a/lib/features/communication/presentation/pages/broadcast_page.dart b/lib/features/communication/presentation/pages/broadcast_page.dart new file mode 100644 index 0000000..762b57d --- /dev/null +++ b/lib/features/communication/presentation/pages/broadcast_page.dart @@ -0,0 +1,212 @@ +/// Page contacter le bureau — Communication UnionFlow v4 +/// +/// Remplace l'ancien broadcast par un canal vers le rôle PRESIDENT. +library broadcast_page; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../shared/design_system/unionflow_design_system.dart'; +import '../../../../shared/widgets/core_card.dart'; +import '../bloc/messaging_bloc.dart'; +import '../bloc/messaging_event.dart'; +import '../bloc/messaging_state.dart'; +import 'conversation_detail_page.dart'; + +/// Page pour contacter le bureau (canal rôle PRESIDENT) +class BroadcastPage extends StatefulWidget { + final String organizationId; + + const BroadcastPage({ + super.key, + required this.organizationId, + }); + + @override + State createState() => _BroadcastPageState(); +} + +class _BroadcastPageState extends State { + final _messageController = TextEditingController(); + String _selectedRole = 'PRESIDENT'; + bool _isSending = false; + + static const _roles = [ + ('PRESIDENT', 'Président'), + ('TRESORIER', 'Trésorier'), + ('SECRETAIRE', 'Secrétaire'), + ('VICE_PRESIDENT', 'Vice-Président'), + ('CONSEILLER', 'Conseiller'), + ]; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + void _submit() { + final message = _messageController.text.trim(); + if (message.isEmpty || _isSending) return; + + setState(() => _isSending = true); + + context.read().add( + DemarrerConversationRole( + roleCible: _selectedRole, + organisationId: widget.organizationId, + premierMessage: message, + ), + ); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return BlocListener( + listener: (context, state) { + if (state is ConversationCreee) { + setState(() => _isSending = false); + // Naviguer vers la conversation créée + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: context.read(), + child: ConversationDetailPage(conversationId: state.conversation.id), + ), + ), + ); + } + if (state is MessagingError) { + setState(() => _isSending = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: ColorTokens.error, + ), + ); + } + }, + child: Scaffold( + backgroundColor: scheme.surface, + appBar: UFAppBar( + title: 'Contacter le bureau', + moduleGradient: ModuleColors.communicationGradient, + automaticallyImplyLeading: true, + ), + body: SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.all(SpacingTokens.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Info + CoreCard( + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: ModuleColors.communication.withOpacity(0.15), + borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), + ), + child: const Icon( + Icons.account_circle_outlined, + color: ModuleColors.communication, + size: 24, + ), + ), + const SizedBox(width: SpacingTokens.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Contacter un responsable', style: AppTypography.actionText), + const SizedBox(height: 2), + Text( + 'Votre message sera transmis au titulaire du rôle choisi', + style: AppTypography.subtitleSmall, + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: SpacingTokens.lg), + + // Sélection du rôle + Text('Destinataire', style: AppTypography.actionText), + const SizedBox(height: SpacingTokens.sm), + Wrap( + spacing: SpacingTokens.sm, + runSpacing: SpacingTokens.sm, + children: _roles.map((role) { + final isSelected = _selectedRole == role.$1; + return ChoiceChip( + label: Text(role.$2), + selected: isSelected, + selectedColor: ModuleColors.communication.withOpacity(0.2), + labelStyle: AppTypography.bodyTextSmall.copyWith( + color: isSelected ? ModuleColors.communication : null, + fontWeight: isSelected ? FontWeight.w600 : null, + ), + onSelected: (_) => setState(() => _selectedRole = role.$1), + ); + }).toList(), + ), + + const SizedBox(height: SpacingTokens.lg), + + // Message + Text('Message', style: AppTypography.actionText), + const SizedBox(height: SpacingTokens.sm), + TextField( + controller: _messageController, + maxLines: 6, + minLines: 3, + decoration: InputDecoration( + hintText: 'Écrivez votre message...', + hintStyle: AppTypography.bodyTextSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + filled: true, + fillColor: scheme.surfaceContainerHighest.withOpacity(0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + borderSide: const BorderSide(color: ModuleColors.communication, width: 2), + ), + ), + style: AppTypography.bodyMedium, + ), + + const SizedBox(height: SpacingTokens.xl), + + SizedBox( + width: double.infinity, + child: UFPrimaryButton( + label: _isSending ? 'Envoi en cours...' : 'Envoyer le message', + onPressed: _isSending ? null : _submit, + icon: _isSending ? null : Icons.send_rounded, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/communication/presentation/pages/conversation_detail_page.dart b/lib/features/communication/presentation/pages/conversation_detail_page.dart new file mode 100644 index 0000000..aad0158 --- /dev/null +++ b/lib/features/communication/presentation/pages/conversation_detail_page.dart @@ -0,0 +1,377 @@ +/// Page de détail d'une conversation — Messages v4 +library conversation_detail_page; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/utils/logger.dart'; +import '../../../../shared/design_system/unionflow_design_system.dart'; +import '../../domain/entities/conversation.dart'; +import '../../domain/entities/message.dart'; +import '../bloc/messaging_bloc.dart'; +import '../bloc/messaging_event.dart'; +import '../bloc/messaging_state.dart'; +import '../widgets/message_bubble.dart'; + +/// Page détail conversation : messages + champ d'envoi +class ConversationDetailPage extends StatefulWidget { + final String conversationId; + + const ConversationDetailPage({ + super.key, + required this.conversationId, + }); + + @override + State createState() => _ConversationDetailPageState(); +} + +class _ConversationDetailPageState extends State { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final FocusNode _focusNode = FocusNode(); + + Conversation? _conversation; + String? _currentUserId; + + @override + void initState() { + super.initState(); + context.read().add(OpenConversation(widget.conversationId)); + } + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _sendMessage() { + final content = _messageController.text.trim(); + if (content.isEmpty) return; + + context.read().add( + EnvoyerMessageTexte( + conversationId: widget.conversationId, + contenu: content, + ), + ); + _messageController.clear(); + _focusNode.requestFocus(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: scheme.surface, + appBar: _buildAppBar(), + body: Column( + children: [ + Expanded( + child: BlocConsumer( + listener: (context, state) { + if (state is ConversationOuverte) { + setState(() { + _conversation = state.conversation; + // Déterminer l'id de l'utilisateur courant via le premier participant non-expéditeur + }); + } + if (state is MessagesLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + } + if (state is MessageEnvoye) { + AppLogger.info('Message envoyé: ${state.message.id}'); + } + if (state is MessagingError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: ColorTokens.error, + ), + ); + } + }, + builder: (context, state) { + if (state is MessagingLoading && _conversation == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is ConversationOuverte) { + return _buildMessagesList(state.conversation.messages, scheme); + } + + if (state is MessagesLoaded) { + return _buildMessagesList(state.messages, scheme); + } + + if (_conversation != null) { + return _buildMessagesList(_conversation!.messages, scheme); + } + + return const SizedBox.shrink(); + }, + ), + ), + _buildInputBar(scheme), + ], + ), + ); + } + + UFAppBar _buildAppBar() { + final title = _conversation?.titre ?? 'Conversation'; + + return UFAppBar( + title: title, + moduleGradient: ModuleColors.communicationGradient, + automaticallyImplyLeading: true, + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline, color: ModuleColors.communicationOnColor), + onPressed: _conversation != null ? _confirmArchive : null, + tooltip: 'Archiver', + ), + ], + ); + } + + void _confirmArchive() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Archiver la conversation ?'), + content: const Text('La conversation sera archivée et n\'apparaîtra plus dans votre liste.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: ColorTokens.error), + child: const Text('Archiver'), + ), + ], + ), + ).then((confirmed) { + if (confirmed == true && mounted) { + context.read().add(ArchiverConversation(widget.conversationId)); + Navigator.of(context).pop(); + } + }); + } + + Widget _buildMessagesList(List messages, ColorScheme scheme) { + if (messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: scheme.onSurfaceVariant.withOpacity(0.4), + ), + const SizedBox(height: SpacingTokens.md), + Text('Aucun message', style: AppTypography.headerSmall), + const SizedBox(height: SpacingTokens.sm), + Text('Envoyez le premier message !', style: AppTypography.bodyTextSmall), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.md, + vertical: SpacingTokens.sm, + ), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + final isMine = _isMyMessage(message); + + final showDateSeparator = index == 0 || + !_isSameDay( + messages[index - 1].dateEnvoi ?? DateTime.now(), + message.dateEnvoi ?? DateTime.now(), + ); + + return Column( + children: [ + if (showDateSeparator) + Padding( + padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: 4), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), + ), + child: Text( + _formatDateSeparator(message.dateEnvoi ?? DateTime.now()), + style: AppTypography.badgeText.copyWith( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ), + ), + ), + MessageBubble( + message: message, + isMine: isMine, + onLongPress: isMine && !message.supprime + ? () => _showMessageOptions(message) + : null, + ), + ], + ); + }, + ); + } + + void _showMessageOptions(Message message) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.delete_outline, color: AppColors.error), + title: const Text('Supprimer le message'), + onTap: () { + Navigator.pop(ctx); + context.read().add( + SupprimerMessage( + conversationId: widget.conversationId, + messageId: message.id, + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.close), + title: const Text('Annuler'), + onTap: () => Navigator.pop(ctx), + ), + ], + ), + ), + ); + } + + Widget _buildInputBar(ColorScheme scheme) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.sm, + vertical: SpacingTokens.sm, + ), + decoration: BoxDecoration( + color: scheme.surface, + border: Border( + top: BorderSide(color: scheme.outlineVariant.withOpacity(0.3)), + ), + boxShadow: [ + BoxShadow( + color: scheme.shadow.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), + ), + child: TextField( + controller: _messageController, + focusNode: _focusNode, + textInputAction: TextInputAction.send, + maxLines: 4, + minLines: 1, + onSubmitted: (_) => _sendMessage(), + decoration: InputDecoration( + hintText: 'Écrire un message...', + hintStyle: AppTypography.bodyTextSmall.copyWith( + color: scheme.onSurfaceVariant, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.md, + vertical: SpacingTokens.sm, + ), + border: InputBorder.none, + ), + style: AppTypography.bodyTextSmall, + ), + ), + ), + const SizedBox(width: SpacingTokens.sm), + Material( + color: ModuleColors.communication, + borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), + child: InkWell( + onTap: _sendMessage, + borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + child: const Icon( + Icons.send_rounded, + color: ModuleColors.communicationOnColor, + size: 22, + ), + ), + ), + ), + ], + ), + ), + ); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + bool _isMyMessage(Message message) { + // On ne peut pas connaître l'ID du membre courant sans le BLoC d'auth, + // mais l'expéditeur d'un message récent qu'on a envoyé sera déterminé + // via la liste des participants. En attendant le token JWT, on utilise + // le premier participant de type INITIATEUR s'il n'y en a pas d'autre. + // TODO: Injecter AuthBloc pour comparer expediteurId avec l'id du user connecté. + return false; // Tous les messages affichés comme reçus pour l'instant + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + String _formatDateSeparator(DateTime date) { + final now = DateTime.now(); + if (_isSameDay(date, now)) return "Aujourd'hui"; + if (_isSameDay(date, now.subtract(const Duration(days: 1)))) return 'Hier'; + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } +} diff --git a/lib/features/communication/presentation/pages/conversations_page.dart b/lib/features/communication/presentation/pages/conversations_page.dart index d36c408..43703a9 100644 --- a/lib/features/communication/presentation/pages/conversations_page.dart +++ b/lib/features/communication/presentation/pages/conversations_page.dart @@ -1,143 +1,141 @@ -/// Page liste des conversations +/// Page liste des conversations v4 library conversations_page; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../core/di/injection_container.dart'; + import '../../../../shared/design_system/unionflow_design_system.dart'; -import '../../../../shared/utils/snackbar_helper.dart'; +import '../../domain/entities/conversation.dart'; import '../bloc/messaging_bloc.dart'; import '../bloc/messaging_event.dart'; import '../bloc/messaging_state.dart'; import '../widgets/conversation_tile.dart'; +import 'conversation_detail_page.dart'; class ConversationsPage extends StatelessWidget { - final String? organizationId; - - const ConversationsPage({ - super.key, - this.organizationId, - }); + const ConversationsPage({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => sl() - ..add(LoadConversations(organizationId: organizationId)), - child: Scaffold( - backgroundColor: ColorTokens.background, - appBar: const UFAppBar( - title: 'MESSAGES', - automaticallyImplyLeading: true, - ), - body: BlocBuilder( - builder: (context, state) { - if (state is MessagingLoading) { - return const Center(child: CircularProgressIndicator()); - } + final scheme = Theme.of(context).colorScheme; - if (state is MessagingError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 64, - color: AppColors.error, - ), - const SizedBox(height: SpacingTokens.md), - Text( - 'Erreur', - style: AppTypography.headerSmall, - ), - const SizedBox(height: SpacingTokens.sm), - Text( - state.message, - style: AppTypography.bodyTextSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: SpacingTokens.lg), - UFPrimaryButton( - label: 'Réessayer', - onPressed: () { - context.read().add( - LoadConversations(organizationId: organizationId), - ); - }, - ), - ], + return Scaffold( + backgroundColor: scheme.surface, + appBar: UFAppBar( + title: 'Messages', + moduleGradient: ModuleColors.communicationGradient, + automaticallyImplyLeading: true, + ), + body: BlocConsumer( + listener: (context, state) { + if (state is MessagingError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: ColorTokens.error, + ), + ); + } + if (state is ConversationCreee) { + _openDetail(context, state.conversation.id); + } + }, + builder: (context, state) { + if (state is MessagingLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is MesConversationsLoaded) { + return _buildList(context, state.conversations); + } + + // État initial ou erreur non fatale — afficher liste vide + return _buildEmpty(context); + }, + ), + ); + } + + Widget _buildList(BuildContext context, List conversations) { + if (conversations.isEmpty) { + return _buildEmpty(context); + } + + // Tri : non lus d'abord, puis par date de dernier message + final sorted = List.of(conversations) + ..sort((a, b) { + if (a.hasUnread && !b.hasUnread) return -1; + if (!a.hasUnread && b.hasUnread) return 1; + final aDate = a.dernierMessageAt; + final bDate = b.dernierMessageAt; + if (aDate == null && bDate == null) return 0; + if (aDate == null) return 1; + if (bDate == null) return -1; + return bDate.compareTo(aDate); + }); + + return RefreshIndicator( + color: ModuleColors.communication, + onRefresh: () async { + context.read().add(const LoadMesConversations()); + }, + child: ListView.separated( + padding: const EdgeInsets.all(SpacingTokens.md), + itemCount: sorted.length, + separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm), + itemBuilder: (context, index) { + final conv = sorted[index]; + return ConversationTile( + conversation: conv, + onTap: () => _openDetail(context, conv.id), + ); + }, + ), + ); + } + + Widget _buildEmpty(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return RefreshIndicator( + color: ModuleColors.communication, + onRefresh: () async { + context.read().add(const LoadMesConversations()); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: scheme.onSurfaceVariant.withOpacity(0.4), ), - ); - } - - if (state is ConversationsLoaded) { - final conversations = state.conversations; - - if (conversations.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.chat_bubble_outline, - size: 64, - color: AppColors.textSecondaryLight, - ), - const SizedBox(height: SpacingTokens.md), - Text( - 'Aucune conversation', - style: AppTypography.headerSmall.copyWith( - color: AppColors.textSecondaryLight, - ), - ), - const SizedBox(height: SpacingTokens.sm), - Text( - 'Commencez une nouvelle conversation', - style: AppTypography.bodyTextSmall.copyWith( - color: AppColors.textSecondaryLight, - ), - ), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: () async { - context.read().add( - LoadConversations(organizationId: organizationId), - ); - }, - child: ListView.separated( - padding: const EdgeInsets.all(SpacingTokens.md), - itemCount: conversations.length, - separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm), - itemBuilder: (context, index) { - final conversation = conversations[index]; - return ConversationTile( - conversation: conversation, - onTap: () { - SnackbarHelper.showNotImplemented( - context, - 'Affichage des messages', - ); - }, - ); - }, + const SizedBox(height: SpacingTokens.md), + Text('Aucune conversation', style: AppTypography.headerSmall), + const SizedBox(height: SpacingTokens.sm), + Text( + 'Contactez un membre ou le bureau\nde votre organisation', + style: AppTypography.bodyTextSmall, + textAlign: TextAlign.center, ), - ); - } - - return const SizedBox.shrink(); - }, + ], + ), + ), ), - floatingActionButton: FloatingActionButton( - backgroundColor: AppColors.primaryGreen, - onPressed: () { - SnackbarHelper.showNotImplemented(context, 'Nouvelle conversation'); - }, - child: const Icon(Icons.add, color: Colors.white), + ), + ); + } + + void _openDetail(BuildContext context, String conversationId) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: context.read(), + child: ConversationDetailPage(conversationId: conversationId), ), ), ); diff --git a/lib/features/communication/presentation/pages/conversations_page_wrapper.dart b/lib/features/communication/presentation/pages/conversations_page_wrapper.dart new file mode 100644 index 0000000..4b09db0 --- /dev/null +++ b/lib/features/communication/presentation/pages/conversations_page_wrapper.dart @@ -0,0 +1,33 @@ +/// Wrapper BLoC pour la page des conversations v4 +/// +/// Fournit le MessagingBloc et charge les conversations au démarrage. +library conversations_page_wrapper; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/di/injection_container.dart'; +import '../../../../core/utils/logger.dart'; +import '../bloc/messaging_bloc.dart'; +import '../bloc/messaging_event.dart'; +import 'conversations_page.dart'; + +/// Wrapper qui fournit le BLoC à la page des conversations +class ConversationsPageWrapper extends StatelessWidget { + const ConversationsPageWrapper({super.key}); + + @override + Widget build(BuildContext context) { + AppLogger.info('ConversationsPageWrapper: Création du BlocProvider'); + + return BlocProvider( + create: (context) { + AppLogger.info('ConversationsPageWrapper: Initialisation du MessagingBloc'); + final bloc = sl(); + bloc.add(const LoadMesConversations()); + return bloc; + }, + child: const ConversationsPage(), + ); + } +} diff --git a/lib/features/communication/presentation/widgets/message_bubble.dart b/lib/features/communication/presentation/widgets/message_bubble.dart new file mode 100644 index 0000000..9363ccc --- /dev/null +++ b/lib/features/communication/presentation/widgets/message_bubble.dart @@ -0,0 +1,241 @@ +/// Widget bulle de message v4 — Communication UnionFlow +library message_bubble; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../../shared/design_system/unionflow_design_system.dart'; +import '../../domain/entities/message.dart'; + +/// Bulle de message différenciée envoyé/reçu +class MessageBubble extends StatelessWidget { + final Message message; + final bool isMine; + final VoidCallback? onLongPress; + + const MessageBubble({ + super.key, + required this.message, + required this.isMine, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + // Message supprimé + if (message.supprime) { + return _buildDeleted(scheme); + } + + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: GestureDetector( + onLongPress: onLongPress, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + margin: EdgeInsets.only( + left: isMine ? 48 : 0, + right: isMine ? 0 : 48, + bottom: SpacingTokens.xs, + ), + child: Column( + crossAxisAlignment: + isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + // Nom expéditeur (messages reçus) + if (!isMine && message.expediteurNomComplet.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: SpacingTokens.sm, bottom: 2), + child: Text( + message.expediteurNomComplet, + style: AppTypography.badgeText.copyWith( + color: ModuleColors.communication, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Réponse à un message parent + if (message.hasParent && message.messageParentApercu != null) + Container( + margin: EdgeInsets.only( + left: isMine ? 0 : 0, + bottom: 4, + ), + padding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.sm, + vertical: 4, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withOpacity(0.7), + borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), + border: Border( + left: BorderSide( + color: ModuleColors.communication, + width: 3, + ), + ), + ), + child: Text( + message.messageParentApercu!, + style: AppTypography.badgeText.copyWith( + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + // Bulle principale + Container( + padding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.md, + vertical: SpacingTokens.sm, + ), + decoration: BoxDecoration( + color: isMine + ? ModuleColors.communication + : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(SpacingTokens.radiusMd), + topRight: const Radius.circular(SpacingTokens.radiusMd), + bottomLeft: Radius.circular( + isMine ? SpacingTokens.radiusMd : SpacingTokens.radiusXs, + ), + bottomRight: Radius.circular( + isMine ? SpacingTokens.radiusXs : SpacingTokens.radiusMd, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Contenu selon type + _buildContent(scheme), + + const SizedBox(height: 4), + + // Horodatage + if (message.dateEnvoi != null) + Text( + DateFormat('HH:mm').format(message.dateEnvoi!), + style: AppTypography.badgeText.copyWith( + color: isMine + ? Colors.white.withOpacity(0.7) + : scheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildContent(ColorScheme scheme) { + final textColor = isMine ? ModuleColors.communicationOnColor : scheme.onSurface; + + if (message.isVocal) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.mic, size: 18, color: textColor), + const SizedBox(width: 4), + Text( + 'Note vocale${message.dureeAudio != null ? ' · ${message.dureeAudio}s' : ''}', + style: AppTypography.bodyTextSmall.copyWith(color: textColor), + ), + ], + ); + } + + if (message.isImage) { + if (message.urlFichier != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(SpacingTokens.radiusSm), + child: Image.network( + message.urlFichier!, + width: 200, + height: 150, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, size: 18, color: textColor), + const SizedBox(width: 4), + Text('Image', style: AppTypography.bodyTextSmall.copyWith(color: textColor)), + ], + ), + ), + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.image_outlined, size: 18, color: textColor), + const SizedBox(width: 4), + Text('Image', style: AppTypography.bodyTextSmall.copyWith(color: textColor)), + ], + ); + } + + if (message.isSysteme) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 16, color: textColor.withOpacity(0.7)), + const SizedBox(width: 4), + Flexible( + child: Text( + message.contenu ?? 'Notification système', + style: AppTypography.bodyTextSmall.copyWith( + color: textColor.withOpacity(0.7), + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + } + + // TEXTE (défaut) + return Text( + message.contenu ?? '', + style: AppTypography.bodyTextSmall.copyWith(color: textColor), + ); + } + + Widget _buildDeleted(ColorScheme scheme) { + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: SpacingTokens.xs), + padding: const EdgeInsets.symmetric( + horizontal: SpacingTokens.md, + vertical: SpacingTokens.sm, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withOpacity(0.4), + borderRadius: BorderRadius.circular(SpacingTokens.radiusMd), + border: Border.all(color: scheme.outlineVariant.withOpacity(0.3)), + ), + child: Text( + '🚫 Message supprimé', + style: AppTypography.bodyTextSmall.copyWith( + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ); + } +}