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;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/message_model.dart';
import '../models/conversation_model.dart';
import '../../domain/entities/message.dart';
import '../models/message_model.dart';
import '../models/contact_policy_model.dart';
import 'package:http/http.dart' as http;
@lazySingleton
class MessagingRemoteDatasource {
@@ -22,7 +24,7 @@ class MessagingRemoteDatasource {
required this.authService,
});
/// Headers HTTP avec authentification — rafraîchit le token si expiré (fix IC-03)
/// Headers HTTP avec authentification — rafraîchit le token si expiré
Future<Map<String, String>> _getHeaders() async {
final token = await authService.getValidAccessToken();
return {
@@ -32,290 +34,271 @@ class MessagingRemoteDatasource {
};
}
// === CONVERSATIONS ===
String get _base => '${AppConfig.apiBaseUrl}/api/messagerie';
Future<List<ConversationModel>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organisationId': organizationId,
'includeArchived': includeArchived.toString(),
});
// ── Conversations ─────────────────────────────────────────────────────────
final response = await client.get(uri, headers: await _getHeaders());
Future<List<ConversationSummaryModel>> getMesConversations() async {
final response = await client.get(
Uri.parse('$_base/conversations'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => ConversationModel.fromJson(json))
final list = json.decode(response.body) as List<dynamic>;
return list
.map((j) => ConversationSummaryModel.fromJson(j as Map<String, dynamic>))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des conversations');
}
throw ServerException('Erreur récupération conversations (${response.statusCode})');
}
Future<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 {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/conversations/$conversationId');
// ── Messages ──────────────────────────────────────────────────────────────
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());
_checkAuth(response);
if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Conversation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
final list = json.decode(response.body) as List<dynamic>;
return list
.map((j) => MessageModel.fromJson(j as Map<String, dynamic>))
.toList();
}
throw ServerException('Erreur récupération messages (${response.statusCode})');
}
Future<ConversationModel> createConversation({
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});
Future<void> marquerLu(String conversationId) async {
final response = await client.put(
uri,
Uri.parse('$_base/conversations/$conversationId/lire'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur marquage lu (${response.statusCode})');
}
}
Future<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(),
body: body,
);
if (response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de l\'édition du message');
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
throw ServerException('Erreur blocage membre (${response.statusCode})');
}
}
Future<void> deleteMessage(String messageId) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messages/$messageId');
Future<void> debloquerMembre(String membreId, {String? organisationId}) async {
final uri = Uri.parse('$_base/blocages/$membreId').replace(
queryParameters: {
if (organisationId != null) 'organisationId': organisationId,
},
);
final response = await client.delete(uri, headers: await _getHeaders());
_checkAuth(response);
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 404) {
throw NotFoundException('Message non trouvé');
} else {
throw ServerException('Erreur lors de la suppression du message');
}
throw ServerException('Erreur déblocage membre (${response.statusCode})');
}
}
Future<void> markMessageAsRead(String messageId) async {
// Backend has no per-message read endpoint — use markConversationAsRead
if (AppConfig.enableLogging) {
debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId');
Future<List<Map<String, dynamic>>> getMesBlocages() async {
final response = await client.get(
Uri.parse('$_base/blocages'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
final list = json.decode(response.body) as List<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 {
try {
final conversations = await getConversations(organizationId: organizationId);
return conversations.fold<int>(0, (sum, c) => sum + c.unreadCount);
} catch (_) {
return 0;
// ── Politique de communication ────────────────────────────────────────────
Future<ContactPolicyModel> getPolitique(String organisationId) async {
final response = await client.get(
Uri.parse('$_base/politique/$organisationId'),
headers: await _getHeaders(),
);
_checkAuth(response);
if (response.statusCode == 200) {
return ContactPolicyModel.fromJson(json.decode(response.body) as Map<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;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart';
part 'conversation_model.g.dart';
@JsonSerializable(explicitToJson: true)
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
/// Modèle de résumé de conversation (liste)
class ConversationSummaryModel extends ConversationSummary {
const ConversationSummaryModel({
required super.id,
required super.name,
super.description,
required super.type,
required super.participantIds,
super.organizationId,
this.lastMessage,
super.unreadCount,
super.isMuted,
super.isPinned,
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
required super.typeConversation,
required super.titre,
required super.statut,
super.dernierMessageApercu,
super.dernierMessageType,
super.dernierMessageAt,
super.nonLus,
super.organisationId,
});
static Message? _messageFromJson(Map<String, dynamic>? json) =>
json == null ? null : MessageModel.fromJson(json);
static Map<String, dynamic>? _messageToJson(Message? message) =>
message == null ? null : MessageModel.fromEntity(message).toJson();
factory ConversationModel.fromJson(Map<String, dynamic> json) =>
_$ConversationModelFromJson(json);
Map<String, dynamic> toJson() => _$ConversationModelToJson(this);
factory ConversationModel.fromEntity(Conversation conversation) {
return ConversationModel(
id: conversation.id,
name: conversation.name,
description: conversation.description,
type: conversation.type,
participantIds: conversation.participantIds,
organizationId: conversation.organizationId,
lastMessage: conversation.lastMessage,
unreadCount: conversation.unreadCount,
isMuted: conversation.isMuted,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
avatarUrl: conversation.avatarUrl,
metadata: conversation.metadata,
factory ConversationSummaryModel.fromJson(Map<String, dynamic> json) {
return ConversationSummaryModel(
id: json['id']?.toString() ?? '',
typeConversation: json['typeConversation']?.toString() ?? 'DIRECTE',
titre: json['titre']?.toString() ?? '',
statut: json['statut']?.toString() ?? 'ACTIVE',
dernierMessageApercu: json['dernierMessageApercu']?.toString(),
dernierMessageType: json['dernierMessageType']?.toString(),
dernierMessageAt: json['dernierMessageAt'] != null
? DateTime.tryParse(json['dernierMessageAt'].toString())
: null,
nonLus: _parseInt(json['nonLus']),
organisationId: json['organisationId']?.toString(),
);
}
Conversation toEntity() => this;
}
/// Modèle de participant
class ConversationParticipantModel extends ConversationParticipant {
const ConversationParticipantModel({
required super.membreId,
super.prenom,
super.nom,
super.roleDansConversation,
super.luJusqua,
});
factory ConversationParticipantModel.fromJson(Map<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
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',
};
// Modèles v4 : désérialisation manuelle, code generation non utilisé.

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

View File

@@ -1,84 +1,2 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
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',
};
// Modèles v4 : désérialisation manuelle, code generation non utilisé.

View File

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

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;
import 'package:equatable/equatable.dart';
import 'message.dart';
/// Type de conversation
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
// ── Résumé de conversation (liste) ───────────────────────────────────────────
/// Conversation de groupe
group,
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
/// Résumé d'une conversation pour l'affichage en liste
class ConversationSummary extends Equatable {
final String id;
final String name;
final String? description;
final ConversationType type;
final List<String> participantIds;
final String? organizationId;
final Message? lastMessage;
final int unreadCount;
final bool isMuted;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
final String typeConversation; // DIRECTE | ROLE_CANAL | GROUPE
final String titre;
final String statut; // ACTIVE | ARCHIVEE
final String? dernierMessageApercu;
final String? dernierMessageType;
final DateTime? dernierMessageAt;
final int nonLus;
final String? organisationId;
const Conversation({
const ConversationSummary({
required this.id,
required this.name,
this.description,
required this.type,
required this.participantIds,
this.organizationId,
this.lastMessage,
this.unreadCount = 0,
this.isMuted = false,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
required this.typeConversation,
required this.titre,
required this.statut,
this.dernierMessageApercu,
this.dernierMessageType,
this.dernierMessageAt,
this.nonLus = 0,
this.organisationId,
});
/// Vérifie si la conversation a des messages non lus
bool get hasUnread => unreadCount > 0;
/// Vérifie si c'est une conversation individuelle
bool get isIndividual => type == ConversationType.individual;
/// Vérifie si c'est un broadcast
bool get isBroadcast => type == ConversationType.broadcast;
/// Nombre de participants
int get participantCount => participantIds.length;
/// Copie avec modifications
Conversation copyWith({
String? id,
String? name,
String? description,
ConversationType? type,
List<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,
);
}
bool get hasUnread => nonLus > 0;
bool get isDirecte => typeConversation == 'DIRECTE';
bool get isRoleCanal => typeConversation == 'ROLE_CANAL';
bool get isGroupe => typeConversation == 'GROUPE';
bool get isActive => statut == 'ACTIVE';
@override
List<Object?> get props => [
id,
name,
description,
type,
participantIds,
organizationId,
lastMessage,
unreadCount,
isMuted,
isPinned,
isArchived,
createdAt,
updatedAt,
avatarUrl,
metadata,
id, typeConversation, titre, statut,
dernierMessageApercu, dernierMessageType, dernierMessageAt,
nonLus, organisationId,
];
}
// ── Participant ───────────────────────────────────────────────────────────────
/// Participant dans une conversation
class ConversationParticipant extends Equatable {
final String membreId;
final String? prenom;
final String? nom;
final String? roleDansConversation;
final DateTime? luJusqua;
const ConversationParticipant({
required this.membreId,
this.prenom,
this.nom,
this.roleDansConversation,
this.luJusqua,
});
String get nomComplet {
if (prenom != null && nom != null) return '$prenom $nom';
if (prenom != null) return prenom!;
if (nom != null) return nom!;
return membreId;
}
@override
List<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;
import 'package:equatable/equatable.dart';
/// Type de message
enum MessageType {
/// Message individuel (membre à membre)
individual,
/// Broadcast organisation (OrgAdmin → tous)
broadcast,
/// Message ciblé par rôle (Moderator → groupe)
targeted,
/// Notification système
system,
}
/// Statut de lecture du message
enum MessageStatus {
/// Envoyé mais non lu
sent,
/// Livré (reçu par le serveur)
delivered,
/// Lu par le destinataire
read,
/// Échec d'envoi
failed,
}
/// Priorité du message
enum MessagePriority {
/// Priorité normale
normal,
/// Priorité élevée (important)
high,
/// Priorité urgente (critique)
urgent,
}
/// Entité Message
/// Message dans une conversation
class Message extends Equatable {
final String id;
final String conversationId;
final String senderId;
final String senderName;
final String? senderAvatar;
final String content;
final MessageType type;
final MessageStatus status;
final MessagePriority priority;
final List<String> recipientIds;
final List<String>? recipientRoles;
final String? organizationId;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
final String typeMessage; // TEXTE | VOCAL | IMAGE | SYSTEME
final String? contenu;
final String? urlFichier;
final int? dureeAudio;
final bool supprime;
final String? expediteurId;
final String? expediteurNom;
final String? expediteurPrenom;
final String? messageParentId;
final String? messageParentApercu;
final DateTime? dateEnvoi;
const Message({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderName,
this.senderAvatar,
required this.content,
required this.type,
required this.status,
this.priority = MessagePriority.normal,
required this.recipientIds,
this.recipientRoles,
this.organizationId,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
required this.typeMessage,
this.contenu,
this.urlFichier,
this.dureeAudio,
this.supprime = false,
this.expediteurId,
this.expediteurNom,
this.expediteurPrenom,
this.messageParentId,
this.messageParentApercu,
this.dateEnvoi,
});
/// Vérifie si le message a été lu
bool get isRead => status == MessageStatus.read;
String get expediteurNomComplet {
if (expediteurPrenom != null && expediteurNom != null) {
return '$expediteurPrenom $expediteurNom';
}
if (expediteurPrenom != null) return expediteurPrenom!;
if (expediteurNom != null) return expediteurNom!;
return '';
}
/// Vérifie si le message est urgent
bool get isUrgent => priority == MessagePriority.urgent;
bool get isTexte => typeMessage == 'TEXTE';
bool get isVocal => typeMessage == 'VOCAL';
bool get isImage => typeMessage == 'IMAGE';
bool get isSysteme => typeMessage == 'SYSTEME';
bool get hasParent => messageParentId != null;
/// Vérifie si le message est un broadcast
bool get isBroadcast => type == MessageType.broadcast;
/// Vérifie si le message a des pièces jointes
bool get hasAttachments => attachments != null && attachments!.isNotEmpty;
/// Copie avec modifications
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<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,
);
/// Texte à afficher dans la liste (aperçu)
String get apercu {
if (supprime) return '🚫 Message supprimé';
if (isVocal) return '🎙️ Note vocale${dureeAudio != null ? ' (${dureeAudio}s)' : ''}';
if (isImage) return '📷 Image';
if (isSysteme) return contenu ?? '🔔 Notification système';
return contenu ?? '';
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
id, typeMessage, contenu, urlFichier, dureeAudio, supprime,
expediteurId, expediteurNom, expediteurPrenom,
messageParentId, messageParentApercu, dateEnvoi,
];
}

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;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart';
import '../entities/message_template.dart';
import '../entities/message.dart';
import '../entities/contact_policy.dart';
/// Interface du repository de messagerie
abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
// ── Conversations ─────────────────────────────────────────────────────────
/// Récupère les conversations résumées de l'utilisateur connecté
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
Future<Either<Failure, Conversation>> getConversationById(String conversationId);
/// Crée une nouvelle conversation
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
/// Démarre un canal de communication avec un rôle officiel
Future<Conversation> demarrerConversationRole({
required String roleCible,
required String organisationId,
String? premierMessage,
});
/// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId);
Future<void> archiverConversation(String conversationId);
/// Marque une conversation comme lue
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
// ── Messages ──────────────────────────────────────────────────────────────
/// Mute/démute une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId);
/// Pin/unpin une conversation
Future<Either<Failure, void>> togglePinConversation(String conversationId);
// === MESSAGES ===
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
/// Envoie un message dans une conversation
Future<Message> envoyerMessage(
String conversationId, {
required String typeMessage,
String? contenu,
String? urlFichier,
int? dureeAudio,
String? messageParentId,
});
/// Envoie un message individuel
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
/// Récupère l'historique des messages (paginé)
Future<List<Message>> getMessages(String conversationId, {int page = 0});
/// Marque tous les messages d'une conversation comme lus
Future<void> marquerLu(String conversationId);
/// 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
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Débloque un membre
Future<void> debloquerMembre(String membreId, {String? organisationId});
/// Envoie un message ciblé par rôles
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Récupère la liste des membres bloqués
Future<List<Map<String, dynamic>>> getMesBlocages();
/// Marque un message comme lu
Future<Either<Failure, void>> markMessageAsRead(String messageId);
// ── Politique de communication ────────────────────────────────────────────
/// Édite un message
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
});
/// Récupère la politique de communication d'une organisation
Future<ContactPolicy> getPolitique(String organisationId);
/// Supprime un message
Future<Either<Failure, void>> deleteMessage(String messageId);
// === TEMPLATES ===
/// Récupère tous les templates disponibles
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
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,
/// Met à jour la politique de communication (ADMIN seulement)
Future<ContactPolicy> mettreAJourPolitique(
String organisationId, {
required String typePolitique,
required bool autoriserMembreVersMembre,
required bool autoriserMembreVersRole,
required bool autoriserNotesVocales,
});
}

View File

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

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

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

View File

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

View File

@@ -1,105 +1,202 @@
/// BLoC de gestion de la messagerie
/// BLoC de gestion de la messagerie v4
library messaging_bloc;
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_conversations.dart';
import '../../domain/usecases/get_messages.dart';
import '../../domain/usecases/send_message.dart';
import '../../domain/usecases/send_broadcast.dart';
import '../../../../core/utils/logger.dart';
import '../../../../core/websocket/websocket_service.dart';
import '../../domain/repositories/messaging_repository.dart';
import 'messaging_event.dart';
import 'messaging_state.dart';
@injectable
class MessagingBloc extends Bloc<MessagingEvent, MessagingState> {
final GetConversations getConversations;
final GetMessages getMessages;
final SendMessage sendMessage;
final SendBroadcast sendBroadcast;
final MessagingRepository _repository;
final WebSocketService _webSocketService;
StreamSubscription<WebSocketEvent>? _wsSubscription;
String? _currentConversationId;
MessagingBloc({
required this.getConversations,
required this.getMessages,
required this.sendMessage,
required this.sendBroadcast,
}) : super(MessagingInitial()) {
on<LoadConversations>(_onLoadConversations);
required MessagingRepository repository,
required WebSocketService webSocketService,
}) : _repository = repository,
_webSocketService = webSocketService,
super(MessagingInitial()) {
on<LoadMesConversations>(_onLoadMesConversations);
on<OpenConversation>(_onOpenConversation);
on<DemarrerConversationDirecte>(_onDemarrerConversationDirecte);
on<DemarrerConversationRole>(_onDemarrerConversationRole);
on<ArchiverConversation>(_onArchiverConversation);
on<EnvoyerMessageTexte>(_onEnvoyerMessageTexte);
on<LoadMessages>(_onLoadMessages);
on<SendMessageEvent>(_onSendMessage);
on<SendBroadcastEvent>(_onSendBroadcast);
on<MarquerLu>(_onMarquerLu);
on<SupprimerMessage>(_onSupprimerMessage);
on<NouveauMessageWebSocket>(_onNouveauMessageWebSocket);
_listenWebSocket();
}
Future<void> _onLoadConversations(
LoadConversations event,
void _listenWebSocket() {
_wsSubscription = _webSocketService.eventStream.listen((event) {
if (event is ChatMessageEvent && event.conversationId != null) {
AppLogger.info('MessagingBloc: NOUVEAU_MESSAGE WS pour conv ${event.conversationId}');
add(NouveauMessageWebSocket(event.conversationId!));
}
});
}
Future<void> _onLoadMesConversations(
LoadMesConversations event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
try {
final conversations = await _repository.getMesConversations();
emit(MesConversationsLoaded(conversations));
} catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
}
final result = await getConversations(
organizationId: event.organizationId,
includeArchived: event.includeArchived,
);
Future<void> _onOpenConversation(
OpenConversation event,
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(
(failure) => emit(MessagingError(failure.message)),
(conversations) => emit(ConversationsLoaded(conversations: conversations)),
);
Future<void> _onDemarrerConversationDirecte(
DemarrerConversationDirecte event,
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(
LoadMessages event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
final result = await getMessages(
conversationId: event.conversationId,
limit: event.limit,
beforeMessageId: event.beforeMessageId,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(messages) => emit(MessagesLoaded(
try {
final messages = await _repository.getMessages(event.conversationId, page: event.page);
emit(MessagesLoaded(
conversationId: event.conversationId,
messages: messages,
hasMore: messages.length == (event.limit ?? 50),
)),
);
hasMore: messages.length >= 20,
));
} catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
}
Future<void> _onSendMessage(
SendMessageEvent event,
Future<void> _onMarquerLu(
MarquerLu event,
Emitter<MessagingState> emit,
) async {
final result = await sendMessage(
conversationId: event.conversationId,
content: event.content,
attachments: event.attachments,
priority: event.priority,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(MessageSent(message)),
);
await _repository.marquerLu(event.conversationId);
}
Future<void> _onSendBroadcast(
SendBroadcastEvent event,
Future<void> _onSupprimerMessage(
SupprimerMessage event,
Emitter<MessagingState> emit,
) async {
final result = await sendBroadcast(
organizationId: event.organizationId,
subject: event.subject,
content: event.content,
priority: event.priority,
attachments: event.attachments,
);
try {
await _repository.supprimerMessage(event.conversationId, event.messageId);
emit(const MessagingActionOk('supprimer-message'));
add(LoadMessages(conversationId: event.conversationId));
} catch (e) {
emit(MessagingError(e.toString().replaceFirst('Exception: ', '')));
}
}
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(BroadcastSent(message)),
);
Future<void> _onNouveauMessageWebSocket(
NouveauMessageWebSocket event,
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;
import 'package:equatable/equatable.dart';
import '../../domain/entities/message.dart';
abstract class MessagingEvent extends Equatable {
const MessagingEvent();
@@ -11,108 +10,126 @@ abstract class MessagingEvent extends Equatable {
List<Object?> get props => [];
}
/// Charger les conversations
class LoadConversations extends MessagingEvent {
final String? organizationId;
final bool includeArchived;
// ── Conversations ─────────────────────────────────────────────────────────────
const LoadConversations({
this.organizationId,
this.includeArchived = false,
/// Charger la liste des conversations
class LoadMesConversations extends MessagingEvent {
const LoadMesConversations();
}
/// Ouvrir une conversation (charge le détail)
class OpenConversation extends MessagingEvent {
final String conversationId;
const OpenConversation(this.conversationId);
@override
List<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
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 {
final String conversationId;
final int? limit;
final String? beforeMessageId;
final int page;
const LoadMessages({
required this.conversationId,
this.limit,
this.beforeMessageId,
});
const LoadMessages({required this.conversationId, this.page = 0});
@override
List<Object?> get props => [conversationId, limit, beforeMessageId];
List<Object?> get props => [conversationId, page];
}
/// Envoyer un message
class SendMessageEvent extends MessagingEvent {
/// Marquer une conversation comme lue
class MarquerLu extends MessagingEvent {
final String conversationId;
final String content;
final List<String>? attachments;
final MessagePriority priority;
const SendMessageEvent({
required this.conversationId,
required this.content,
this.attachments,
this.priority = MessagePriority.normal,
});
const MarquerLu(this.conversationId);
@override
List<Object?> get props => [conversationId, content, attachments, priority];
List<Object?> get props => [conversationId];
}
/// Envoyer un broadcast
class SendBroadcastEvent extends MessagingEvent {
final String organizationId;
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 {
/// Supprimer un message
class SupprimerMessage extends MessagingEvent {
final String conversationId;
final String messageId;
const MarkMessageAsReadEvent(this.messageId);
@override
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,
const SupprimerMessage({
required this.conversationId,
required this.messageId,
});
@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;
import 'package:equatable/equatable.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
@@ -18,18 +19,24 @@ class MessagingInitial extends MessagingState {}
/// Chargement en cours
class MessagingLoading extends MessagingState {}
/// Conversations chargées
class ConversationsLoaded extends MessagingState {
final List<Conversation> conversations;
final int unreadCount;
/// Liste des conversations chargée
class MesConversationsLoaded extends MessagingState {
final List<ConversationSummary> conversations;
const ConversationsLoaded({
required this.conversations,
this.unreadCount = 0,
});
const MesConversationsLoaded(this.conversations);
@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
@@ -48,44 +55,35 @@ class MessagesLoaded extends MessagingState {
List<Object?> get props => [conversationId, messages, hasMore];
}
/// Message envoyé avec succès
class MessageSent extends MessagingState {
/// Message envoyé avec succès — la conversation s'actualise
class MessageEnvoye extends MessagingState {
final Message message;
final String conversationId;
const MessageSent(this.message);
const MessageEnvoye({required this.message, required this.conversationId});
@override
List<Object?> get props => [message];
List<Object?> get props => [message, conversationId];
}
/// Broadcast envoyé avec succès
class BroadcastSent extends MessagingState {
final Message message;
const BroadcastSent(this.message);
@override
List<Object?> get props => [message];
}
/// Conversation créée
class ConversationCreated extends MessagingState {
/// Conversation créée (après démarrage direct/rôle)
class ConversationCreee extends MessagingState {
final Conversation conversation;
const ConversationCreated(this.conversation);
const ConversationCreee(this.conversation);
@override
List<Object?> get props => [conversation];
}
/// Compteur de non lus chargé
class UnreadCountLoaded extends MessagingState {
final int count;
/// Action silencieuse réussie (marquer lu, supprimer message, etc.)
class MessagingActionOk extends MessagingState {
final String action;
const UnreadCountLoaded(this.count);
const MessagingActionOk(this.action);
@override
List<Object?> get props => [count];
List<Object?> get props => [action];
}
/// 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;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/utils/snackbar_helper.dart';
import '../../domain/entities/conversation.dart';
import '../bloc/messaging_bloc.dart';
import '../bloc/messaging_event.dart';
import '../bloc/messaging_state.dart';
import '../widgets/conversation_tile.dart';
import 'conversation_detail_page.dart';
class ConversationsPage extends StatelessWidget {
final String? organizationId;
const ConversationsPage({
super.key,
this.organizationId,
});
const ConversationsPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<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());
}
final scheme = Theme.of(context).colorScheme;
if (state is MessagingError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Erreur',
style: AppTypography.headerSmall,
),
const SizedBox(height: SpacingTokens.sm),
Text(
state.message,
style: AppTypography.bodyTextSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId),
);
},
),
],
return Scaffold(
backgroundColor: scheme.surface,
appBar: UFAppBar(
title: 'Messages',
moduleGradient: ModuleColors.communicationGradient,
automaticallyImplyLeading: true,
),
body: BlocConsumer<MessagingBloc, MessagingState>(
listener: (context, state) {
if (state is MessagingError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: ColorTokens.error,
),
);
}
if (state is ConversationCreee) {
_openDetail(context, state.conversation.id);
}
},
builder: (context, state) {
if (state is MessagingLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is MesConversationsLoaded) {
return _buildList(context, state.conversations);
}
// État initial ou erreur non fatale — afficher liste vide
return _buildEmpty(context);
},
),
);
}
Widget _buildList(BuildContext context, List<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),
),
);
}
if (state is ConversationsLoaded) {
final conversations = state.conversations;
if (conversations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 64,
color: AppColors.textSecondaryLight,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Aucune conversation',
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Commencez une nouvelle conversation',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
context.read<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',
);
},
);
},
const SizedBox(height: SpacingTokens.md),
Text('Aucune conversation', style: AppTypography.headerSmall),
const SizedBox(height: SpacingTokens.sm),
Text(
'Contactez un membre ou le bureau\nde votre organisation',
style: AppTypography.bodyTextSmall,
textAlign: TextAlign.center,
),
);
}
return const SizedBox.shrink();
},
],
),
),
),
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.primaryGreen,
onPressed: () {
SnackbarHelper.showNotImplemented(context, 'Nouvelle conversation');
},
child: const Icon(Icons.add, color: Colors.white),
),
);
}
void _openDetail(BuildContext context, String conversationId) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: context.read<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,
),
),
),
);
}
}