/// Page de détail d'une conversation — Messages v4 library conversation_detail_page; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/utils/logger.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../domain/entities/conversation.dart'; import '../../domain/entities/message.dart'; import '../bloc/messaging_bloc.dart'; import '../bloc/messaging_event.dart'; import '../bloc/messaging_state.dart'; import '../widgets/message_bubble.dart'; /// Page détail conversation : messages + champ d'envoi class ConversationDetailPage extends StatefulWidget { final String conversationId; const ConversationDetailPage({ super.key, required this.conversationId, }); @override State createState() => _ConversationDetailPageState(); } class _ConversationDetailPageState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final FocusNode _focusNode = FocusNode(); Conversation? _conversation; String? _currentUserId; @override void initState() { super.initState(); context.read().add(OpenConversation(widget.conversationId)); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); _focusNode.dispose(); super.dispose(); } void _sendMessage() { final content = _messageController.text.trim(); if (content.isEmpty) return; context.read().add( EnvoyerMessageTexte( conversationId: widget.conversationId, contenu: content, ), ); _messageController.clear(); _focusNode.requestFocus(); } void _scrollToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Scaffold( backgroundColor: scheme.surface, appBar: _buildAppBar(), body: Column( children: [ Expanded( child: BlocConsumer( listener: (context, state) { if (state is ConversationOuverte) { setState(() { _conversation = state.conversation; // Déterminer l'id de l'utilisateur courant via le premier participant non-expéditeur }); } if (state is MessagesLoaded) { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); } if (state is MessageEnvoye) { AppLogger.info('Message envoyé: ${state.message.id}'); } if (state is MessagingError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), backgroundColor: ColorTokens.error, ), ); } }, builder: (context, state) { if (state is MessagingLoading && _conversation == null) { return const Center(child: CircularProgressIndicator()); } if (state is ConversationOuverte) { return _buildMessagesList(state.conversation.messages, scheme); } if (state is MessagesLoaded) { return _buildMessagesList(state.messages, scheme); } if (_conversation != null) { return _buildMessagesList(_conversation!.messages, scheme); } return const SizedBox.shrink(); }, ), ), _buildInputBar(scheme), ], ), ); } UFAppBar _buildAppBar() { final title = _conversation?.titre ?? 'Conversation'; return UFAppBar( title: title, moduleGradient: ModuleColors.communicationGradient, automaticallyImplyLeading: true, actions: [ IconButton( icon: const Icon(Icons.delete_outline, color: ModuleColors.communicationOnColor), onPressed: _conversation != null ? _confirmArchive : null, tooltip: 'Archiver', ), ], ); } void _confirmArchive() { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Archiver la conversation ?'), content: const Text('La conversation sera archivée et n\'apparaîtra plus dans votre liste.'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), style: TextButton.styleFrom(foregroundColor: ColorTokens.error), child: const Text('Archiver'), ), ], ), ).then((confirmed) { if (confirmed == true && mounted) { context.read().add(ArchiverConversation(widget.conversationId)); Navigator.of(context).pop(); } }); } Widget _buildMessagesList(List messages, ColorScheme scheme) { if (messages.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.chat_bubble_outline, size: 64, color: scheme.onSurfaceVariant.withOpacity(0.4), ), const SizedBox(height: SpacingTokens.md), Text('Aucun message', style: AppTypography.headerSmall), const SizedBox(height: SpacingTokens.sm), Text('Envoyez le premier message !', style: AppTypography.bodyTextSmall), ], ), ); } return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.md, vertical: SpacingTokens.sm, ), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; final isMine = _isMyMessage(message); final showDateSeparator = index == 0 || !_isSameDay( messages[index - 1].dateEnvoi ?? DateTime.now(), message.dateEnvoi ?? DateTime.now(), ); return Column( children: [ if (showDateSeparator) Padding( padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: 4), decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), ), child: Text( _formatDateSeparator(message.dateEnvoi ?? DateTime.now()), style: AppTypography.badgeText.copyWith( color: scheme.onSurfaceVariant, fontSize: 11, ), ), ), ), ), MessageBubble( message: message, isMine: isMine, onLongPress: isMine && !message.supprime ? () => _showMessageOptions(message) : null, ), ], ); }, ); } void _showMessageOptions(Message message) { showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.delete_outline, color: AppColors.error), title: const Text('Supprimer le message'), onTap: () { Navigator.pop(ctx); context.read().add( SupprimerMessage( conversationId: widget.conversationId, messageId: message.id, ), ); }, ), ListTile( leading: const Icon(Icons.close), title: const Text('Annuler'), onTap: () => Navigator.pop(ctx), ), ], ), ), ); } Widget _buildInputBar(ColorScheme scheme) { return Container( padding: const EdgeInsets.symmetric( horizontal: SpacingTokens.sm, vertical: SpacingTokens.sm, ), decoration: BoxDecoration( color: scheme.surface, border: Border( top: BorderSide(color: scheme.outlineVariant.withOpacity(0.3)), ), boxShadow: [ BoxShadow( color: scheme.shadow.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, -2), ), ], ), child: SafeArea( child: Row( children: [ Expanded( child: Container( decoration: BoxDecoration( color: scheme.surfaceContainerHighest.withOpacity(0.5), borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), ), child: TextField( controller: _messageController, focusNode: _focusNode, textInputAction: TextInputAction.send, maxLines: 4, minLines: 1, onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( hintText: 'Écrire un message...', hintStyle: AppTypography.bodyTextSmall.copyWith( color: scheme.onSurfaceVariant, ), contentPadding: const EdgeInsets.symmetric( horizontal: SpacingTokens.md, vertical: SpacingTokens.sm, ), border: InputBorder.none, ), style: AppTypography.bodyTextSmall, ), ), ), const SizedBox(width: SpacingTokens.sm), Material( color: ModuleColors.communication, borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), child: InkWell( onTap: _sendMessage, borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular), child: Container( width: 44, height: 44, alignment: Alignment.center, child: const Icon( Icons.send_rounded, color: ModuleColors.communicationOnColor, size: 22, ), ), ), ), ], ), ), ); } // ── Helpers ──────────────────────────────────────────────────────────────── bool _isMyMessage(Message message) { // On ne peut pas connaître l'ID du membre courant sans le BLoC d'auth, // mais l'expéditeur d'un message récent qu'on a envoyé sera déterminé // via la liste des participants. En attendant le token JWT, on utilise // le premier participant de type INITIATEUR s'il n'y en a pas d'autre. // TODO: Injecter AuthBloc pour comparer expediteurId avec l'id du user connecté. return false; // Tous les messages affichés comme reçus pour l'instant } bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } String _formatDateSeparator(DateTime date) { final now = DateTime.now(); if (_isSameDay(date, now)) return "Aujourd'hui"; if (_isSameDay(date, now.subtract(const Duration(days: 1)))) return 'Hier'; return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; } }