Cahier des charges du projet
Contexte
- Une startup souhaite développer une application mobile de messagerie instantanée destinée aux apprenants et formateurs afin de faciliter la communication en temps réel.
- L’application doit être réalisée avec Flutter pour assurer une compatibilité Android et iOS, et doit reposer sur une architecture client–serveur avec WebSockets.
- Le projet doit permettre aux utilisateurs d’échanger des messages texte en temps réel, de consulter l’historique des conversations et de gérer leur statut de connexion.
Principaux objectifs
- Messagerie instantanée bidirectionnelle (faible latence).
- Persistance de l’historique des messages.
- Connexions sécurisées et authentifiées.
- Bon comportement en cas de perte de connexion (reconnexion automatique, file d’envoi locale).
- Extensibilité (conversations de groupe, pièces jointes, emojis, etc.).
Fonctionnalités attendues
- Authentification des utilisateurs
- Inscription (username, email, mot de passe).
- Connexion sécurisée avec
JWT.
- Messagerie
- Envoi et réception de messages en temps réel.
- Affichage de l’historique des messages (stockage en base + cache local avec
sqflite). - Statut des messages : envoyé, délivré, lu.
- Indicateurs
- Présence en ligne / hors ligne.
- Indicateur « en train d’écrire ».
- Interface utilisateur (Flutter)
- Page de connexion / inscription.
- Page de liste de conversations.
- Écran de discussion (ChatScreen avec
ListView.builder).
- Gestion des erreurs
- Déconnexion automatique en cas d’inactivité ou d’expiration du token.
- Tentative de reconnexion automatique en cas de perte de réseau.
- Authentification des utilisateurs
Exigences techniques
- Frontend (client Flutter)
- Framework : Flutter (Android & iOS).
- Packages recommandés :
web_socket_channel(connexion WebSocket).provider/riverpod/bloc(gestion d’état).shared_preferences(stockage léger des préférences utilisateur).sqflite(base de données locale pour messages offline).
- Composants :
- ChatScreen (interface principale de chat).
- Service WebSocket réutilisable (classe
ChatWebSocketService). - Stockage local pour cache + file d’envoi offline.
- Backend (serveur)
- WebSocket endpoint (exemple :
wss://api.messagerie.com/ws/chat). - Authentification via JWT.
- Base de données SQL (PostgreSQL) ou NoSQL (MongoDB).
- API REST complémentaire (authentification, récupération historique).
- WebSocket endpoint (exemple :
- Frontend (client Flutter)
Modèle de données
- Table users
- id (uuid)
- username
- password_hash
- avatar_url
- last_seen
- Table messages
- id
- sender_id
- recipient_id
- text
- status (sent/delivered/read)
- timestamp
- Table users
Protocole WebSocket (Flutter)
- Format général JSON :
{ "type": "message.send", "payload": { "id": "12345", "sender": "alice", "recipient": "bob", "text": "Salut", "timestamp": "2025-09-16T08:00:00Z" } }- Types d’événements :
- message.send — client → serveur.
- message.receive — serveur → client.
- message.ack — accusé de réception.
- typing.start / typing.stop — indicateur de saisie.
- presence.update — état de connexion.
Comportements côté client (Flutter)
- Connexion via WebSocket + JWT.
- Optimistic UI (message affiché avant ACK serveur).
- Reconnexion automatique (exponential backoff).
- Stockage offline avec
sqflite.
User stories
- US-01 : Connexion utilisateur (login/logout).
- US-02 : Envoyer un message en temps réel.
- US-03 : Lire l’historique d’une conversation.
- US-04 : Voir l’indicateur de saisie.
Tests & qualité
- Tests unitaires (modèle
ChatMessage, service WebSocket). - Tests d’intégration (flux complet login → envoi → réception message).
- Tests de charge (simuler plusieurs connexions).
- Tests de sécurité (XSS, injection, validation token).
- Tests unitaires (modèle
Livrables
- Code source Flutter (frontend).
- Code backend (WebSocket + API REST).
- Scripts de déploiement (Docker, CI/CD).
- Plan de tests et documentation technique.
Recommandations finales
- Utiliser
wss://en production (TLS obligatoire). - Ne pas exposer les tokens en clair.
- Utiliser
Redis pub/subsi plusieurs serveurs backend. - Garder un protocole JSON simple et extensible.
- Utiliser
Réalisation du projet
-
Structure du projet
lib/ ├── main.dart ├── core/ │ ├── constants.dart │ ├── services/ │ │ ├── auth_service.dart │ │ ├── socket_service.dart │ │ ├── message_service.dart │ │ └── database_service.dart │ ├── models/ │ │ ├── user_model.dart │ │ ├── message_model.dart │ │ └── conversation_model.dart │ └── utils/ │ ├── validators.dart │ └── helpers.dart ├── providers/ │ ├── auth_provider.dart │ ├── chat_provider.dart │ └── connection_provider.dart ├── ui/ │ ├── widgets/ │ │ ├── message_bubble.dart │ │ ├── typing_indicator.dart │ │ ├── online_indicator.dart │ │ └── custom_textfield.dart │ ├── screens/ │ │ ├── auth/ │ │ │ ├── login_screen.dart │ │ │ └── register_screen.dart │ │ ├── conversations_screen.dart │ │ ├── chat_screen.dart │ │ └── splash_screen.dart │ └── theme.dart └── data/ ├── repositories/ │ ├── auth_repository.dart │ └── message_repository.dart └── local/ └── local_database.dart -
Configuration initiale
-
pubspec.yaml
name: realtime_chat description: Application de messagerie instantanée en temps réel version: 1.0.0+1 environment: sdk: ">=3.0.0 <4.0.0" dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 web_socket_channel: ^2.4.0 provider: ^6.0.5 shared_preferences: ^2.2.2 sqflite: ^2.3.0 path: ^1.8.3 intl: ^0.18.1 crypto: ^3.0.3 jwt_decoder: ^2.0.1 connectivity_plus: ^5.0.1 flutter_secure_storage: ^8.0.0 cached_network_image: ^3.3.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 flutter: uses-material-design: true assets: - assets/images/
-
-
Modèles de données
-
user_model.dart
class User { final String id; final String username; final String email; final String? avatarUrl; final DateTime lastSeen; final bool isOnline; User({ required this.id, required this.username, required this.email, this.avatarUrl, required this.lastSeen, required this.isOnline, }); factory User.fromJson(Mapjson) { return User( id: json['id'], username: json['username'], email: json['email'], avatarUrl: json['avatarUrl'], lastSeen: DateTime.parse(json['lastSeen']), isOnline: json['isOnline'], ); } Map toJson() { return { 'id': id, 'username': username, 'email': email, 'avatarUrl': avatarUrl, 'lastSeen': lastSeen.toIso8601String(), 'isOnline': isOnline, }; } } -
message_model.dart
enum MessageStatus { sent, delivered, read } enum MessageType { text, image, file } class Message { final String id; final String conversationId; final String senderId; final String text; final MessageType type; final MessageStatus status; final DateTime timestamp; final Map? metadata; Message({ required this.id, required this.conversationId, required this.senderId, required this.text, this.type = MessageType.text, this.status = MessageStatus.sent, required this.timestamp, this.metadata, }); factory Message.fromJson(Map json) { return Message( id: json['id'], conversationId: json['conversationId'], senderId: json['senderId'], text: json['text'], type: MessageType.values.firstWhere( (e) => e.toString() == 'MessageType.${json['type']}', orElse: () => MessageType.text, ), status: MessageStatus.values.firstWhere( (e) => e.toString() == 'MessageStatus.${json['status']}', orElse: () => MessageStatus.sent, ), timestamp: DateTime.parse(json['timestamp']), metadata: json['metadata'], ); } Map toJson() { return { 'id': id, 'conversationId': conversationId, 'senderId': senderId, 'text': text, 'type': type.toString().split('.').last, 'status': status.toString().split('.').last, 'timestamp': timestamp.toIso8601String(), 'metadata': metadata, }; } Message copyWith({ MessageStatus? status, }) { return Message( id: id, conversationId: conversationId, senderId: senderId, text: text, type: type, status: status ?? this.status, timestamp: timestamp, metadata: metadata, ); } } -
conversation_model.dart
enum ConversationType { private, group } class Conversation { final String id; final ConversationType type; final String name; final String? avatarUrl; final Listparticipants; final Message? lastMessage; final int unreadCount; final DateTime createdAt; Conversation({ required this.id, required this.type, required this.name, this.avatarUrl, required this.participants, this.lastMessage, this.unreadCount = 0, required this.createdAt, }); factory Conversation.fromJson(Map json) { return Conversation( id: json['id'], type: ConversationType.values.firstWhere( (e) => e.toString() == 'ConversationType.${json['type']}', orElse: () => ConversationType.private, ), name: json['name'], avatarUrl: json['avatarUrl'], participants: List .from(json['participants']), lastMessage: json['lastMessage'] != null ? Message.fromJson(json['lastMessage']) : null, unreadCount: json['unreadCount'] ?? 0, createdAt: DateTime.parse(json['createdAt']), ); } Map toJson() { return { 'id': id, 'type': type.toString().split('.').last, 'name': name, 'avatarUrl': avatarUrl, 'participants': participants, 'lastMessage': lastMessage?.toJson(), 'unreadCount': unreadCount, 'createdAt': createdAt.toIso8601String(), }; } Conversation copyWith({ Message? lastMessage, int? unreadCount, }) { return Conversation( id: id, type: type, name: name, avatarUrl: avatarUrl, participants: participants, lastMessage: lastMessage ?? this.lastMessage, unreadCount: unreadCount ?? this.unreadCount, createdAt: createdAt, ); } }
-
-
Services et gestion d'état
socket_service.dart
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/io.dart'; import 'dart:convert'; class SocketService { WebSocketChannel? _channel; String? _token; bool _isConnected = false; Function(Map)? onMessageReceived; Function()? onConnected; Function()? onDisconnected; static final SocketService _instance = SocketService._internal(); factory SocketService() => _instance; SocketService._internal(); void connect(String url, String token) { _token = token; try { _channel = IOWebSocketChannel.connect( '$url?token=$token', headers: {'Authorization': 'Bearer $token'}, ); _channel!.stream.listen( (data) { final message = json.decode(data); if (onMessageReceived != null) { onMessageReceived!(message); } }, onDone: () { _isConnected = false; if (onDisconnected != null) onDisconnected!(); }, onError: (error) { _isConnected = false; if (onDisconnected != null) onDisconnected!(); }, ); _isConnected = true; if (onConnected != null) onConnected!(); } catch (e) { print('WebSocket connection error: $e'); } } void disconnect() { _channel?.sink.close(); _isConnected = false; } void sendMessage(Map message) { if (_isConnected && _channel != null) { _channel!.sink.add(json.encode(message)); } } bool get isConnected => _isConnected; void setMessageHandler(Function(Map ) handler) { onMessageReceived = handler; } } auth_service.dart
import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class AuthService { static const String baseUrl = 'https://api.example.com'; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); Future
-
Interface utilisateur
login_screen.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/auth_provider.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @override StatecreateState() => _LoginScreenState(); } class _LoginScreenState extends State { final _formKey = GlobalKey (); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _isLoading = false; void _login() async { if (!_formKey.currentState!.validate()) return; setState(() => _isLoading = true); final authProvider = Provider.of (context, listen: false); final result = await authProvider.login( _emailController.text, _passwordController.text, ); setState(() => _isLoading = false); if (result['success'] == true) { Navigator.pushReplacementNamed(context, '/conversations'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result['error'])), ); } } @override Widget build(BuildContext context) { return Scaffold( body: Padding( padding: const EdgeInsets.all(20.0), child: Form( key: _formKey, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'Connexion', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 30), TextFormField( controller: _emailController, decoration: const InputDecoration( labelText: 'Email', border: OutlineInputBorder(), ), validator: (value) { if (value == null || value.isEmpty) { return 'Veuillez entrer votre email'; } if (!value.contains('@')) { return 'Email invalide'; } return null; }, ), const SizedBox(height: 15), TextFormField( controller: _passwordController, decoration: const InputDecoration( labelText: 'Mot de passe', border: OutlineInputBorder(), ), obscureText: true, validator: (value) { if (value == null || value.isEmpty) { return 'Veuillez entrer votre mot de passe'; } if (value.length < 6) { return 'Le mot de passe doit contenir au moins 6 caractères'; } return null; }, ), const SizedBox(height: 20), _isLoading ? const CircularProgressIndicator() : ElevatedButton( onPressed: _login, child: const Text('Se connecter'), ), const SizedBox(height: 15), TextButton( onPressed: () { Navigator.pushNamed(context, '/register'); }, child: const Text('Créer un compte'), ), ], ), ), ), ); } } conversations_screen.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/chat_provider.dart'; import '../core/models/conversation_model.dart'; class ConversationsScreen extends StatefulWidget { const ConversationsScreen({super.key}); @override StatecreateState() => _ConversationsScreenState(); } class _ConversationsScreenState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { Provider.of (context, listen: false).loadConversations(); }); } @override Widget build(BuildContext context) { final chatProvider = Provider.of (context); return Scaffold( appBar: AppBar( title: const Text('Conversations'), actions: [ IconButton( icon: const Icon(Icons.search), onPressed: () {}, ), IconButton( icon: const Icon(Icons.logout), onPressed: () { // Implement logout }, ), ], ), body: chatProvider.conversations.isEmpty ? const Center(child: Text('Aucune conversation')) : ListView.builder( itemCount: chatProvider.conversations.length, itemBuilder: (context, index) { final conversation = chatProvider.conversations[index]; return ConversationListItem( conversation: conversation, onTap: () { chatProvider.setCurrentConversation(conversation); Navigator.pushNamed(context, '/chat'); }, ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { // Navigate to new conversation screen }, child: const Icon(Icons.message), ), ); } } class ConversationListItem extends StatelessWidget { final Conversation conversation; final VoidCallback onTap; const ConversationListItem({ super.key, required this.conversation, required this.onTap, }); @override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( backgroundImage: conversation.avatarUrl != null ? NetworkImage(conversation.avatarUrl!) : null, child: conversation.avatarUrl == null ? Text(conversation.name[0]) : null, ), title: Text(conversation.name), subtitle: conversation.lastMessage != null ? Text( conversation.lastMessage!.text, overflow: TextOverflow.ellipsis, ) : null, trailing: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (conversation.lastMessage != null) Text( _formatTime(conversation.lastMessage!.timestamp), style: const TextStyle(fontSize: 12), ), if (conversation.unreadCount > 0) Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), child: Text( conversation.unreadCount.toString(), style: const TextStyle( color: Colors.white, fontSize: 12, ), ), ), ], ), onTap: onTap, ); } String _formatTime(DateTime timestamp) { final now = DateTime.now(); final difference = now.difference(timestamp); if (difference.inDays > 0) { return '${difference.inDays}j'; } else if (difference.inHours > 0) { return '${difference.inHours}h'; } else if (difference.inMinutes > 0) { return '${difference.inMinutes}min'; } else { return 'Maintenant'; } } } chat_screen.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/chat_provider.dart'; import '../widgets/message_bubble.dart'; import '../widgets/typing_indicator.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({super.key}); @override StatecreateState() => _ChatScreenState(); } class _ChatScreenState extends State { final _messageController = TextEditingController(); final _scrollController = ScrollController(); bool _isTyping = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); } void _scrollToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; final chatProvider = Provider.of (context, listen: false); chatProvider.sendMessage(text); _messageController.clear(); _scrollToBottom(); } void _onTypingChanged(bool isTyping) { setState(() { _isTyping = isTyping; }); } @override Widget build(BuildContext context) { final chatProvider = Provider.of (context); final currentConversation = chatProvider.currentConversation; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(currentConversation?.name ?? 'Chat'), if (chatProvider.isTyping) const Text( 'en train d\'écrire...', style: TextStyle(fontSize: 12), ) else const Text( 'En ligne', style: TextStyle(fontSize: 12), ), ], ), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { Navigator.pop(context); }, ), ), body: Column( children: [ Expanded( child: ListView.builder( controller: _scrollController, itemCount: chatProvider.messages.length + (chatProvider.isTyping ? 1 : 0), itemBuilder: (context, index) { if (index < chatProvider.messages.length) { final message = chatProvider.messages[index]; return MessageBubble(message: message); } else { return const TypingIndicator(); } }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: InputDecoration( hintText: 'Tapez un message...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16), ), onChanged: (value) { if (value.isNotEmpty && !_isTyping) { chatProvider.startTyping(); _onTypingChanged(true); } else if (value.isEmpty && _isTyping) { chatProvider.stopTyping(); _onTypingChanged(false); } }, onSubmitted: (_) => _sendMessage(), ), ), IconButton( icon: const Icon(Icons.send), onPressed: _sendMessage, ), ], ), ), ], ), ); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); super.dispose(); } } message_bubble.dart
import 'package:flutter/material.dart'; import '../../core/models/message_model.dart'; class MessageBubble extends StatelessWidget { final Message message; final bool isMe; const MessageBubble({ super.key, required this.message, this.isMe = false, // This should be determined based on current user }); @override Widget build(BuildContext context) { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: isMe ? Colors.blue : Colors.grey[300], borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( message.text, style: TextStyle( color: isMe ? Colors.white : Colors.black, ), ), const SizedBox(height: 4), Text( _formatTime(message.timestamp), style: TextStyle( color: isMe ? Colors.white70 : Colors.grey[600], fontSize: 10, ), ), ], ), ), ); } String _formatTime(DateTime timestamp) { return '${timestamp.hour}:${timestamp.minute.toString().padLeft(2, '0')}'; } }
-
Point d'entrée principal
main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'ui/screens/splash_screen.dart'; import 'ui/screens/auth/login_screen.dart'; import 'ui/screens/auth/register_screen.dart'; import 'ui/screens/conversations_screen.dart'; import 'ui/screens/chat_screen.dart'; import 'providers/auth_provider.dart'; import 'providers/chat_provider.dart'; import 'core/services/auth_service.dart'; import 'core/services/socket_service.dart'; import 'core/services/message_service.dart'; import 'ui/theme.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider( create: (context) => AuthProvider(AuthService()), ), ChangeNotifierProxyProvider( create: (context) => ChatProvider( SocketService(), MessageService(), ), update: (context, authProvider, chatProvider) { if (authProvider.isAuthenticated) { // Reconnect socket with new token final token = authProvider.token; if (token != null) { chatProvider?.connectSocket(token); } } else { chatProvider?.disconnectSocket(); } return chatProvider!; }, ), ], child: MaterialApp( title: 'Realtime Chat', theme: appTheme, initialRoute: '/splash', routes: { '/splash': (context) => const SplashScreen(), '/login': (context) => const LoginScreen(), '/register': (context) => const RegisterScreen(), '/conversations': (context) => const ConversationsScreen(), '/chat': (context) => const ChatScreen(), }, ), ); } } splash_screen.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override StatecreateState() => _SplashScreenState(); } class _SplashScreenState extends State { @override void initState() { super.initState(); _checkAuthStatus(); } void _checkAuthStatus() async { final authProvider = Provider.of (context, listen: false); final isLoggedIn = await authProvider.checkAuthStatus(); Future.delayed(const Duration(seconds: 2), () { if (isLoggedIn) { Navigator.pushReplacementNamed(context, '/conversations'); } else { Navigator.pushReplacementNamed(context, '/login'); } }); } @override Widget build(BuildContext context) { return const Scaffold( body: Center( child: FlutterLogo(size: 100), ), ); } }
-
-
Configuration du thème
theme.dart
import 'package:flutter/material.dart'; final ThemeData appTheme = ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, appBarTheme: const AppBarTheme( elevation: 0, backgroundColor: Colors.white, iconTheme: IconThemeData(color: Colors.black), titleTextStyle: TextStyle( color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold, ), ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), );-
Gestion de la connexion
connection_provider.dart
import 'package:flutter/foundation.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; class ConnectionProvider with ChangeNotifier { final Connectivity _connectivity = Connectivity(); bool _isConnected = true; bool get isConnected => _isConnected; ConnectionProvider() { _initConnectivity(); _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); } void _initConnectivity() async { final result = await _connectivity.checkConnectivity(); _updateConnectionStatus(result); } void _updateConnectionStatus(ConnectivityResult result) { final newStatus = result != ConnectivityResult.none; if (_isConnected != newStatus) { _isConnected = newStatus; notifyListeners(); } } }
-
Conclusion
- Cette implémentation fournit une base solide pour une application de chat en temps réel avec Flutter. L'architecture suit les meilleures pratiques avec une séparation claire des responsabilités, l'utilisation de providers pour la gestion d'état, et des services pour la logique métier.
- Pour compléter l'application, vous devrez :
- Implémenter le backend selon les spécifications fournies
- Configurer les variables d'environnement pour les URLs d'API
- Ajouter la gestion des pièces jointes et des médias
- Implémenter les notifications push
- Ajouter des tests unitaires et d'intégration
- Configurer le déploiement avec Docker et CI/CD
- Cette architecture est extensible et peut être adaptée pour supporter des fonctionnalités supplémentaires comme les conversations de groupe, les appels vocaux/vidéo, et les réactions aux messages.
-