feat(communication): module messagerie unifié + contact policies + blocages

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.)
This commit is contained in:
dahoud
2026-04-15 20:26:35 +00:00
parent 07b8488714
commit 45dcd2171e
23 changed files with 2096 additions and 1588 deletions

View File

@@ -1,16 +1,18 @@
/// Datasource distant pour la communication (API) /// Datasource distant pour la messagerie v4 — /api/messagerie/*
library messaging_remote_datasource; library messaging_remote_datasource;
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart'; import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart'; import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart'; import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/message_model.dart';
import '../models/conversation_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 @lazySingleton
class MessagingRemoteDatasource { class MessagingRemoteDatasource {
@@ -22,7 +24,7 @@ class MessagingRemoteDatasource {
required this.authService, 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<Map<String, String>> _getHeaders() async { Future<Map<String, String>> _getHeaders() async {
final token = await authService.getValidAccessToken(); final token = await authService.getValidAccessToken();
return { return {
@@ -32,290 +34,271 @@ class MessagingRemoteDatasource {
}; };
} }
// === CONVERSATIONS === String get _base => '${AppConfig.apiBaseUrl}/api/messagerie';
Future<List<ConversationModel>> getConversations({ // ── Conversations ─────────────────────────────────────────────────────────
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organisationId': organizationId,
'includeArchived': includeArchived.toString(),
});
final response = await client.get(uri, headers: await _getHeaders()); Future<List<ConversationSummaryModel>> getMesConversations() async {
final response = await client.get(
Uri.parse('$_base/conversations'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body); final list = json.decode(response.body) as List<dynamic>;
return jsonList return list
.map((json) => ConversationModel.fromJson(json)) .map((j) => ConversationSummaryModel.fromJson(j as Map<String, dynamic>))
.toList(); .toList();
} else if (response.statusCode == 401) { }
throw UnauthorizedException(); throw ServerException('Erreur récupération conversations (${response.statusCode})');
} else { }
throw ServerException('Erreur lors de la récupération des conversations');
Future<ConversationModel> 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<String, dynamic>);
}
if (response.statusCode == 404) throw NotFoundException('Conversation non trouvée');
throw ServerException('Erreur récupération conversation (${response.statusCode})');
}
Future<ConversationModel> 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<String, dynamic>);
}
throw ServerException('Erreur démarrage conversation directe (${response.statusCode})');
}
Future<ConversationModel> 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<String, dynamic>);
}
throw ServerException('Erreur démarrage conversation rôle (${response.statusCode})');
}
Future<void> 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<ConversationModel> getConversationById(String conversationId) async { // ── Messages ──────────────────────────────────────────────────────────────
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId'); Future<MessageModel> 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<String, dynamic>);
}
throw ServerException('Erreur envoi message (${response.statusCode})');
}
Future<List<MessageModel>> 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()); final response = await client.get(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode == 200) { if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body)); final list = json.decode(response.body) as List<dynamic>;
} else if (response.statusCode == 404) { return list
throw NotFoundException('Conversation non trouvée'); .map((j) => MessageModel.fromJson(j as Map<String, dynamic>))
} else if (response.statusCode == 401) { .toList();
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
} }
throw ServerException('Erreur récupération messages (${response.statusCode})');
} }
Future<ConversationModel> createConversation({ Future<void> marquerLu(String conversationId) async {
required String name,
required List<String> 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<List<MessageModel>> 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<dynamic> 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<MessageModel> sendMessage({
required String conversationId,
required String content,
List<String>? 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<MessageModel> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? 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<void> 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<void> 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<void> 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<void> 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<MessageModel> editMessage({
required String messageId,
required String newContent,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId');
final body = json.encode({'content': newContent});
final response = await client.put( 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<void> 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<void> 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(), headers: await _getHeaders(),
body: body, body: body,
); );
if (response.statusCode == 200) { _checkAuth(response);
return MessageModel.fromJson(json.decode(response.body)); if (response.statusCode != 200 && response.statusCode != 204) {
} else if (response.statusCode == 401) { throw ServerException('Erreur blocage membre (${response.statusCode})');
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de l\'édition du message');
} }
} }
Future<void> deleteMessage(String messageId) async { Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId'); final uri = Uri.parse('$_base/blocages/$membreId').replace(
queryParameters: {
if (organisationId != null) 'organisationId': organisationId,
},
);
final response = await client.delete(uri, headers: await _getHeaders()); final response = await client.delete(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) { throw ServerException('Erreur déblocage membre (${response.statusCode})');
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de la suppression du message');
}
} }
} }
Future<void> markMessageAsRead(String messageId) async { Future<List<Map<String, dynamic>>> getMesBlocages() async {
// Backend has no per-message read endpoint — use markConversationAsRead final response = await client.get(
if (AppConfig.enableLogging) { Uri.parse('$_base/blocages'),
debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId'); headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
final list = json.decode(response.body) as List<dynamic>;
return list.map((j) => j as Map<String, dynamic>).toList();
} }
throw ServerException('Erreur récupération blocages (${response.statusCode})');
} }
Future<int> getUnreadCount({String? organizationId}) async { // ── Politique de communication ────────────────────────────────────────────
try {
final conversations = await getConversations(organizationId: organizationId); Future<ContactPolicyModel> getPolitique(String organisationId) async {
return conversations.fold<int>(0, (sum, c) => sum + c.unreadCount); final response = await client.get(
} catch (_) { Uri.parse('$_base/politique/$organisationId'),
return 0; headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
return ContactPolicyModel.fromJson(json.decode(response.body) as Map<String, dynamic>);
} }
throw ServerException('Erreur récupération politique (${response.statusCode})');
}
Future<ContactPolicyModel> 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<String, dynamic>);
}
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');
} }
} }

View File

@@ -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<String, dynamic> 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,
);
}
}

View File

@@ -1,70 +1,110 @@
/// Model de données Conversation avec sérialisation JSON /// Modèles de données Conversation v4 avec sérialisation JSON
library conversation_model; library conversation_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart'; import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart'; import 'message_model.dart';
part 'conversation_model.g.dart'; /// Modèle de résumé de conversation (liste)
class ConversationSummaryModel extends ConversationSummary {
@JsonSerializable(explicitToJson: true) const ConversationSummaryModel({
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
required super.id, required super.id,
required super.name, required super.typeConversation,
super.description, required super.titre,
required super.type, required super.statut,
required super.participantIds, super.dernierMessageApercu,
super.organizationId, super.dernierMessageType,
this.lastMessage, super.dernierMessageAt,
super.unreadCount, super.nonLus,
super.isMuted, super.organisationId,
super.isPinned, });
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
static Message? _messageFromJson(Map<String, dynamic>? json) => factory ConversationSummaryModel.fromJson(Map<String, dynamic> json) {
json == null ? null : MessageModel.fromJson(json); return ConversationSummaryModel(
id: json['id']?.toString() ?? '',
static Map<String, dynamic>? _messageToJson(Message? message) => typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE',
message == null ? null : MessageModel.fromEntity(message).toJson(); titre: json['titre']?.toString() ?? '',
statut: json['statut']?.toString() ?? 'ACTIVE',
factory ConversationModel.fromJson(Map<String, dynamic> json) => dernierMessageApercu: json['dernierMessageApercu']?.toString(),
_$ConversationModelFromJson(json); dernierMessageType: json['dernierMessageType']?.toString(),
dernierMessageAt: json['dernierMessageAt'] != null
Map<String, dynamic> toJson() => _$ConversationModelToJson(this); ? DateTime.tryParse(json['dernierMessageAt'].toString())
: null,
factory ConversationModel.fromEntity(Conversation conversation) { nonLus: _parseInt(json['nonLus']),
return ConversationModel( organisationId: json['organisationId']?.toString(),
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,
); );
} }
}
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<String, dynamic> 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<String, dynamic> json) {
final participantsJson = json['participants'] as List<dynamic>? ?? [];
final messagesJson = json['messages'] as List<dynamic>? ?? [];
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<String, dynamic>))
.toList(),
messages: messagesJson
.map((m) => MessageModel.fromJson(m as Map<String, dynamic>))
.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;
} }

View File

@@ -1,57 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// Modèles v4 : désérialisation manuelle, code generation non utilisé.
part of 'conversation_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ConversationModel _$ConversationModelFromJson(Map<String, dynamic> 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<dynamic>)
.map((e) => e as String)
.toList(),
organizationId: json['organizationId'] as String?,
lastMessage: ConversationModel._messageFromJson(
json['lastMessage'] as Map<String, dynamic>?),
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<String, dynamic>?,
);
Map<String, dynamic> _$ConversationModelToJson(ConversationModel instance) =>
<String, dynamic>{
'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',
};

View File

@@ -1,83 +1,48 @@
/// Model de données Message avec sérialisation JSON /// Modèle de données Message v4 avec sérialisation JSON
library message_model; library message_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/message.dart'; import '../../domain/entities/message.dart';
part 'message_model.g.dart'; /// Modèle Message v4
@JsonSerializable(explicitToJson: true)
class MessageModel extends Message { class MessageModel extends Message {
const MessageModel({ const MessageModel({
required super.id, required super.id,
required super.conversationId, required super.typeMessage,
required super.senderId, super.contenu,
required super.senderName, super.urlFichier,
super.senderAvatar, super.dureeAudio,
required super.content, super.supprime,
required super.type, super.expediteurId,
required super.status, super.expediteurNom,
super.priority, super.expediteurPrenom,
required super.recipientIds, super.messageParentId,
super.recipientRoles, super.messageParentApercu,
super.organizationId, super.dateEnvoi,
required super.createdAt,
super.readAt,
super.metadata,
super.attachments,
super.isEdited,
super.editedAt,
super.isDeleted,
}); });
factory MessageModel.fromJson(Map<String, dynamic> json) => factory MessageModel.fromJson(Map<String, dynamic> json) {
_$MessageModelFromJson(json);
Map<String, dynamic> toJson() => _$MessageModelToJson(this);
factory MessageModel.fromEntity(Message message) {
return MessageModel( return MessageModel(
id: message.id, id: json['id']?.toString() ?? '',
conversationId: message.conversationId, typeMessage: json['typeMessage']?.toString() ?? 'TEXTE',
senderId: message.senderId, contenu: json['contenu']?.toString(),
senderName: message.senderName, urlFichier: json['urlFichier']?.toString(),
senderAvatar: message.senderAvatar, dureeAudio: _parseInt(json['dureeAudio']),
content: message.content, supprime: json['supprime'] == true,
type: message.type, expediteurId: json['expediteurId']?.toString(),
status: message.status, expediteurNom: json['expediteurNom']?.toString(),
priority: message.priority, expediteurPrenom: json['expediteurPrenom']?.toString(),
recipientIds: message.recipientIds, messageParentId: json['messageParentId']?.toString(),
recipientRoles: message.recipientRoles, messageParentApercu: json['messageParentApercu']?.toString(),
organizationId: message.organizationId, dateEnvoi: json['dateEnvoi'] != null
createdAt: message.createdAt, ? DateTime.tryParse(json['dateEnvoi'].toString())
readAt: message.readAt, : null,
metadata: message.metadata,
attachments: message.attachments,
isEdited: message.isEdited,
editedAt: message.editedAt,
isDeleted: message.isDeleted,
); );
} }
}
Message toEntity() => Message(
id: id, int? _parseInt(dynamic value) {
conversationId: conversationId, if (value == null) return null;
senderId: senderId, if (value is int) return value;
senderName: senderName, if (value is double) return value.toInt();
senderAvatar: senderAvatar, return int.tryParse(value.toString());
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,
);
} }

View File

@@ -1,84 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// Modèles v4 : désérialisation manuelle, code generation non utilisé.
part of 'message_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MessageModel _$MessageModelFromJson(Map<String, dynamic> 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<dynamic>)
.map((e) => e as String)
.toList(),
recipientRoles: (json['recipientRoles'] as List<dynamic>?)
?.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<String, dynamic>?,
attachments: (json['attachments'] as List<dynamic>?)
?.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<String, dynamic> _$MessageModelToJson(MessageModel instance) =>
<String, dynamic>{
'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',
};

View File

@@ -1,416 +1,252 @@
/// Implémentation du repository de messagerie /// Implémentation du repository de messagerie v4
library messaging_repository_impl; library messaging_repository_impl;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.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/conversation.dart';
import '../../domain/entities/message.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 '../../domain/repositories/messaging_repository.dart';
import '../datasources/messaging_remote_datasource.dart'; import '../datasources/messaging_remote_datasource.dart';
@LazySingleton(as: MessagingRepository) @LazySingleton(as: MessagingRepository)
class MessagingRepositoryImpl implements MessagingRepository { class MessagingRepositoryImpl implements MessagingRepository {
final MessagingRemoteDatasource remoteDatasource; final MessagingRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
MessagingRepositoryImpl({ MessagingRepositoryImpl({required this.remoteDatasource});
required this.remoteDatasource,
required this.networkInfo,
});
@override @override
Future<Either<Failure, List<Conversation>>> getConversations({ Future<List<ConversationSummary>> getMesConversations() async {
String? organizationId,
bool includeArchived = false,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final conversations = await remoteDatasource.getConversations( return await remoteDatasource.getMesConversations();
organizationId: organizationId,
includeArchived: includeArchived,
);
return Right(conversations);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, Conversation>> getConversationById( Future<Conversation> getConversation(String conversationId) async {
String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final conversation = return await remoteDatasource.getConversation(conversationId);
await remoteDatasource.getConversationById(conversationId);
return Right(conversation);
} on NotFoundException { } on NotFoundException {
return Left(NotFoundFailure('Conversation non trouvée')); throw Exception('Conversation non trouvée');
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, Conversation>> createConversation({ Future<Conversation> demarrerConversationDirecte({
required String name, required String destinataireId,
required List<String> participantIds, required String organisationId,
String? organizationId, String? premierMessage,
String? description,
}) async { }) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final conversation = await remoteDatasource.createConversation( return await remoteDatasource.demarrerConversationDirecte(
name: name, destinataireId: destinataireId,
participantIds: participantIds, organisationId: organisationId,
organizationId: organizationId, premierMessage: premierMessage,
description: description,
); );
return Right(conversation);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, List<Message>>> getMessages({ Future<Conversation> demarrerConversationRole({
required String conversationId, required String roleCible,
int? limit, required String organisationId,
String? beforeMessageId, String? premierMessage,
}) async { }) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final messages = await remoteDatasource.getMessages( return await remoteDatasource.demarrerConversationRole(
conversationId: conversationId, roleCible: roleCible,
limit: limit, organisationId: organisationId,
beforeMessageId: beforeMessageId, premierMessage: premierMessage,
); );
return Right(messages);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, Message>> sendMessage({ Future<void> archiverConversation(String conversationId) async {
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final message = await remoteDatasource.sendMessage( await remoteDatasource.archiverConversation(conversationId);
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
return Right(message);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, Message>> sendBroadcast({ Future<Message> envoyerMessage(
required String organizationId, String conversationId, {
required String subject, required String typeMessage,
required String content, String? contenu,
MessagePriority priority = MessagePriority.normal, String? urlFichier,
List<String>? attachments, int? dureeAudio,
String? messageParentId,
}) async { }) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final message = await remoteDatasource.sendBroadcast( return await remoteDatasource.envoyerMessage(
organizationId: organizationId, conversationId,
subject: subject, typeMessage: typeMessage,
content: content, contenu: contenu,
priority: priority, urlFichier: urlFichier,
attachments: attachments, dureeAudio: dureeAudio,
messageParentId: messageParentId,
); );
return Right(message); } on UnauthorizedException {
throw Exception('Session expirée — veuillez vous reconnecter');
} on ForbiddenException catch (e) { } on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message)); throw Exception(e.message);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, void>> markMessageAsRead(String messageId) async { Future<List<Message>> getMessages(String conversationId, {int page = 0}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
await remoteDatasource.markMessageAsRead(messageId); return await remoteDatasource.getMessages(conversationId, page: page);
return const Right(null);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, int>> getUnreadCount({String? organizationId}) async { Future<void> marquerLu(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
final count = await remoteDatasource.marquerLu(conversationId);
await remoteDatasource.getUnreadCount(organizationId: organizationId); } catch (_) {
return Right(count); // Non-bloquant — ignorer les erreurs de marquage
} 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'));
} }
} }
// === CONVERSATION ACTIONS ===
@override @override
Future<Either<Failure, void>> archiveConversation(String conversationId) async { Future<void> supprimerMessage(String conversationId, String messageId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
await remoteDatasource.archiveConversation(conversationId); await remoteDatasource.supprimerMessage(conversationId, messageId);
return const Right(null);
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, Message>> sendTargetedMessage({ Future<void> bloquerMembre({
required String organizationId, required String membreABloquerId,
required List<String> targetRoles, String? organisationId,
required String subject, String? raison,
required String content,
MessagePriority priority = MessagePriority.normal,
}) async { }) async {
// TODO: Backend needs specific endpoint for targeted messages
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> markConversationAsRead(String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
await remoteDatasource.markConversationAsRead(conversationId); await remoteDatasource.bloquerMembre(
return const Right(null); membreABloquerId: membreABloquerId,
} on UnauthorizedException { organisationId: organisationId,
return Left(UnauthorizedFailure('Session expirée')); raison: raison,
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, void>> 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<Either<Failure, void>> 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<Either<Failure, Message>> 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,
); );
return Right(message);
} on NotFoundException {
return Left(NotFoundFailure('Message non trouvé'));
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, void>> deleteMessage(String messageId) async { Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try { try {
await remoteDatasource.deleteMessage(messageId); await remoteDatasource.debloquerMembre(membreId, organisationId: organisationId);
return const Right(null);
} on NotFoundException {
return Left(NotFoundFailure('Message non trouvé'));
} on UnauthorizedException { } on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée')); throw Exception('Session expirée — veuillez vous reconnecter');
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); throw Exception(e.message);
} catch (e) { } catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e')); throw Exception('Erreur inattendue: $e');
} }
} }
@override @override
Future<Either<Failure, List<MessageTemplate>>> getTemplates({ Future<List<Map<String, dynamic>>> getMesBlocages() async {
String? organizationId, try {
TemplateCategory? category, 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<ContactPolicy> 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<ContactPolicy> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
}) async { }) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); try {
} return await remoteDatasource.mettreAJourPolitique(
organisationId,
@override typePolitique: typePolitique,
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId) async { autoriserMembreVersMembre: autoriserMembreVersMembre,
return Left(NotImplementedFailure('Fonctionnalité en cours de développement')); autoriserMembreVersRole: autoriserMembreVersRole,
} autoriserNotesVocales: autoriserNotesVocales,
);
@override } on UnauthorizedException {
Future<Either<Failure, MessageTemplate>> createTemplate({ throw Exception('Session expirée — veuillez vous reconnecter');
required String name, } on ForbiddenException catch (e) {
required String description, throw Exception(e.message);
required TemplateCategory category, } on ServerException catch (e) {
required String subject, throw Exception(e.message);
required String body, } catch (e) {
List<Map<String, dynamic>>? variables, throw Exception('Erreur inattendue: $e');
String? organizationId, }
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> 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<Either<Failure, void>> deleteTemplate(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
} }
} }

View File

@@ -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<Object?> get props => [
id, organisationId, typePolitique,
autoriserMembreVersMembre, autoriserMembreVersRole, autoriserNotesVocales,
];
}

View File

@@ -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; library conversation;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'message.dart'; import 'message.dart';
/// Type de conversation // ── Résumé de conversation (liste) ───────────────────────────────────────────
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
/// Conversation de groupe /// Résumé d'une conversation pour l'affichage en liste
group, class ConversationSummary extends Equatable {
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
final String id; final String id;
final String name; final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE
final String? description; final String titre;
final ConversationType type; final String statut; // ACTIVE | ARCHIVEE
final List<String> participantIds; final String? dernierMessageApercu;
final String? organizationId; final String? dernierMessageType;
final Message? lastMessage; final DateTime? dernierMessageAt;
final int unreadCount; final int nonLus;
final bool isMuted; final String? organisationId;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
const Conversation({ const ConversationSummary({
required this.id, required this.id,
required this.name, required this.typeConversation,
this.description, required this.titre,
required this.type, required this.statut,
required this.participantIds, this.dernierMessageApercu,
this.organizationId, this.dernierMessageType,
this.lastMessage, this.dernierMessageAt,
this.unreadCount = 0, this.nonLus = 0,
this.isMuted = false, this.organisationId,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
}); });
/// Vérifie si la conversation a des messages non lus bool get hasUnread => nonLus > 0;
bool get hasUnread => unreadCount > 0; bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
/// Vérifie si c'est une conversation individuelle bool get isGroupe => typeConversation == 'GROUPE';
bool get isIndividual => type == ConversationType.individual; bool get isActive => statut == 'ACTIVE';
/// 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<String>? participantIds,
String? organizationId,
Message? lastMessage,
int? unreadCount,
bool? isMuted,
bool? isPinned,
bool? isArchived,
DateTime? createdAt,
DateTime? updatedAt,
String? avatarUrl,
Map<String, dynamic>? 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,
);
}
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id, typeConversation, titre, statut,
name, dernierMessageApercu, dernierMessageType, dernierMessageAt,
description, nonLus, organisationId,
type, ];
participantIds, }
organizationId,
lastMessage, // ── Participant ───────────────────────────────────────────────────────────────
unreadCount,
isMuted, /// Participant dans une conversation
isPinned, class ConversationParticipant extends Equatable {
isArchived, final String membreId;
createdAt, final String? prenom;
updatedAt, final String? nom;
avatarUrl, final String? roleDansConversation;
metadata, 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<Object?> 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<ConversationParticipant> participants;
final List<Message> 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<Object?> get props => [
id, typeConversation, titre, statut, organisationId, organisationNom,
dateCreation, nombreMessages, participants, messages, nonLus, roleCible,
]; ];
} }

View File

@@ -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; library message;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Type de message /// Message dans une conversation
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
class Message extends Equatable { class Message extends Equatable {
final String id; final String id;
final String conversationId; final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME
final String senderId; final String? contenu;
final String senderName; final String? urlFichier;
final String? senderAvatar; final int? dureeAudio;
final String content; final bool supprime;
final MessageType type; final String? expediteurId;
final MessageStatus status; final String? expediteurNom;
final MessagePriority priority; final String? expediteurPrenom;
final List<String> recipientIds; final String? messageParentId;
final List<String>? recipientRoles; final String? messageParentApercu;
final String? organizationId; final DateTime? dateEnvoi;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
const Message({ const Message({
required this.id, required this.id,
required this.conversationId, required this.typeMessage,
required this.senderId, this.contenu,
required this.senderName, this.urlFichier,
this.senderAvatar, this.dureeAudio,
required this.content, this.supprime = false,
required this.type, this.expediteurId,
required this.status, this.expediteurNom,
this.priority = MessagePriority.normal, this.expediteurPrenom,
required this.recipientIds, this.messageParentId,
this.recipientRoles, this.messageParentApercu,
this.organizationId, this.dateEnvoi,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
}); });
/// Vérifie si le message a été lu String get expediteurNomComplet {
bool get isRead => status == MessageStatus.read; 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 isTexte => typeMessage == 'TEXTE';
bool get isUrgent => priority == MessagePriority.urgent; 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 /// Texte à afficher dans la liste (aperçu)
bool get isBroadcast => type == MessageType.broadcast; String get apercu {
if (supprime) return '🚫 Message supprimé';
/// Vérifie si le message a des pièces jointes if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}';
bool get hasAttachments => attachments != null && attachments!.isNotEmpty; if (isImage) return '📷 Image';
if (isSysteme) return contenu ?? '🔔 Notification système';
/// Copie avec modifications return contenu ?? '';
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<String>? recipientIds,
List<String>? recipientRoles,
String? organizationId,
DateTime? createdAt,
DateTime? readAt,
Map<String, dynamic>? metadata,
List<String>? 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,
);
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id, typeMessage, contenu, urlFichier, dureeAudio, supprime,
conversationId, expediteurId, expediteurNom, expediteurPrenom,
senderId, messageParentId, messageParentApercu, dateEnvoi,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
]; ];
} }

View File

@@ -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; library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart'; import '../entities/conversation.dart';
import '../entities/message_template.dart'; import '../entities/message.dart';
import '../entities/contact_policy.dart';
/// Interface du repository de messagerie /// Interface du repository de messagerie
abstract class MessagingRepository { abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur // ── Conversations ─────────────────────────────────────────────────────────
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId, /// Récupère les conversations résumées de l'utilisateur connecté
bool includeArchived = false, Future<List<ConversationSummary>> getMesConversations();
/// Récupère la conversation complète (avec participants et messages)
Future<Conversation> getConversation(String conversationId);
/// Démarre une conversation directe avec un membre
Future<Conversation> demarrerConversationDirecte({
required String destinataireId,
required String organisationId,
String? premierMessage,
}); });
/// Récupère une conversation par son ID /// Démarre un canal de communication avec un rôle officiel
Future<Either<Failure, Conversation>> getConversationById(String conversationId); Future<Conversation> demarrerConversationRole({
required String roleCible,
/// Crée une nouvelle conversation required String organisationId,
Future<Either<Failure, Conversation>> createConversation({ String? premierMessage,
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}); });
/// Archive une conversation /// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId); Future<void> archiverConversation(String conversationId);
/// Marque une conversation comme lue // ── Messages ──────────────────────────────────────────────────────────────
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
/// Mute/démute une conversation /// Envoie un message dans une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId); Future<Message> envoyerMessage(
String conversationId, {
/// Pin/unpin une conversation required String typeMessage,
Future<Either<Failure, void>> togglePinConversation(String conversationId); String? contenu,
String? urlFichier,
// === MESSAGES === int? dureeAudio,
String? messageParentId,
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}); });
/// Envoie un message individuel /// Récupère l'historique des messages (paginé)
Future<Either<Failure, Message>> sendMessage({ Future<List<Message>> getMessages(String conversationId, {int page = 0});
required String conversationId,
required String content, /// Marque tous les messages d'une conversation comme lus
List<String>? attachments, Future<void> marquerLu(String conversationId);
MessagePriority priority = MessagePriority.normal,
/// Supprime un message (soft-delete)
Future<void> supprimerMessage(String conversationId, String messageId);
// ── Blocages ──────────────────────────────────────────────────────────────
/// Bloque un membre
Future<void> bloquerMembre({
required String membreABloquerId,
String? organisationId,
String? raison,
}); });
/// Envoie un broadcast à toute l'organisation /// Débloque un membre
Future<Either<Failure, Message>> sendBroadcast({ Future<void> debloquerMembre(String membreId, {String? organisationId});
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Envoie un message ciblé par rôles /// Récupère la liste des membres bloqués
Future<Either<Failure, Message>> sendTargetedMessage({ Future<List<Map<String, dynamic>>> getMesBlocages();
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Marque un message comme lu // ── Politique de communication ────────────────────────────────────────────
Future<Either<Failure, void>> markMessageAsRead(String messageId);
/// Édite un message /// Récupère la politique de communication d'une organisation
Future<Either<Failure, Message>> editMessage({ Future<ContactPolicy> getPolitique(String organisationId);
required String messageId,
required String newContent,
});
/// Supprime un message /// Met à jour la politique de communication (ADMIN seulement)
Future<Either<Failure, void>> deleteMessage(String messageId); Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
// === TEMPLATES === required String typePolitique,
required bool autoriserMembreVersMembre,
/// Récupère tous les templates disponibles required bool autoriserMembreVersRole,
Future<Either<Failure, List<MessageTemplate>>> getTemplates({ required bool autoriserNotesVocales,
String? organizationId,
TemplateCategory? category,
});
/// Récupère un template par son ID
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId);
/// Crée un nouveau template
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
});
/// Met à jour un template
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
});
/// Supprime un template
Future<Either<Failure, void>> deleteTemplate(String templateId);
/// Envoie un message à partir d'un template
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
});
// === STATISTIQUES ===
/// Récupère le nombre de messages non lus
Future<Either<Failure, int>> getUnreadCount({String? organizationId});
/// Récupère les statistiques de communication
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}); });
} }

View File

@@ -1,9 +1,8 @@
/// Use case: Récupérer les conversations /// Use case: Récupérer les conversations v4
library get_conversations; library get_conversations;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/conversation.dart'; import '../entities/conversation.dart';
import '../repositories/messaging_repository.dart'; import '../repositories/messaging_repository.dart';
@@ -13,13 +12,7 @@ class GetConversations {
GetConversations(this.repository); GetConversations(this.repository);
Future<Either<Failure, List<Conversation>>> call({ Future<List<ConversationSummary>> call() async {
String? organizationId, return await repository.getMesConversations();
bool includeArchived = false,
}) async {
return await repository.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
} }
} }

View File

@@ -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; library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart'; import '../entities/message.dart';
import '../repositories/messaging_repository.dart'; import '../repositories/messaging_repository.dart';
@@ -13,19 +12,10 @@ class GetMessages {
GetMessages(this.repository); GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({ Future<List<Message>> call({
required String conversationId, required String conversationId,
int? limit, int page = 0,
String? beforeMessageId,
}) async { }) async {
if (conversationId.isEmpty) { return await repository.getMessages(conversationId, page: page);
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
} }
} }

View File

@@ -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; library send_broadcast;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart'; import '../repositories/messaging_repository.dart';
@lazySingleton @lazySingleton
@@ -13,32 +13,13 @@ class SendBroadcast {
SendBroadcast(this.repository); SendBroadcast(this.repository);
Future<Either<Failure, Message>> call({ /// Démarre un canal de communication avec le rôle BUREAU
required String organizationId, Future<void> call({
required String subject, required String organisationId,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async { }) async {
// Validation await repository.demarrerConversationRole(
if (subject.trim().isEmpty) { roleCible: 'PRESIDENT',
return Left(ValidationFailure('Le sujet ne peut pas être vide')); organisationId: organisationId,
}
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,
); );
} }
} }

View File

@@ -1,9 +1,8 @@
/// Use case: Envoyer un message /// Use case: Envoyer un message v4
library send_message; library send_message;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart'; import '../entities/message.dart';
import '../repositories/messaging_repository.dart'; import '../repositories/messaging_repository.dart';
@@ -13,22 +12,15 @@ class SendMessage {
SendMessage(this.repository); SendMessage(this.repository);
Future<Either<Failure, Message>> call({ Future<Message> call({
required String conversationId, required String conversationId,
required String content, required String contenu,
List<String>? attachments, String typeMessage = 'TEXTE',
MessagePriority priority = MessagePriority.normal,
}) async { }) async {
// Validation return await repository.envoyerMessage(
if (content.trim().isEmpty) { conversationId,
return Left(ValidationFailure('Le message ne peut pas être vide')); typeMessage: typeMessage,
} contenu: contenu,
return await repository.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
); );
} }
} }

View File

@@ -1,105 +1,202 @@
/// BLoC de gestion de la messagerie /// BLoC de gestion de la messagerie v4
library messaging_bloc; library messaging_bloc;
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_conversations.dart';
import '../../domain/usecases/get_messages.dart'; import '../../../../core/utils/logger.dart';
import '../../domain/usecases/send_message.dart'; import '../../../../core/websocket/websocket_service.dart';
import '../../domain/usecases/send_broadcast.dart'; import '../../domain/repositories/messaging_repository.dart';
import 'messaging_event.dart'; import 'messaging_event.dart';
import 'messaging_state.dart'; import 'messaging_state.dart';
@injectable @injectable
class MessagingBloc extends Bloc<MessagingEvent, MessagingState> { class MessagingBloc extends Bloc<MessagingEvent, MessagingState> {
final GetConversations getConversations; final MessagingRepository _repository;
final GetMessages getMessages; final WebSocketService _webSocketService;
final SendMessage sendMessage;
final SendBroadcast sendBroadcast; StreamSubscription<WebSocketEvent>? _wsSubscription;
String? _currentConversationId;
MessagingBloc({ MessagingBloc({
required this.getConversations, required MessagingRepository repository,
required this.getMessages, required WebSocketService webSocketService,
required this.sendMessage, }) : _repository = repository,
required this.sendBroadcast, _webSocketService = webSocketService,
}) : super(MessagingInitial()) { super(MessagingInitial()) {
on<LoadConversations>(_onLoadConversations); on<LoadMesConversations>(_onLoadMesConversations);
on<OpenConversation>(_onOpenConversation);
on<DemarrerConversationDirecte>(_onDemarrerConversationDirecte);
on<DemarrerConversationRole>(_onDemarrerConversationRole);
on<ArchiverConversation>(_onArchiverConversation);
on<EnvoyerMessageTexte>(_onEnvoyerMessageTexte);
on<LoadMessages>(_onLoadMessages); on<LoadMessages>(_onLoadMessages);
on<SendMessageEvent>(_onSendMessage); on<MarquerLu>(_onMarquerLu);
on<SendBroadcastEvent>(_onSendBroadcast); on<SupprimerMessage>(_onSupprimerMessage);
on<NouveauMessageWebSocket>(_onNouveauMessageWebSocket);
_listenWebSocket();
} }
Future<void> _onLoadConversations( void _listenWebSocket() {
LoadConversations event, _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<void> _onLoadMesConversations(
LoadMesConversations event,
Emitter<MessagingState> emit, Emitter<MessagingState> emit,
) async { ) async {
emit(MessagingLoading()); emit(MessagingLoading());
try {
final conversations = await _repository.getMesConversations();
emit(MesConversationsLoaded(conversations));
} catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
}
final result = await getConversations( Future<void> _onOpenConversation(
organizationId: event.organizationId, OpenConversation event,
includeArchived: event.includeArchived, Emitter<MessagingState> 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( Future<void> _onDemarrerConversationDirecte(
(failure) => emit(MessagingError(failure.message)), DemarrerConversationDirecte event,
(conversations) => emit(ConversationsLoaded(conversations: conversations)), Emitter<MessagingState> 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<void> _onDemarrerConversationRole(
DemarrerConversationRole event,
Emitter<MessagingState> 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<void> _onArchiverConversation(
ArchiverConversation event,
Emitter<MessagingState> emit,
) async {
try {
await _repository.archiverConversation(event.conversationId);
emit(const MessagingActionOk('archiver'));
add(const LoadMesConversations());
} catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
}
Future<void> _onEnvoyerMessageTexte(
EnvoyerMessageTexte event,
Emitter<MessagingState> 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<void> _onLoadMessages( Future<void> _onLoadMessages(
LoadMessages event, LoadMessages event,
Emitter<MessagingState> emit, Emitter<MessagingState> emit,
) async { ) async {
emit(MessagingLoading()); try {
final messages = await _repository.getMessages(event.conversationId, page: event.page);
final result = await getMessages( emit(MessagesLoaded(
conversationId: event.conversationId,
limit: event.limit,
beforeMessageId: event.beforeMessageId,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(messages) => emit(MessagesLoaded(
conversationId: event.conversationId, conversationId: event.conversationId,
messages: messages, messages: messages,
hasMore: messages.length == (event.limit ?? 50), hasMore: messages.length >= 20,
)), ));
); } catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
} }
Future<void> _onSendMessage( Future<void> _onMarquerLu(
SendMessageEvent event, MarquerLu event,
Emitter<MessagingState> emit, Emitter<MessagingState> emit,
) async { ) async {
final result = await sendMessage( await _repository.marquerLu(event.conversationId);
conversationId: event.conversationId,
content: event.content,
attachments: event.attachments,
priority: event.priority,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(MessageSent(message)),
);
} }
Future<void> _onSendBroadcast( Future<void> _onSupprimerMessage(
SendBroadcastEvent event, SupprimerMessage event,
Emitter<MessagingState> emit, Emitter<MessagingState> emit,
) async { ) async {
final result = await sendBroadcast( try {
organizationId: event.organizationId, await _repository.supprimerMessage(event.conversationId, event.messageId);
subject: event.subject, emit(const MessagingActionOk('supprimer-message'));
content: event.content, add(LoadMessages(conversationId: event.conversationId));
priority: event.priority, } catch (e) {
attachments: event.attachments, emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
); }
}
result.fold( Future<void> _onNouveauMessageWebSocket(
(failure) => emit(MessagingError(failure.message)), NouveauMessageWebSocket event,
(message) => emit(BroadcastSent(message)), Emitter<MessagingState> 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<void> close() {
_wsSubscription?.cancel();
_currentConversationId = null;
return super.close();
} }
} }

View File

@@ -1,8 +1,7 @@
/// Événements du BLoC Messaging /// Événements du BLoC Messagerie v4
library messaging_event; library messaging_event;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../domain/entities/message.dart';
abstract class MessagingEvent extends Equatable { abstract class MessagingEvent extends Equatable {
const MessagingEvent(); const MessagingEvent();
@@ -11,108 +10,126 @@ abstract class MessagingEvent extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
/// Charger les conversations // ── Conversations ─────────────────────────────────────────────────────────────
class LoadConversations extends MessagingEvent {
final String? organizationId;
final bool includeArchived;
const LoadConversations({ /// Charger la liste des conversations
this.organizationId, class LoadMesConversations extends MessagingEvent {
this.includeArchived = false, const LoadMesConversations();
}
/// Ouvrir une conversation (charge le détail)
class OpenConversation extends MessagingEvent {
final String conversationId;
const OpenConversation(this.conversationId);
@override
List<Object?> 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 @override
List<Object?> get props => [organizationId, includeArchived]; List<Object?> 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<Object?> get props => [roleCible, organisationId, premierMessage];
}
/// Archiver une conversation
class ArchiverConversation extends MessagingEvent {
final String conversationId;
const ArchiverConversation(this.conversationId);
@override
List<Object?> 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<Object?> get props => [conversationId, contenu, messageParentId];
}
/// Charger l'historique des messages
class LoadMessages extends MessagingEvent { class LoadMessages extends MessagingEvent {
final String conversationId; final String conversationId;
final int? limit; final int page;
final String? beforeMessageId;
const LoadMessages({ const LoadMessages({required this.conversationId, this.page = 0});
required this.conversationId,
this.limit,
this.beforeMessageId,
});
@override @override
List<Object?> get props => [conversationId, limit, beforeMessageId]; List<Object?> get props => [conversationId, page];
} }
/// Envoyer un message /// Marquer une conversation comme lue
class SendMessageEvent extends MessagingEvent { class MarquerLu extends MessagingEvent {
final String conversationId; final String conversationId;
final String content;
final List<String>? attachments;
final MessagePriority priority;
const SendMessageEvent({ const MarquerLu(this.conversationId);
required this.conversationId,
required this.content,
this.attachments,
this.priority = MessagePriority.normal,
});
@override @override
List<Object?> get props => [conversationId, content, attachments, priority]; List<Object?> get props => [conversationId];
} }
/// Envoyer un broadcast /// Supprimer un message
class SendBroadcastEvent extends MessagingEvent { class SupprimerMessage extends MessagingEvent {
final String organizationId; final String conversationId;
final String subject;
final String content;
final MessagePriority priority;
final List<String>? attachments;
const SendBroadcastEvent({
required this.organizationId,
required this.subject,
required this.content,
this.priority = MessagePriority.normal,
this.attachments,
});
@override
List<Object?> get props => [organizationId, subject, content, priority, attachments];
}
/// Marquer un message comme lu
class MarkMessageAsReadEvent extends MessagingEvent {
final String messageId; final String messageId;
const MarkMessageAsReadEvent(this.messageId); const SupprimerMessage({
required this.conversationId,
@override required this.messageId,
List<Object?> get props => [messageId];
}
/// Charger le nombre de messages non lus
class LoadUnreadCount extends MessagingEvent {
final String? organizationId;
const LoadUnreadCount({this.organizationId});
@override
List<Object?> get props => [organizationId];
}
/// Créer une nouvelle conversation
class CreateConversationEvent extends MessagingEvent {
final String name;
final List<String> participantIds;
final String? organizationId;
final String? description;
const CreateConversationEvent({
required this.name,
required this.participantIds,
this.organizationId,
this.description,
}); });
@override @override
List<Object?> get props => [name, participantIds, organizationId, description]; List<Object?> 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<Object?> get props => [conversationId];
} }

View File

@@ -1,7 +1,8 @@
/// États du BLoC Messaging /// États du BLoC Messagerie v4
library messaging_state; library messaging_state;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import '../../domain/entities/conversation.dart'; import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart'; import '../../domain/entities/message.dart';
@@ -18,18 +19,24 @@ class MessagingInitial extends MessagingState {}
/// Chargement en cours /// Chargement en cours
class MessagingLoading extends MessagingState {} class MessagingLoading extends MessagingState {}
/// Conversations chargées /// Liste des conversations chargée
class ConversationsLoaded extends MessagingState { class MesConversationsLoaded extends MessagingState {
final List<Conversation> conversations; final List<ConversationSummary> conversations;
final int unreadCount;
const ConversationsLoaded({ const MesConversationsLoaded(this.conversations);
required this.conversations,
this.unreadCount = 0,
});
@override @override
List<Object?> get props => [conversations, unreadCount]; List<Object?> get props => [conversations];
}
/// Conversation ouverte (détail avec messages)
class ConversationOuverte extends MessagingState {
final Conversation conversation;
const ConversationOuverte(this.conversation);
@override
List<Object?> get props => [conversation];
} }
/// Messages d'une conversation chargés /// Messages d'une conversation chargés
@@ -48,44 +55,35 @@ class MessagesLoaded extends MessagingState {
List<Object?> get props => [conversationId, messages, hasMore]; List<Object?> get props => [conversationId, messages, hasMore];
} }
/// Message envoyé avec succès /// Message envoyé avec succès — la conversation s'actualise
class MessageSent extends MessagingState { class MessageEnvoye extends MessagingState {
final Message message; final Message message;
final String conversationId;
const MessageSent(this.message); const MessageEnvoye({required this.message, required this.conversationId});
@override @override
List<Object?> get props => [message]; List<Object?> get props => [message, conversationId];
} }
/// Broadcast envoyé avec succès /// Conversation créée (après démarrage direct/rôle)
class BroadcastSent extends MessagingState { class ConversationCreee extends MessagingState {
final Message message;
const BroadcastSent(this.message);
@override
List<Object?> get props => [message];
}
/// Conversation créée
class ConversationCreated extends MessagingState {
final Conversation conversation; final Conversation conversation;
const ConversationCreated(this.conversation); const ConversationCreee(this.conversation);
@override @override
List<Object?> get props => [conversation]; List<Object?> get props => [conversation];
} }
/// Compteur de non lus chargé /// Action silencieuse réussie (marquer lu, supprimer message, etc.)
class UnreadCountLoaded extends MessagingState { class MessagingActionOk extends MessagingState {
final int count; final String action;
const UnreadCountLoaded(this.count); const MessagingActionOk(this.action);
@override @override
List<Object?> get props => [count]; List<Object?> get props => [action];
} }
/// Erreur /// Erreur

View File

@@ -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<BroadcastPage> createState() => _BroadcastPageState();
}
class _BroadcastPageState extends State<BroadcastPage> {
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<MessagingBloc>().add(
DemarrerConversationRole(
roleCible: _selectedRole,
organisationId: widget.organizationId,
premierMessage: message,
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return BlocListener<MessagingBloc, MessagingState>(
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<MessagingBloc>(),
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,
),
),
],
),
),
),
),
);
}
}

View File

@@ -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<ConversationDetailPage> createState() => _ConversationDetailPageState();
}
class _ConversationDetailPageState extends State<ConversationDetailPage> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final FocusNode _focusNode = FocusNode();
Conversation? _conversation;
String? _currentUserId;
@override
void initState() {
super.initState();
context.read<MessagingBloc>().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<MessagingBloc>().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<MessagingBloc, MessagingState>(
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<bool>(
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<MessagingBloc>().add(ArchiverConversation(widget.conversationId));
Navigator.of(context).pop();
}
});
}
Widget _buildMessagesList(List<Message> 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<void>(
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<MessagingBloc>().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}';
}
}

View File

@@ -1,143 +1,141 @@
/// Page liste des conversations /// Page liste des conversations v4
library conversations_page; library conversations_page;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.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_bloc.dart';
import '../bloc/messaging_event.dart'; import '../bloc/messaging_event.dart';
import '../bloc/messaging_state.dart'; import '../bloc/messaging_state.dart';
import '../widgets/conversation_tile.dart'; import '../widgets/conversation_tile.dart';
import 'conversation_detail_page.dart';
class ConversationsPage extends StatelessWidget { class ConversationsPage extends StatelessWidget {
final String? organizationId; const ConversationsPage({super.key});
const ConversationsPage({
super.key,
this.organizationId,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( final scheme = Theme.of(context).colorScheme;
create: (_) => sl<MessagingBloc>()
..add(LoadConversations(organizationId: organizationId)),
child: Scaffold(
backgroundColor: ColorTokens.background,
appBar: const UFAppBar(
title: 'MESSAGES',
automaticallyImplyLeading: true,
),
body: BlocBuilder<MessagingBloc, MessagingState>(
builder: (context, state) {
if (state is MessagingLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is MessagingError) { return Scaffold(
return Center( backgroundColor: scheme.surface,
child: Column( appBar: UFAppBar(
mainAxisAlignment: MainAxisAlignment.center, title: 'Messages',
children: [ moduleGradient: ModuleColors.communicationGradient,
const Icon( automaticallyImplyLeading: true,
Icons.error_outline, ),
size: 64, body: BlocConsumer<MessagingBloc, MessagingState>(
color: AppColors.error, listener: (context, state) {
), if (state is MessagingError) {
const SizedBox(height: SpacingTokens.md), ScaffoldMessenger.of(context).showSnackBar(
Text( SnackBar(
'Erreur', content: Text(state.message),
style: AppTypography.headerSmall, backgroundColor: ColorTokens.error,
), ),
const SizedBox(height: SpacingTokens.sm), );
Text( }
state.message, if (state is ConversationCreee) {
style: AppTypography.bodyTextSmall, _openDetail(context, state.conversation.id);
textAlign: TextAlign.center, }
), },
const SizedBox(height: SpacingTokens.lg), builder: (context, state) {
UFPrimaryButton( if (state is MessagingLoading) {
label: 'Réessayer', return const Center(child: CircularProgressIndicator());
onPressed: () { }
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId), 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<ConversationSummary> 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<MessagingBloc>().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<MessagingBloc>().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),
), ),
); const SizedBox(height: SpacingTokens.md),
} Text('Aucune conversation', style: AppTypography.headerSmall),
const SizedBox(height: SpacingTokens.sm),
if (state is ConversationsLoaded) { Text(
final conversations = state.conversations; 'Contactez un membre ou le bureau\nde votre organisation',
style: AppTypography.bodyTextSmall,
if (conversations.isEmpty) { textAlign: TextAlign.center,
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<MessagingBloc>().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',
);
},
);
},
), ),
); ],
} ),
),
return const SizedBox.shrink();
},
), ),
floatingActionButton: FloatingActionButton( ),
backgroundColor: AppColors.primaryGreen, );
onPressed: () { }
SnackbarHelper.showNotImplemented(context, 'Nouvelle conversation');
}, void _openDetail(BuildContext context, String conversationId) {
child: const Icon(Icons.add, color: Colors.white), Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: context.read<MessagingBloc>(),
child: ConversationDetailPage(conversationId: conversationId),
), ),
), ),
); );

View File

@@ -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<MessagingBloc>(
create: (context) {
AppLogger.info('ConversationsPageWrapper: Initialisation du MessagingBloc');
final bloc = sl<MessagingBloc>();
bloc.add(const LoadMesConversations());
return bloc;
},
child: const ConversationsPage(),
);
}
}

View File

@@ -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,
),
),
),
);
}
}