Files
afterwork/lib/data/services/realtime_notification_service.dart
dahoud 92612abbd7 fix(chat): Correction race condition + Implémentation TODOs
## Corrections Critiques

### Race Condition - Statuts de Messages
- Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas
- Cause : WebSocket delivery confirmations arrivaient avant messages locaux
- Solution : Pattern Optimistic UI dans chat_bloc.dart
  - Création message temporaire immédiate
  - Ajout à la liste AVANT requête HTTP
  - Remplacement par message serveur à la réponse
- Fichier : lib/presentation/state_management/chat_bloc.dart

## Implémentation TODOs (13/21)

### Social (social_header_widget.dart)
-  Copier lien du post dans presse-papiers
-  Partage natif via Share.share()
-  Dialogue de signalement avec 5 raisons

### Partage (share_post_dialog.dart)
-  Interface sélection d'amis avec checkboxes
-  Partage externe via Share API

### Média (media_upload_service.dart)
-  Parsing JSON réponse backend
-  Méthode deleteMedia() pour suppression
-  Génération miniature vidéo

### Posts (create_post_dialog.dart, edit_post_dialog.dart)
-  Extraction URL depuis uploads
-  Documentation chargement médias

### Chat (conversations_screen.dart)
-  Navigation vers notifications
-  ConversationSearchDelegate pour recherche

## Nouveaux Fichiers

### Configuration
- build-prod.ps1 : Script build production avec dart-define
- lib/core/constants/env_config.dart : Gestion environnements

### Documentation
- TODOS_IMPLEMENTED.md : Documentation complète TODOs

## Améliorations

### Architecture
- Refactoring injection de dépendances
- Amélioration routing et navigation
- Optimisation providers (UserProvider, FriendsProvider)

### UI/UX
- Amélioration thème et couleurs
- Optimisation animations
- Meilleure gestion erreurs

### Services
- Configuration API avec env_config
- Amélioration datasources (events, users)
- Optimisation modèles de données
2026-01-10 10:43:17 +00:00

422 lines
14 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../core/constants/env_config.dart';
import '../../core/utils/app_logger.dart';
/// Service WebSocket pour les notifications en temps réel.
///
/// Ce service gère les connexions WebSocket pour recevoir :
/// - Demandes d'amitié (envoi, réception, acceptation, rejet)
/// - Notifications système (événements, rappels)
/// - Alertes de messages
///
/// **Architecture :**
/// - Connexion WebSocket persistante à `/notifications/ws/{userId}`
/// - Streams séparés par type de notification
/// - Reconnexion automatique en cas de déconnexion
/// - Support multi-sessions (l'utilisateur peut être connecté sur plusieurs appareils)
class RealtimeNotificationService extends ChangeNotifier {
RealtimeNotificationService(this.userId);
final String userId;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
bool _isConnected = false;
bool get isConnected => _isConnected;
bool _isDisposed = false;
Timer? _reconnectTimer;
// Streams pour différents types d'événements
final _friendRequestController = StreamController<FriendRequestNotification>.broadcast();
final _systemNotificationController = StreamController<SystemNotification>.broadcast();
final _messageAlertController = StreamController<MessageAlert>.broadcast();
final _presenceController = StreamController<PresenceUpdate>.broadcast();
Stream<FriendRequestNotification> get friendRequestStream => _friendRequestController.stream;
Stream<SystemNotification> get systemNotificationStream => _systemNotificationController.stream;
Stream<MessageAlert> get messageAlertStream => _messageAlertController.stream;
Stream<PresenceUpdate> get presenceStream => _presenceController.stream;
/// Récupère l'URL WebSocket à partir de l'URL HTTP de base.
String get _wsUrl {
final baseUrl = EnvConfig.apiBaseUrl;
// Remplacer http:// par ws:// ou https:// par wss://
final wsUrl = baseUrl.replaceFirst('http://', 'ws://').replaceFirst('https://', 'wss://');
return '$wsUrl/notifications/ws/$userId';
}
/// Se connecte au serveur WebSocket.
Future<void> connect() async {
if (_isDisposed) {
AppLogger.w('Tentative de connexion après dispose, ignorée', tag: 'RealtimeNotificationService');
return;
}
if (_isConnected) {
AppLogger.w('Déjà connecté', tag: 'RealtimeNotificationService');
return;
}
try {
AppLogger.i('Connexion à: $_wsUrl', tag: 'RealtimeNotificationService');
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
// Écouter les messages entrants
_subscription = _channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
cancelOnError: false,
);
_isConnected = true;
if (!_isDisposed) {
notifyListeners();
}
AppLogger.i('Connecté avec succès au service de notifications', tag: 'RealtimeNotificationService');
} catch (e, stackTrace) {
AppLogger.e('Erreur de connexion', error: e, stackTrace: stackTrace, tag: 'RealtimeNotificationService');
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
}
}
/// Déconnecte du serveur WebSocket.
Future<void> disconnect() async {
_reconnectTimer?.cancel();
_reconnectTimer = null;
if (!_isConnected) return;
AppLogger.i('Déconnexion...', tag: 'RealtimeNotificationService');
await _subscription?.cancel();
_subscription = null;
try {
await _channel?.sink.close();
} catch (e) {
AppLogger.w('Erreur lors de la fermeture du canal: $e', tag: 'RealtimeNotificationService');
}
_channel = null;
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
AppLogger.i('Déconnecté du service de notifications', tag: 'RealtimeNotificationService');
}
/// Envoie un ping pour maintenir la connexion active (keep-alive).
void sendPing() {
if (!_isConnected) {
AppLogger.w('Impossible d\'envoyer un ping: non connecté', tag: 'RealtimeNotificationService');
return;
}
final payload = {
'type': 'ping',
};
_channel?.sink.add(json.encode(payload));
AppLogger.d('Ping envoyé', tag: 'RealtimeNotificationService');
}
/// Envoie un heartbeat pour maintenir le statut online.
void sendHeartbeat() {
sendPing(); // Le ping est géré côté serveur pour mettre à jour la présence
}
/// Envoie un accusé de réception pour une notification.
void sendAcknowledgement(String notificationId) {
if (!_isConnected) return;
final payload = {
'type': 'ack',
'data': {
'notificationId': notificationId,
},
};
_channel?.sink.add(json.encode(payload));
AppLogger.d('ACK envoyé pour notification: $notificationId', tag: 'RealtimeNotificationService');
}
/// Gère les messages entrants du WebSocket.
void _handleMessage(dynamic data) {
try {
final jsonData = json.decode(data as String) as Map<String, dynamic>;
final type = jsonData['type'] as String?;
final payload = jsonData['data'] as Map<String, dynamic>?;
if (type == 'connected') {
AppLogger.i('Confirmation de connexion reçue', tag: 'RealtimeNotificationService');
return;
}
if (type == 'pong') {
AppLogger.d('Pong reçu', tag: 'RealtimeNotificationService');
return;
}
if (payload == null) {
AppLogger.w('Payload null pour le type: $type', tag: 'RealtimeNotificationService');
return;
}
switch (type) {
case 'friend_request_received':
final notification = FriendRequestNotification.fromJson(payload, type: 'received');
_friendRequestController.add(notification);
AppLogger.i('Demande d\'amitié reçue de: ${notification.senderName}', tag: 'RealtimeNotificationService');
break;
case 'friend_request_accepted':
final notification = FriendRequestNotification.fromJson(payload, type: 'accepted');
_friendRequestController.add(notification);
AppLogger.i('Demande d\'amitié acceptée par: ${notification.senderName}', tag: 'RealtimeNotificationService');
break;
case 'friend_request_rejected':
final notification = FriendRequestNotification.fromJson(payload, type: 'rejected');
_friendRequestController.add(notification);
AppLogger.i('Demande d\'amitié rejetée: ${notification.requestId}', tag: 'RealtimeNotificationService');
break;
case 'message_received':
final alert = MessageAlert.fromJson(payload);
_messageAlertController.add(alert);
AppLogger.i('Nouveau message de: ${alert.senderName}', tag: 'RealtimeNotificationService');
break;
case 'system_notification':
final notification = SystemNotification.fromJson(payload);
_systemNotificationController.add(notification);
AppLogger.i('Notification système: ${notification.title}', tag: 'RealtimeNotificationService');
break;
case 'presence':
final update = PresenceUpdate.fromJson(payload);
_presenceController.add(update);
AppLogger.i('Mise à jour présence: ${update.userId} -> ${update.isOnline}', tag: 'RealtimeNotificationService');
break;
default:
AppLogger.w('Type de notification inconnu: $type', tag: 'RealtimeNotificationService');
}
} catch (e, stackTrace) {
AppLogger.e('Erreur parsing notification', error: e, stackTrace: stackTrace, tag: 'RealtimeNotificationService');
}
}
/// Gère les erreurs WebSocket.
void _handleError(Object error) {
AppLogger.w('Erreur WebSocket: $error', tag: 'RealtimeNotificationService');
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
// Ne pas tenter de reconnexion si le backend ne supporte pas WebSocket
if (error.toString().contains('not upgraded to websocket')) {
AppLogger.w('WebSocket non supporté par le backend, reconnexion désactivée', tag: 'RealtimeNotificationService');
return;
}
// Tentative de reconnexion après 5 secondes seulement si pas disposé
if (!_isDisposed && _reconnectTimer == null) {
_reconnectTimer = Timer(const Duration(seconds: 5), () {
if (!_isDisposed && !_isConnected) {
AppLogger.i('Tentative de reconnexion...', tag: 'RealtimeNotificationService');
_reconnectTimer = null;
connect();
}
});
}
}
/// Gère la déconnexion WebSocket.
void _handleDisconnection() {
AppLogger.i('Déconnexion détectée', tag: 'RealtimeNotificationService');
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
// Tenter une reconnexion
if (!_isDisposed && _reconnectTimer == null) {
_reconnectTimer = Timer(const Duration(seconds: 5), () {
if (!_isDisposed && !_isConnected) {
AppLogger.i('Reconnexion après déconnexion...', tag: 'RealtimeNotificationService');
_reconnectTimer = null;
connect();
}
});
}
}
@override
void dispose() {
_isDisposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
disconnect();
_friendRequestController.close();
_systemNotificationController.close();
_messageAlertController.close();
_presenceController.close();
super.dispose();
}
}
/// Notification de demande d'amitié.
class FriendRequestNotification {
const FriendRequestNotification({
required this.type,
required this.requestId,
required this.senderId,
required this.senderName,
required this.senderProfileImage,
});
/// Type: 'received', 'accepted', 'rejected'
final String type;
/// ID de la demande d'amitié (friendshipId)
final String requestId;
/// ID de l'utilisateur qui a envoyé/accepté/rejeté
final String senderId;
/// Nom complet de l'utilisateur
final String senderName;
/// URL de l'image de profil
final String senderProfileImage;
factory FriendRequestNotification.fromJson(Map<String, dynamic> json, {required String type}) {
return FriendRequestNotification(
type: type,
requestId: json['requestId']?.toString() ?? json['friendshipId']?.toString() ?? '',
senderId: json['senderId']?.toString() ?? json['accepterId']?.toString() ?? '',
senderName: json['senderName']?.toString() ?? json['acceptedBy']?.toString() ?? 'Utilisateur',
senderProfileImage: json['senderProfileImage']?.toString() ?? json['accepterProfileImage']?.toString() ?? '',
);
}
String toDisplayMessage() {
switch (type) {
case 'received':
return 'Nouvelle demande d\'amitié de $senderName';
case 'accepted':
return '$senderName a accepté votre demande';
case 'rejected':
return 'Demande d\'amitié refusée';
default:
return 'Notification de demande d\'amitié';
}
}
}
/// Notification système.
class SystemNotification {
const SystemNotification({
required this.notificationId,
required this.title,
required this.message,
required this.type,
required this.timestamp,
});
final String notificationId;
final String title;
final String message;
final String type; // 'event', 'friend', 'reminder', 'other'
final DateTime timestamp;
factory SystemNotification.fromJson(Map<String, dynamic> json) {
return SystemNotification(
notificationId: json['notificationId']?.toString() ?? '',
title: json['title']?.toString() ?? 'Notification',
message: json['message']?.toString() ?? '',
type: json['type']?.toString() ?? 'other',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'].toString())
: DateTime.now(),
);
}
}
/// Alerte de message.
class MessageAlert {
const MessageAlert({
required this.messageId,
required this.conversationId,
required this.senderId,
required this.senderName,
required this.content,
required this.timestamp,
});
final String messageId;
final String conversationId;
final String senderId;
final String senderName;
final String content;
final DateTime timestamp;
factory MessageAlert.fromJson(Map<String, dynamic> json) {
return MessageAlert(
messageId: json['messageId']?.toString() ?? '',
conversationId: json['conversationId']?.toString() ?? '',
senderId: json['senderId']?.toString() ?? '',
senderName: json['senderName']?.toString() ?? 'Utilisateur',
content: json['content']?.toString() ?? '',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'].toString())
: DateTime.now(),
);
}
}
/// Mise à jour de présence utilisateur.
class PresenceUpdate {
const PresenceUpdate({
required this.userId,
required this.isOnline,
this.lastSeen,
required this.timestamp,
});
final String userId;
final bool isOnline;
final DateTime? lastSeen;
final DateTime timestamp;
factory PresenceUpdate.fromJson(Map<String, dynamic> json) {
return PresenceUpdate(
userId: json['userId']?.toString() ?? '',
isOnline: json['isOnline'] as bool? ?? false,
lastSeen: json['lastSeen'] != null && json['lastSeen'].toString().isNotEmpty
? DateTime.tryParse(json['lastSeen'].toString())
: null,
timestamp: json['timestamp'] != null
? DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int)
: DateTime.now(),
);
}
}