Application de chat en temps réel
Sommaire
- 1- Cahier des charges du projet
- 1.1- Contexte
- 1.2- Principaux objectifs
- 1.3- Fonctionnalités attendues
- 1.4- Exigences techniques
- 1.5- Modèle de données
- 1.6- Protocole WebSocket (Flutter)
- 1.7- Comportements côté client (Flutter)
- 1.8- User stories
- 1.9- Tests & qualité
- 1.10- Livrables
- 1.11- Recommandations finales
- 2- Réalisation du projet
- 2.1- Structure du projet
- 2.2- Configuration initiale
- 2.2.1- pubspec.yaml
- 2.3- Modèles de données
- 2.3.1- user_model.dart
- 2.3.2- message_model.dart
- 2.3.3- conversation_model.dart
- 2.3.4- Cours Flutter
Application de chat en temps réel
-
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.
-
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).
-
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
-
Protocole WebSocket (Flutter)
- Format général JSON :
- 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).
-
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/sub
si plusieurs serveurs backend. - Garder un protocole JSON simple et extensible.
-
Réalisation du projet
-
Structure du projet
-
Configuration initiale
-
pubspec.yaml
-
Modèles de données
-
user_model.dart
-
message_model.dart
-
conversation_model.dart
{ "type": "message.send", "payload": { "id": "12345", "sender": "alice", "recipient": "bob", "text": "Salut", "timestamp": "2025-09-16T08:00:00Z" } }
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
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/
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(Map json) {
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,
};
}
}
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,
);
}
}
enum ConversationType { private, group }
class Conversation {
final String id;
final ConversationType type;
final String name;
final String? avatarUrl;
final List participants;
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,
);
}
}
3. Services et gestion d'état
socket_service.dart
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
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
if (_isConnected && _channel != null) {
_channel!.sink.add(json.encode(message));
}
}
bool get isConnected => _isConnected;
void setMessageHandler(Function(Map
onMessageReceived = handler;
}
}
auth_service.dart
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
if (response.statusCode == 200) {
final data = json.decode(response.body);
await _secureStorage.write(key: 'access_token', value: data['accessToken']);
await _secureStorage.write(key: 'refresh_token', value: data['refreshToken']);
// Store user data in shared preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user', json.encode(data['user']));
return {'success': true, 'user': data['user']};
} else {
return {'success': false, 'error': 'Invalid credentials'};
}
}
Future
if (response.statusCode == 201) {
final data = json.decode(response.body);
await _secureStorage.write(key: 'access_token', value: data['accessToken']);
await _secureStorage.write(key: 'refresh_token', value: data['refreshToken']);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('user', json.encode(data['user']));
return {'success': true, 'user': data['user']};
} else {
return {'success': false, 'error': 'Registration failed'};
}
}
Future
final prefs = await SharedPreferences.getInstance();
await prefs.remove('user');
await _secureStorage.delete(key: 'access_token');
await _secureStorage.delete(key: 'refresh_token');
}
Future
return await _secureStorage.read(key: 'access_token');
}
Future
Future
final token = await getAccessToken();
return token != null;
}
}
chat_provider.dart
dart
import 'package:flutter/foundation.dart';
import '../core/models/message_model.dart';
import '../core/models/conversation_model.dart';
import '../core/services/socket_service.dart';
import '../core/services/message_service.dart';
class ChatProvider with ChangeNotifier {
final SocketService _socketService;
final MessageService _messageService;
List
List
Conversation? _currentConversation;
bool _isTyping = false;
String _typingUser = '';
List
List
Conversation? get currentConversation => _currentConversation;
bool get isTyping => _isTyping;
String get typingUser => _typingUser;
ChatProvider(this._socketService, this._messageService) {
_socketService.setMessageHandler(_handleSocketMessage);
}
void _handleSocketMessage(Map
final type = message['type'];
final payload = message['payload'];
switch (type) {
case 'message.receive':
final newMessage = Message.fromJson(payload);
_addMessage(newMessage);
break;
case 'message.ack':
_updateMessageStatus(payload['id'], MessageStatus.values.firstWhere(
(e) => e.toString() == 'MessageStatus.${payload['status']}',
orElse: () => MessageStatus.delivered,
));
break;
case 'typing.start':
_setTyping(true, payload['userId']);
break;
case 'typing.stop':
_setTyping(false, '');
break;
case 'presence.update':
_updateUserPresence(payload['userId'], payload['isOnline']);
break;
}
}
void _addMessage(Message message) {
_messages.add(message);
notifyListeners();
}
void _updateMessageStatus(String messageId, MessageStatus status) {
final index = _messages.indexWhere((msg) => msg.id == messageId);
if (index != -1) {
_messages[index] = _messages[index].copyWith(status: status);
notifyListeners();
}
}
void _setTyping(bool typing, String userId) {
_isTyping = typing;
_typingUser = userId;
notifyListeners();
}
void _updateUserPresence(String userId, bool isOnline) {
// Update presence in conversations and messages
notifyListeners();
}
Future
_conversations = await _messageService.getConversations();
notifyListeners();
}
Future
_messages = await _messageService.getMessages(conversationId);
notifyListeners();
}
Future
if (_currentConversation == null) return;
final message = Message(
id: DateTime.now().millisecondsSinceEpoch.toString(),
conversationId: _currentConversation!.id,
senderId: 'current_user_id', // Should be replaced with actual user ID
text: text,
timestamp: DateTime.now(),
);
// Optimistic UI update
_addMessage(message);
// Send via socket
_socketService.sendMessage({
'type': 'message.send',
'payload': message.toJson(),
});
// Save to local database
await _messageService.saveMessage(message);
}
void setCurrentConversation(Conversation conversation) {
_currentConversation = conversation;
loadMessages(conversation.id);
}
void startTyping() {
_socketService.sendMessage({
'type': 'typing.start',
'payload': {'conversationId': _currentConversation?.id},
});
}
void stopTyping() {
_socketService.sendMessage({
'type': 'typing.stop',
'payload': {'conversationId': _currentConversation?.id},
});
}
void markAsRead(String messageId) {
_updateMessageStatus(messageId, MessageStatus.read);
_socketService.sendMessage({
'type': 'message.ack',
'payload': {'messageId': messageId, 'status': 'read'},
});
}
}
4. Interface utilisateur
login_screen.dart
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
State
}
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
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
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
State
}
class _ConversationsScreenState extends State
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of
});
}
@override
Widget build(BuildContext context) {
final chatProvider = Provider.of
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
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
State
}
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
chatProvider.sendMessage(text);
_messageController.clear();
_scrollToBottom();
}
void _onTypingChanged(bool isTyping) {
setState(() {
_isTyping = isTyping;
});
}
@override
Widget build(BuildContext context) {
final chatProvider = Provider.of
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
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')}';
}
}
5. Point d'entrée principal
main.dart
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
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
State
}
class _SplashScreenState extends State
@override
void initState() {
super.initState();
_checkAuthStatus();
}
void _checkAuthStatus() async {
final authProvider = Provider.of
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),
),
);
}
}
6. Configuration du thème
theme.dart
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),
),
),
),
);
7. Gestion de la connexion
connection_provider.dart
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.