## 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
422 lines
14 KiB
Dart
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(),
|
|
);
|
|
}
|
|
}
|