Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
105
lib/features/communication/presentation/bloc/messaging_bloc.dart
Normal file
105
lib/features/communication/presentation/bloc/messaging_bloc.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
/// BLoC de gestion de la messagerie
|
||||
library messaging_bloc;
|
||||
|
||||
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 '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;
|
||||
|
||||
MessagingBloc({
|
||||
required this.getConversations,
|
||||
required this.getMessages,
|
||||
required this.sendMessage,
|
||||
required this.sendBroadcast,
|
||||
}) : super(MessagingInitial()) {
|
||||
on<LoadConversations>(_onLoadConversations);
|
||||
on<LoadMessages>(_onLoadMessages);
|
||||
on<SendMessageEvent>(_onSendMessage);
|
||||
on<SendBroadcastEvent>(_onSendBroadcast);
|
||||
}
|
||||
|
||||
Future<void> _onLoadConversations(
|
||||
LoadConversations event,
|
||||
Emitter<MessagingState> emit,
|
||||
) async {
|
||||
emit(MessagingLoading());
|
||||
|
||||
final result = await getConversations(
|
||||
organizationId: event.organizationId,
|
||||
includeArchived: event.includeArchived,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(MessagingError(failure.message)),
|
||||
(conversations) => emit(ConversationsLoaded(conversations: conversations)),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
conversationId: event.conversationId,
|
||||
messages: messages,
|
||||
hasMore: messages.length == (event.limit ?? 50),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSendMessage(
|
||||
SendMessageEvent 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)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSendBroadcast(
|
||||
SendBroadcastEvent event,
|
||||
Emitter<MessagingState> emit,
|
||||
) async {
|
||||
final result = await sendBroadcast(
|
||||
organizationId: event.organizationId,
|
||||
subject: event.subject,
|
||||
content: event.content,
|
||||
priority: event.priority,
|
||||
attachments: event.attachments,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(MessagingError(failure.message)),
|
||||
(message) => emit(BroadcastSent(message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/// Événements du BLoC Messaging
|
||||
library messaging_event;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/message.dart';
|
||||
|
||||
abstract class MessagingEvent extends Equatable {
|
||||
const MessagingEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger les conversations
|
||||
class LoadConversations extends MessagingEvent {
|
||||
final String? organizationId;
|
||||
final bool includeArchived;
|
||||
|
||||
const LoadConversations({
|
||||
this.organizationId,
|
||||
this.includeArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [organizationId, includeArchived];
|
||||
}
|
||||
|
||||
/// Charger les messages d'une conversation
|
||||
class LoadMessages extends MessagingEvent {
|
||||
final String conversationId;
|
||||
final int? limit;
|
||||
final String? beforeMessageId;
|
||||
|
||||
const LoadMessages({
|
||||
required this.conversationId,
|
||||
this.limit,
|
||||
this.beforeMessageId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [conversationId, limit, beforeMessageId];
|
||||
}
|
||||
|
||||
/// Envoyer un message
|
||||
class SendMessageEvent 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,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [conversationId, content, attachments, priority];
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, participantIds, organizationId, description];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/// États du BLoC Messaging
|
||||
library messaging_state;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/conversation.dart';
|
||||
import '../../domain/entities/message.dart';
|
||||
|
||||
abstract class MessagingState extends Equatable {
|
||||
const MessagingState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
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;
|
||||
|
||||
const ConversationsLoaded({
|
||||
required this.conversations,
|
||||
this.unreadCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [conversations, unreadCount];
|
||||
}
|
||||
|
||||
/// Messages d'une conversation chargés
|
||||
class MessagesLoaded extends MessagingState {
|
||||
final String conversationId;
|
||||
final List<Message> messages;
|
||||
final bool hasMore;
|
||||
|
||||
const MessagesLoaded({
|
||||
required this.conversationId,
|
||||
required this.messages,
|
||||
this.hasMore = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [conversationId, messages, hasMore];
|
||||
}
|
||||
|
||||
/// Message envoyé avec succès
|
||||
class MessageSent extends MessagingState {
|
||||
final Message message;
|
||||
|
||||
const MessageSent(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final Conversation conversation;
|
||||
|
||||
const ConversationCreated(this.conversation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [conversation];
|
||||
}
|
||||
|
||||
/// Compteur de non lus chargé
|
||||
class UnreadCountLoaded extends MessagingState {
|
||||
final int count;
|
||||
|
||||
const UnreadCountLoaded(this.count);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [count];
|
||||
}
|
||||
|
||||
/// Erreur
|
||||
class MessagingError extends MessagingState {
|
||||
final String message;
|
||||
|
||||
const MessagingError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/// Page liste des conversations
|
||||
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 '../bloc/messaging_bloc.dart';
|
||||
import '../bloc/messaging_event.dart';
|
||||
import '../bloc/messaging_state.dart';
|
||||
import '../widgets/conversation_tile.dart';
|
||||
|
||||
class ConversationsPage extends StatelessWidget {
|
||||
final String? organizationId;
|
||||
|
||||
const ConversationsPage({
|
||||
super.key,
|
||||
this.organizationId,
|
||||
});
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: () {
|
||||
// Navigation vers la page de chat
|
||||
// TODO: Implémenter navigation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Ouvrir conversation: ${conversation.name}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
onPressed: () {
|
||||
// TODO: Ouvrir dialogue nouvelle conversation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nouvelle conversation (à implémenter)')),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/// Widget tuile de conversation
|
||||
library conversation_tile;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../domain/entities/conversation.dart';
|
||||
|
||||
class ConversationTile extends StatelessWidget {
|
||||
final Conversation conversation;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ConversationTile({
|
||||
super.key,
|
||||
required this.conversation,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return DateFormat('HH:mm').format(date);
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Hier';
|
||||
} else if (difference.inDays < 7) {
|
||||
return DateFormat('EEEE', 'fr_FR').format(date);
|
||||
} else {
|
||||
return DateFormat('dd/MM/yy').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
border: Border.all(
|
||||
color: conversation.hasUnread
|
||||
? AppColors.primaryGreen.withOpacity(0.3)
|
||||
: ColorTokens.outline,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
|
||||
backgroundImage: conversation.avatarUrl != null
|
||||
? NetworkImage(conversation.avatarUrl!)
|
||||
: null,
|
||||
child: conversation.avatarUrl == null
|
||||
? Text(
|
||||
conversation.name.isNotEmpty
|
||||
? conversation.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: AppTypography.actionText.copyWith(
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
|
||||
// Contenu
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
conversation.name,
|
||||
style: AppTypography.actionText.copyWith(
|
||||
fontWeight: conversation.hasUnread
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (conversation.lastMessage != null)
|
||||
Text(
|
||||
_formatDate(conversation.lastMessage!.createdAt),
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (conversation.lastMessage != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
conversation.lastMessage!.content,
|
||||
style: AppTypography.bodyTextSmall.copyWith(
|
||||
color: AppColors.textSecondaryLight,
|
||||
fontWeight: conversation.hasUnread
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Badge non lus
|
||||
if (conversation.hasUnread) ...[
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular),
|
||||
),
|
||||
child: Text(
|
||||
'${conversation.unreadCount}',
|
||||
style: AppTypography.badgeText.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Icônes statut
|
||||
if (conversation.isPinned || conversation.isMuted) ...[
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Column(
|
||||
children: [
|
||||
if (conversation.isPinned)
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
size: 16,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
if (conversation.isMuted)
|
||||
Icon(
|
||||
Icons.volume_off,
|
||||
size: 16,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user