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
This commit is contained in:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../../core/utils/app_logger.dart';
/// Service pour gérer le chargement des catégories depuis un fichier JSON.
class CategoryService {
/// Méthode pour charger les catégories depuis un fichier JSON.
@@ -9,23 +11,25 @@ class CategoryService {
Future<Map<String, List<String>>> loadCategories() async {
try {
// Charger le fichier JSON à partir des assets
print('Chargement du fichier JSON des catégories...');
AppLogger.d('Chargement du fichier JSON des catégories...', tag: 'CategoryService');
final String response = await rootBundle.loadString('lib/assets/json/event_categories.json');
// Décoder le contenu du fichier JSON
final Map<String, dynamic> data = json.decode(response);
print('Données JSON décodées avec succès.');
final dynamic decodedData = json.decode(response);
final Map<String, dynamic> data = decodedData as Map<String, dynamic>;
AppLogger.d('Données JSON décodées avec succès.', tag: 'CategoryService');
// Transformer les données en un Map de catégories par type
final Map<String, List<String>> categoriesByType = (data['categories'] as Map<String, dynamic>).map(
(key, value) => MapEntry(key, List<String>.from(value)),
final categoriesData = data['categories'] as Map<String, dynamic>;
final Map<String, List<String>> categoriesByType = categoriesData.map(
(key, value) => MapEntry(key, List<String>.from(value as List)),
);
print('Catégories chargées: $categoriesByType');
AppLogger.d('Catégories chargées: ${categoriesByType.keys.length} types', tag: 'CategoryService');
return categoriesByType;
} catch (e) {
} catch (e, stackTrace) {
// Gérer les erreurs de chargement ou de décodage
print('Erreur lors du chargement des catégories: $e');
AppLogger.e('Erreur lors du chargement des catégories', error: e, stackTrace: stackTrace, tag: 'CategoryService');
throw Exception('Impossible de charger les catégories.');
}
}

View File

@@ -0,0 +1,318 @@
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';
import '../../domain/entities/chat_message.dart';
import '../models/chat_message_model.dart';
/// Service WebSocket pour la communication temps réel du chat.
///
/// Ce service gère :
/// - La connexion WebSocket au serveur
/// - La réception de nouveaux messages en temps réel
/// - Les indicateurs de frappe (typing indicators)
/// - Les confirmations de lecture (read receipts)
class ChatWebSocketService extends ChangeNotifier {
ChatWebSocketService(this.userId);
final String userId;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
bool _isConnected = false;
bool get isConnected => _isConnected;
bool _isDisposed = false;
Timer? _reconnectTimer;
// Streams pour les événements temps réel
final _messageController = StreamController<ChatMessage>.broadcast();
final _typingController = StreamController<TypingIndicator>.broadcast();
final _readReceiptController = StreamController<ReadReceipt>.broadcast();
final _deliveryController = StreamController<DeliveryConfirmation>.broadcast();
Stream<ChatMessage> get messageStream => _messageController.stream;
Stream<TypingIndicator> get typingStream => _typingController.stream;
Stream<ReadReceipt> get readReceiptStream => _readReceiptController.stream;
Stream<DeliveryConfirmation> get deliveryStream => _deliveryController.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/chat/ws/$userId';
}
/// Se connecte au serveur WebSocket.
Future<void> connect() async {
if (_isDisposed) {
AppLogger.w('Tentative de connexion après dispose, ignorée', tag: 'ChatWebSocketService');
return;
}
if (_isConnected) {
AppLogger.w('Déjà connecté', tag: 'ChatWebSocketService');
return;
}
try {
AppLogger.i('Connexion à: $_wsUrl', tag: 'ChatWebSocketService');
_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', tag: 'ChatWebSocketService');
} catch (e, stackTrace) {
AppLogger.e('Erreur de connexion', error: e, stackTrace: stackTrace, tag: 'ChatWebSocketService');
_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: 'ChatWebSocketService');
await _subscription?.cancel();
_subscription = null;
try {
await _channel?.sink.close();
} catch (e) {
AppLogger.w('Erreur lors de la fermeture du canal: $e', tag: 'ChatWebSocketService');
}
_channel = null;
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
AppLogger.i('Déconnecté', tag: 'ChatWebSocketService');
}
/// Envoie un message via WebSocket.
void sendMessage(ChatMessage message) {
if (!_isConnected) {
AppLogger.w('Erreur: Non connecté', tag: 'ChatWebSocketService');
return;
}
final model = ChatMessageModel.fromEntity(message);
final payload = {
'type': 'message',
'data': model.toJson(),
};
_channel?.sink.add(json.encode(payload));
AppLogger.d('Message envoyé: ${message.id}', tag: 'ChatWebSocketService');
}
/// Envoie un indicateur de frappe.
void sendTypingIndicator(String conversationId, bool isTyping) {
if (!_isConnected) return;
final payload = {
'type': 'typing',
'data': {
'conversationId': conversationId,
'userId': userId,
'isTyping': isTyping,
},
};
_channel?.sink.add(json.encode(payload));
AppLogger.d('Indicateur de frappe envoyé: $isTyping', tag: 'ChatWebSocketService');
}
/// Envoie une confirmation de lecture.
void sendReadReceipt(String messageId) {
if (!_isConnected) return;
final payload = {
'type': 'read',
'data': {
'messageId': messageId,
'userId': userId,
},
};
_channel?.sink.add(json.encode(payload));
AppLogger.d('Confirmation de lecture envoyée: $messageId', tag: 'ChatWebSocketService');
}
/// 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 (payload == null) return;
switch (type) {
case 'message':
final message = ChatMessageModel.fromJson(payload).toEntity();
_messageController.add(message);
AppLogger.d('Nouveau message reçu: ${message.id}', tag: 'ChatWebSocketService');
break;
case 'typing':
final indicator = TypingIndicator(
conversationId: payload['conversationId'] as String,
userId: payload['userId'] as String,
isTyping: payload['isTyping'] as bool,
);
_typingController.add(indicator);
AppLogger.d('Indicateur de frappe: ${indicator.isTyping}', tag: 'ChatWebSocketService');
break;
case 'read':
AppLogger.d('RECEIVED READ: $payload', tag: 'ChatWebSocketService');
final receipt = ReadReceipt(
messageId: payload['messageId'] as String,
userId: payload['userId'] as String,
timestamp: DateTime.parse(payload['timestamp'] as String),
);
_readReceiptController.add(receipt);
AppLogger.d('Confirmation lecture ajoutée au stream: ${receipt.messageId}', tag: 'ChatWebSocketService');
break;
case 'delivered':
AppLogger.d('RECEIVED DELIVERED: $payload', tag: 'ChatWebSocketService');
final confirmation = DeliveryConfirmation(
messageId: payload['messageId'] as String,
isDelivered: payload['isDelivered'] as bool,
timestamp: DateTime.fromMillisecondsSinceEpoch(
payload['timestamp'] as int,
),
);
_deliveryController.add(confirmation);
AppLogger.d('Confirmation délivrance ajoutée au stream: ${confirmation.messageId}', tag: 'ChatWebSocketService');
break;
default:
AppLogger.w('Type de message inconnu: $type', tag: 'ChatWebSocketService');
}
} catch (e, stackTrace) {
AppLogger.e('Erreur parsing message', error: e, stackTrace: stackTrace, tag: 'ChatWebSocketService');
}
}
/// Gère les erreurs WebSocket.
void _handleError(Object error) {
AppLogger.w('Erreur WebSocket: $error', tag: 'ChatWebSocketService');
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
// Ne pas tenter de reconnexion si le backend ne supporte pas WebSocket
// Le backend retourne "Connection was not upgraded to websocket"
if (error.toString().contains('not upgraded to websocket')) {
AppLogger.w('WebSocket non supporté par le backend, reconnexion désactivée', tag: 'ChatWebSocketService');
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: 'ChatWebSocketService');
_reconnectTimer = null;
connect();
}
});
}
}
/// Gère la déconnexion WebSocket.
void _handleDisconnection() {
AppLogger.i('Déconnexion détectée', tag: 'ChatWebSocketService');
_isConnected = false;
if (!_isDisposed) {
notifyListeners();
}
}
@override
void dispose() {
_isDisposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
disconnect();
_messageController.close();
_typingController.close();
_readReceiptController.close();
_deliveryController.close();
super.dispose();
}
}
/// Indicateur de frappe.
class TypingIndicator {
const TypingIndicator({
required this.conversationId,
required this.userId,
required this.isTyping,
});
final String conversationId;
final String userId;
final bool isTyping;
}
/// Confirmation de lecture.
class ReadReceipt {
const ReadReceipt({
required this.messageId,
required this.userId,
required this.timestamp,
});
final String messageId;
final String userId;
final DateTime timestamp;
}
/// Confirmation de délivrance.
class DeliveryConfirmation {
const DeliveryConfirmation({
required this.messageId,
required this.isDelivered,
required this.timestamp,
});
final String messageId;
final bool isDelivered;
final DateTime timestamp;
}

View File

@@ -1,13 +1,14 @@
import 'package:flutter_bcrypt/flutter_bcrypt.dart';
import 'package:http/http.dart' as http;
import 'package:afterwork/core/constants/urls.dart';
import '../../core/constants/urls.dart';
import '../../core/utils/app_logger.dart';
class HashPasswordService {
/// Hache le mot de passe en utilisant Bcrypt.
/// Renvoie une chaîne hachée sécurisée.
Future<String> hashPassword(String email, String password) async {
try {
print("Tentative de récupération du sel depuis le serveur pour l'email : $email");
AppLogger.d("Tentative de récupération du sel depuis le serveur pour l'email : $email", tag: 'HashPasswordService');
// Récupérer le sel depuis le serveur avec l'email
final response = await http.get(Uri.parse('${Urls.baseUrl}/users/salt?email=$email'));
@@ -15,32 +16,32 @@ class HashPasswordService {
String salt;
if (response.statusCode == 200 && response.body.isNotEmpty) {
salt = response.body;
print("Sel récupéré depuis le serveur : $salt");
AppLogger.d('Sel récupéré depuis le serveur : $salt', tag: 'HashPasswordService');
} else {
// Si le sel n'est pas trouvé, on en génère un
salt = await FlutterBcrypt.saltWithRounds(rounds: 12);
print("Sel généré : $salt");
AppLogger.d('Sel généré : $salt', tag: 'HashPasswordService');
}
// Hachage du mot de passe avec le sel
String hashedPassword = await FlutterBcrypt.hashPw(password: password, salt: salt);
print("Mot de passe haché avec succès : $hashedPassword");
final String hashedPassword = await FlutterBcrypt.hashPw(password: password, salt: salt);
AppLogger.d('Mot de passe haché avec succès', tag: 'HashPasswordService');
return hashedPassword;
} catch (e) {
print("Erreur lors du hachage du mot de passe : $e");
throw Exception("Erreur lors du hachage du mot de passe.");
} catch (e, stackTrace) {
AppLogger.e('Erreur lors du hachage du mot de passe', error: e, stackTrace: stackTrace, tag: 'HashPasswordService');
throw Exception('Erreur lors du hachage du mot de passe.');
}
}
Future<bool> verifyPassword(String password, String hashedPassword) async {
try {
print("Début de la vérification du mot de passe");
bool result = await FlutterBcrypt.verify(password: password, hash: hashedPassword);
print("Résultat de la vérification : $result");
AppLogger.d('Début de la vérification du mot de passe', tag: 'HashPasswordService');
final bool result = await FlutterBcrypt.verify(password: password, hash: hashedPassword);
AppLogger.d('Résultat de la vérification : $result', tag: 'HashPasswordService');
return result;
} catch (e) {
print("Erreur lors de la vérification du mot de passe : $e");
throw Exception("Erreur lors de la vérification du mot de passe.");
} catch (e, stackTrace) {
AppLogger.e('Erreur lors de la vérification du mot de passe', error: e, stackTrace: stackTrace, tag: 'HashPasswordService');
throw Exception('Erreur lors de la vérification du mot de passe.');
}
}

View File

@@ -0,0 +1,173 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../../core/constants/env_config.dart';
/// Configuration de compression d'image.
class CompressionConfig {
const CompressionConfig({
this.quality = 85,
this.maxWidth = 1920,
this.maxHeight = 1920,
this.format = CompressFormat.jpeg,
});
final int quality; // 0-100
final int maxWidth;
final int maxHeight;
final CompressFormat format;
/// Configuration pour les posts (équilibre qualité/taille)
static const CompressionConfig post = CompressionConfig(
quality: 85,
maxWidth: 1920,
maxHeight: 1920,
);
/// Configuration pour les thumbnails (petite taille)
static const CompressionConfig thumbnail = CompressionConfig(
quality: 70,
maxWidth: 400,
maxHeight: 400,
);
/// Configuration pour les stories (vertical, haute qualité)
static const CompressionConfig story = CompressionConfig(
quality: 90,
maxWidth: 1080,
maxHeight: 1920,
);
/// Configuration pour les avatars (petit, carré)
static const CompressionConfig avatar = CompressionConfig(
quality: 80,
maxWidth: 500,
maxHeight: 500,
);
}
/// Service de compression d'images.
///
/// Compresse les images avant l'upload pour réduire la bande passante
/// et améliorer les performances.
class ImageCompressionService {
/// Compresse une image selon la configuration donnée.
Future<XFile?> compressImage(
XFile file, {
CompressionConfig config = CompressionConfig.post,
}) async {
try {
final filePath = file.path;
final lastIndex = filePath.lastIndexOf('.');
final splitted = filePath.substring(0, lastIndex);
final outPath = '${splitted}_compressed${path.extension(filePath)}';
if (EnvConfig.enableDetailedLogs) {
final originalSize = await File(filePath).length();
debugPrint('[ImageCompression] Compression de: ${path.basename(filePath)}');
debugPrint('[ImageCompression] Taille originale: ${_formatBytes(originalSize)}');
}
final result = await FlutterImageCompress.compressAndGetFile(
filePath,
outPath,
quality: config.quality,
minWidth: config.maxWidth,
minHeight: config.maxHeight,
format: config.format,
);
if (result != null) {
if (EnvConfig.enableDetailedLogs) {
final compressedSize = await File(result.path).length();
final originalSize = await File(filePath).length();
final reduction = ((1 - compressedSize / originalSize) * 100).toStringAsFixed(1);
debugPrint('[ImageCompression] Taille compressée: ${_formatBytes(compressedSize)}');
debugPrint('[ImageCompression] Réduction: $reduction%');
}
return result;
}
return null;
} catch (e) {
debugPrint('[ImageCompression] Erreur: $e');
// En cas d'erreur, on retourne le fichier original
return file;
}
}
/// Compresse plusieurs images en parallèle.
Future<List<XFile>> compressMultipleImages(
List<XFile> files, {
CompressionConfig config = CompressionConfig.post,
void Function(int processed, int total)? onProgress,
}) async {
final results = <XFile>[];
int processed = 0;
for (final file in files) {
// Ne compresser que les images, pas les vidéos
if (_isImageFile(file.path)) {
final compressed = await compressImage(file, config: config);
results.add(compressed ?? file);
} else {
results.add(file);
}
processed++;
if (onProgress != null) {
onProgress(processed, files.length);
}
}
return results;
}
/// Crée un thumbnail à partir d'une image.
Future<XFile?> createThumbnail(XFile file) async {
return compressImage(file, config: CompressionConfig.thumbnail);
}
/// Vérifie si le fichier est une image.
bool _isImageFile(String filePath) {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
final extension = path.extension(filePath).toLowerCase();
return imageExtensions.contains(extension);
}
/// Formate la taille en bytes de manière lisible.
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
/// Nettoie les fichiers temporaires compressés.
Future<void> cleanupTempFiles() async {
try {
final tempDir = await getTemporaryDirectory();
final files = tempDir.listSync();
for (final file in files) {
if (file.path.contains('_compressed')) {
await file.delete();
}
}
if (EnvConfig.enableDetailedLogs) {
debugPrint('[ImageCompression] Fichiers temporaires nettoyés');
}
} catch (e) {
debugPrint('[ImageCompression] Erreur nettoyage: $e');
}
}
}

View File

@@ -0,0 +1,199 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as path;
import 'package:video_thumbnail/video_thumbnail.dart' as video_thumb;
import '../../core/constants/env_config.dart';
/// Résultat d'un upload de média.
class MediaUploadResult {
const MediaUploadResult({
required this.url,
required this.thumbnailUrl,
required this.type,
this.duration,
});
final String url;
final String? thumbnailUrl;
final String type; // 'image' ou 'video'
final Duration? duration;
}
/// Service d'upload de médias vers le backend.
///
/// Gère l'upload d'images et de vidéos avec compression et génération de thumbnails.
class MediaUploadService {
MediaUploadService(this._client);
final http.Client _client;
/// URL de base pour l'upload (à configurer selon votre backend)
static const String _uploadEndpoint = '${EnvConfig.apiBaseUrl}/media/upload';
/// Upload un seul média (image ou vidéo).
Future<MediaUploadResult> uploadMedia(XFile file) async {
try {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Upload de: ${file.path}');
}
final fileExtension = path.extension(file.path).toLowerCase();
final isVideo = _isVideoFile(fileExtension);
// Créer la requête multipart
final request = http.MultipartRequest('POST', Uri.parse(_uploadEndpoint));
// Ajouter le fichier
final fileBytes = await file.readAsBytes();
final multipartFile = http.MultipartFile.fromBytes(
'file',
fileBytes,
filename: path.basename(file.path),
);
request.files.add(multipartFile);
// Ajouter le type
request.fields['type'] = isVideo ? 'video' : 'image';
// Envoyer la requête
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200 || response.statusCode == 201) {
// Parser la réponse JSON du backend
final responseData = json.decode(response.body) as Map<String, dynamic>;
// Format attendu du backend:
// {
// "url": "https://...",
// "thumbnailUrl": "https://...", (optionnel)
// "type": "image" ou "video",
// "duration": 60 (en secondes, optionnel)
// }
final url = responseData['url'] as String? ??
'https://example.com/media/${path.basename(file.path)}';
final thumbnailUrl = responseData['thumbnailUrl'] as String?;
final typeFromBackend = responseData['type'] as String?;
final durationSeconds = responseData['duration'] as int?;
if (EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Upload réussi: $url');
}
return MediaUploadResult(
url: url,
thumbnailUrl: thumbnailUrl,
type: typeFromBackend ?? (isVideo ? 'video' : 'image'),
duration: durationSeconds != null
? Duration(seconds: durationSeconds)
: null,
);
} else {
throw Exception(
'Échec de l\'upload: ${response.statusCode} - ${response.body}',
);
}
} catch (e) {
debugPrint('[MediaUploadService] Erreur: $e');
rethrow;
}
}
/// Upload plusieurs médias en parallèle.
Future<List<MediaUploadResult>> uploadMultipleMedias(
List<XFile> files, {
void Function(int uploaded, int total)? onProgress,
}) async {
final results = <MediaUploadResult>[];
int uploaded = 0;
for (final file in files) {
try {
final result = await uploadMedia(file);
results.add(result);
uploaded++;
if (onProgress != null) {
onProgress(uploaded, files.length);
}
} catch (e) {
debugPrint('[MediaUploadService] Échec upload ${file.path}: $e');
// On continue avec les autres fichiers
}
}
return results;
}
/// Vérifie si le fichier est une vidéo.
bool _isVideoFile(String extension) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.m4v'];
return videoExtensions.contains(extension);
}
/// Supprime un média du serveur.
Future<void> deleteMedia(String mediaUrl) async {
try {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Suppression de: $mediaUrl');
}
// Extraire l'ID ou le nom du fichier de l'URL
final uri = Uri.parse(mediaUrl);
final fileName = uri.pathSegments.last;
// Appel API pour supprimer le média
final deleteUrl = '${EnvConfig.apiBaseUrl}/media/$fileName';
final response = await _client.delete(
Uri.parse(deleteUrl),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200 || response.statusCode == 204) {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Média supprimé: $mediaUrl');
}
} else {
throw Exception(
'Échec de la suppression: ${response.statusCode} - ${response.body}',
);
}
} catch (e) {
debugPrint('[MediaUploadService] Erreur suppression: $e');
rethrow;
}
}
/// Génère un thumbnail pour une vidéo.
Future<String?> generateVideoThumbnail(String videoPath) async {
try {
if (EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Génération thumbnail pour: $videoPath');
}
// Générer le thumbnail à partir de la vidéo
final thumbnailPath = await video_thumb.VideoThumbnail.thumbnailFile(
video: videoPath,
thumbnailPath: (await Directory.systemTemp.createTemp()).path,
imageFormat: video_thumb.ImageFormat.JPEG,
maxWidth: 640,
quality: 75,
);
if (thumbnailPath != null && EnvConfig.enableDetailedLogs) {
debugPrint('[MediaUploadService] Thumbnail généré: $thumbnailPath');
}
return thumbnailPath;
} catch (e) {
debugPrint('[MediaUploadService] Erreur génération thumbnail: $e');
return null;
}
}
}

View File

@@ -0,0 +1,246 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../domain/entities/notification.dart' as domain;
import '../datasources/notification_remote_data_source.dart';
import '../services/realtime_notification_service.dart';
import '../services/secure_storage.dart';
/// Service centralisé de gestion des notifications.
///
/// Ce service gère:
/// - Le compteur de notifications non lues
/// - Le chargement des notifications depuis l'API
/// - Les notifications in-app
/// - Les listeners pour les changements
///
/// **Usage avec Provider:**
/// ```dart
/// // Dans main.dart
/// ChangeNotifierProvider(
/// create: (_) => NotificationService(
/// NotificationRemoteDataSource(http.Client()),
/// SecureStorage(),
/// )..initialize(),
/// ),
///
/// // Dans un widget
/// final notificationService = Provider.of<NotificationService>(context);
/// final unreadCount = notificationService.unreadCount;
/// ```
class NotificationService extends ChangeNotifier {
NotificationService(this._dataSource, this._secureStorage);
final NotificationRemoteDataSource _dataSource;
final SecureStorage _secureStorage;
List<domain.Notification> _notifications = [];
bool _isLoading = false;
Timer? _refreshTimer;
// Service de notifications temps réel
RealtimeNotificationService? _realtimeService;
StreamSubscription<SystemNotification>? _systemNotificationSubscription;
/// Liste de toutes les notifications
List<domain.Notification> get notifications => List.unmodifiable(_notifications);
/// Nombre total de notifications
int get totalCount => _notifications.length;
/// Nombre de notifications non lues
int get unreadCount => _notifications.where((n) => !n.isRead).length;
/// Indique si les notifications sont en cours de chargement
bool get isLoading => _isLoading;
/// Initialise le service (à appeler au démarrage de l'app).
Future<void> initialize() async {
await loadNotifications();
// Actualise les notifications toutes les 2 minutes
_startPeriodicRefresh();
}
/// Charge les notifications depuis l'API.
Future<void> loadNotifications() async {
_isLoading = true;
notifyListeners();
try {
final userId = await _secureStorage.getUserId();
if (userId == null || userId.isEmpty) {
_isLoading = false;
notifyListeners();
return;
}
final notificationModels = await _dataSource.getNotifications(userId);
_notifications = notificationModels.map((model) => model.toEntity()).toList();
// Trie par timestamp décroissant (les plus récentes en premier)
_notifications.sort((a, b) => b.timestamp.compareTo(a.timestamp));
} catch (e) {
debugPrint('[NotificationService] Erreur chargement: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Marque une notification comme lue.
Future<void> markAsRead(String notificationId) async {
try {
await _dataSource.markAsRead(notificationId);
// Mise à jour locale
final index = _notifications.indexWhere((n) => n.id == notificationId);
if (index != -1) {
_notifications[index] = _notifications[index].copyWith(isRead: true);
notifyListeners();
}
} catch (e) {
debugPrint('[NotificationService] Erreur marquage lu: $e');
rethrow;
}
}
/// Marque toutes les notifications comme lues.
Future<void> markAllAsRead() async {
try {
final userId = await _secureStorage.getUserId();
if (userId == null) return;
await _dataSource.markAllAsRead(userId);
// Mise à jour locale
_notifications = _notifications.map((n) => n.copyWith(isRead: true)).toList();
notifyListeners();
} catch (e) {
debugPrint('[NotificationService] Erreur marquage tout lu: $e');
rethrow;
}
}
/// Supprime une notification.
Future<void> deleteNotification(String notificationId) async {
try {
await _dataSource.deleteNotification(notificationId);
// Mise à jour locale
_notifications.removeWhere((n) => n.id == notificationId);
notifyListeners();
} catch (e) {
debugPrint('[NotificationService] Erreur suppression: $e');
rethrow;
}
}
/// Ajoute une nouvelle notification (simulation ou depuis push notification).
///
/// Utilisé pour afficher une notification in-app quand une nouvelle
/// notification arrive via Firebase Cloud Messaging.
void addNotification(domain.Notification notification) {
_notifications.insert(0, notification);
notifyListeners();
}
/// Démarre l'actualisation périodique des notifications.
void _startPeriodicRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(
const Duration(minutes: 2),
(_) => loadNotifications(),
);
}
/// Arrête l'actualisation périodique.
void stopPeriodicRefresh() {
_refreshTimer?.cancel();
_refreshTimer = null;
}
/// Connecte le service de notifications temps réel.
///
/// Cette méthode remplace le polling par des notifications push en temps réel.
/// Le polling est automatiquement désactivé lorsque le service temps réel est connecté.
///
/// [service] : Le service de notifications temps réel à connecter.
void connectRealtime(RealtimeNotificationService service) {
_realtimeService = service;
// IMPORTANT : Arrêter le polling puisqu'on passe en temps réel
stopPeriodicRefresh();
debugPrint('[NotificationService] Polling arrêté, passage en mode temps réel');
// Écouter les notifications système en temps réel
_systemNotificationSubscription = service.systemNotificationStream.listen(
_handleSystemNotification,
onError: (error) {
debugPrint('[NotificationService] Erreur dans le stream de notifications système: $error');
},
);
debugPrint('[NotificationService] Service de notifications temps réel connecté');
}
/// Gère les notifications système reçues en temps réel.
///
/// Cette méthode est appelée automatiquement lorsqu'une notification
/// est reçue via WebSocket.
void _handleSystemNotification(SystemNotification notification) {
debugPrint('[NotificationService] Notification système reçue: ${notification.title}');
// Convertir en entité domain
final domainNotification = domain.Notification(
id: notification.notificationId,
title: notification.title,
message: notification.message,
type: _parseNotificationType(notification.type),
timestamp: notification.timestamp,
isRead: false,
eventId: null,
userId: '', // Le userId sera récupéré du contexte
metadata: null,
);
// Ajouter à la liste locale (en tête de liste pour avoir les plus récentes en premier)
addNotification(domainNotification);
debugPrint('[NotificationService] Notification ajoutée à la liste locale: ${notification.title}');
}
/// Parse le type de notification depuis une chaîne.
domain.NotificationType _parseNotificationType(String type) {
switch (type.toLowerCase()) {
case 'event':
return domain.NotificationType.event;
case 'friend':
return domain.NotificationType.friend;
case 'reminder':
return domain.NotificationType.reminder;
default:
return domain.NotificationType.other;
}
}
/// Déconnecte le service de notifications temps réel.
///
/// Le polling est automatiquement redémarré lorsque le service est déconnecté.
void disconnectRealtime() {
_systemNotificationSubscription?.cancel();
_systemNotificationSubscription = null;
_realtimeService = null;
// Redémarrer le polling si déconnecté du temps réel
_startPeriodicRefresh();
debugPrint('[NotificationService] Service temps réel déconnecté, reprise du polling');
}
@override
void dispose() {
disconnectRealtime();
stopPeriodicRefresh();
super.dispose();
}
}

View File

@@ -1,5 +1,7 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/utils/app_logger.dart';
/// Classe pour gérer les préférences utilisateur à l'aide de SharedPreferences.
/// Permet de stocker et récupérer des informations de manière non sécurisée,
/// contrairement au stockage sécurisé qui est utilisé pour des données sensibles.
@@ -11,88 +13,88 @@ class PreferencesHelper {
/// Sauvegarde une chaîne de caractères (String) dans les préférences.
/// Les actions sont loguées et les erreurs capturées pour garantir une sauvegarde correcte.
Future<void> setString(String key, String value) async {
print("[LOG] Sauvegarde dans les préférences : clé = $key, valeur = $value");
AppLogger.d('Sauvegarde dans les préférences : clé = $key, valeur = $value', tag: 'PreferencesHelper');
final prefs = await _prefs;
final success = await prefs.setString(key, value);
if (success) {
print("[LOG] Sauvegarde réussie pour la clé : $key");
AppLogger.d('Sauvegarde réussie pour la clé : $key', tag: 'PreferencesHelper');
} else {
print("[ERROR] Échec de la sauvegarde pour la clé : $key");
AppLogger.e('Échec de la sauvegarde pour la clé : $key', tag: 'PreferencesHelper');
}
}
/// Récupère une chaîne de caractères depuis les préférences.
/// Retourne la valeur ou null si aucune donnée n'est trouvée.
Future<String?> getString(String key) async {
print("[LOG] Récupération depuis les préférences pour la clé : $key");
AppLogger.d('Récupération depuis les préférences pour la clé : $key', tag: 'PreferencesHelper');
final prefs = await _prefs;
final value = prefs.getString(key);
print("[LOG] Valeur récupérée pour la clé $key : $value");
AppLogger.d('Valeur récupérée pour la clé $key : $value', tag: 'PreferencesHelper');
return value;
}
/// Supprime une entrée dans les préférences.
/// Logue chaque étape de la suppression.
Future<void> remove(String key) async {
print("[LOG] Suppression dans les préférences pour la clé : $key");
AppLogger.d('Suppression dans les préférences pour la clé : $key', tag: 'PreferencesHelper');
final prefs = await _prefs;
final success = await prefs.remove(key);
if (success) {
print("[LOG] Suppression réussie pour la clé : $key");
AppLogger.d('Suppression réussie pour la clé : $key', tag: 'PreferencesHelper');
} else {
print("[ERROR] Échec de la suppression pour la clé : $key");
AppLogger.e('Échec de la suppression pour la clé : $key', tag: 'PreferencesHelper');
}
}
/// Sauvegarde l'identifiant utilisateur dans les préférences.
/// Logue l'action et assure la robustesse de l'opération.
Future<void> saveUserId(String userId) async {
print("[LOG] Sauvegarde de l'userId dans les préférences : $userId");
AppLogger.d("Sauvegarde de l'userId dans les préférences : $userId", tag: 'PreferencesHelper');
await setString('user_id', userId);
}
/// Récupère l'identifiant utilisateur depuis les préférences.
/// Retourne l'ID ou null en cas d'échec.
Future<String?> getUserId() async {
print("[LOG] Récupération de l'userId depuis les préférences.");
return await getString('user_id');
AppLogger.d("Récupération de l'userId depuis les préférences.", tag: 'PreferencesHelper');
return getString('user_id');
}
/// Sauvegarde le nom d'utilisateur dans les préférences.
/// Logue l'opération pour assurer un suivi complet.
Future<void> saveUserName(String userFirstName) async {
print("[LOG] Sauvegarde du userFirstName dans les préférences : $userFirstName");
AppLogger.d('Sauvegarde du userFirstName dans les préférences : $userFirstName', tag: 'PreferencesHelper');
await setString('user_name', userFirstName);
}
/// Récupère le nom d'utilisateur depuis les préférences.
/// Retourne le nom ou null en cas d'échec.
Future<String?> getUseFirstrName() async {
print("[LOG] Récupération du userFirstName depuis les préférences.");
return await getString('user_name');
AppLogger.d('Récupération du userFirstName depuis les préférences.', tag: 'PreferencesHelper');
return getString('user_name');
}
/// Sauvegarde le prénom de l'utilisateur dans les préférences.
/// Logue l'opération pour assurer un suivi complet.
Future<void> saveUserLastName(String userLastName) async {
print("[LOG] Sauvegarde du userLastName dans les préférences : $userLastName");
AppLogger.d('Sauvegarde du userLastName dans les préférences : $userLastName', tag: 'PreferencesHelper');
await setString('user_last_name', userLastName);
}
/// Récupère le prénom de l'utilisateur depuis les préférences.
/// Retourne le prénom ou null en cas d'échec.
Future<String?> getUserLastName() async {
print("[LOG] Récupération du userLastName depuis les préférences.");
return await getString('user_last_name');
AppLogger.d('Récupération du userLastName depuis les préférences.', tag: 'PreferencesHelper');
return getString('user_last_name');
}
/// Supprime toutes les informations utilisateur dans les préférences.
/// Logue chaque étape de la suppression.
Future<void> clearUserInfo() async {
print("[LOG] Suppression des informations utilisateur (userId, userFirstName, userLastName) des préférences.");
AppLogger.d('Suppression des informations utilisateur (userId, userFirstName, userLastName) des préférences.', tag: 'PreferencesHelper');
await remove('user_id');
await remove('user_name');
await remove('user_last_name');
print("[LOG] Suppression réussie des informations utilisateur.");
AppLogger.d('Suppression réussie des informations utilisateur.', tag: 'PreferencesHelper');
}
}

View File

@@ -0,0 +1,421 @@
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(),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart';
import '../../core/utils/app_logger.dart';
/// Classe SecureStorage pour gérer les opérations de stockage sécurisé.
/// Toutes les actions sont loguées pour permettre une traçabilité complète dans le terminal.
@@ -7,18 +8,15 @@ class SecureStorage {
// Instance de FlutterSecureStorage pour le stockage sécurisé.
final FlutterSecureStorage _storage = const FlutterSecureStorage();
// Logger pour suivre et enregistrer les actions dans le terminal.
final Logger _logger = Logger();
/// Écrit une valeur dans le stockage sécurisé avec la clé spécifiée.
/// Les actions sont loguées et les erreurs sont capturées pour assurer la robustesse.
Future<void> write(String key, String value) async {
try {
_logger.i("[LOG] Tentative d'écriture dans le stockage sécurisé : clé = $key, valeur = $value");
AppLogger.d("Tentative d'écriture dans le stockage sécurisé : clé = $key", tag: 'SecureStorage');
await _storage.write(key: key, value: value);
_logger.i("[LOG] Écriture réussie pour la clé : $key");
} catch (e) {
_logger.e("[ERROR] Échec d'écriture pour la clé $key : $e");
AppLogger.d('Écriture réussie pour la clé : $key', tag: 'SecureStorage');
} catch (e, stackTrace) {
AppLogger.e("Échec d'écriture pour la clé $key", error: e, stackTrace: stackTrace, tag: 'SecureStorage');
rethrow;
}
}
@@ -27,12 +25,12 @@ class SecureStorage {
/// Retourne la valeur ou null en cas d'erreur. Chaque action est loguée.
Future<String?> read(String key) async {
try {
_logger.i("[LOG] Lecture de la clé : $key");
AppLogger.d('Lecture de la clé : $key', tag: 'SecureStorage');
final value = await _storage.read(key: key);
_logger.i("[LOG] Valeur lue pour la clé $key : $value");
AppLogger.d('Valeur lue pour la clé $key : $value', tag: 'SecureStorage');
return value;
} catch (e) {
_logger.e("[ERROR] Échec de lecture pour la clé $key : $e");
} catch (e, stackTrace) {
AppLogger.e('Échec de lecture pour la clé $key', error: e, stackTrace: stackTrace, tag: 'SecureStorage');
return null;
}
}
@@ -41,11 +39,11 @@ class SecureStorage {
/// Logue chaque étape de l'opération de suppression.
Future<void> delete(String key) async {
try {
_logger.i("[LOG] Suppression de la clé : $key");
AppLogger.d('Suppression de la clé : $key', tag: 'SecureStorage');
await _storage.delete(key: key);
_logger.i("[LOG] Suppression réussie pour la clé : $key");
} catch (e) {
_logger.e("[ERROR] Échec de suppression pour la clé $key : $e");
AppLogger.d('Suppression réussie pour la clé : $key', tag: 'SecureStorage');
} catch (e, stackTrace) {
AppLogger.e('Échec de suppression pour la clé $key', error: e, stackTrace: stackTrace, tag: 'SecureStorage');
rethrow;
}
}
@@ -54,62 +52,62 @@ class SecureStorage {
/// Logue l'action et assure la robustesse de l'opération.
Future<void> saveUserId(String userId) async {
if (userId.isNotEmpty) {
_logger.i("[LOG] Tentative de sauvegarde de l'userId : $userId");
AppLogger.i("Tentative de sauvegarde de l'userId : $userId", tag: 'SecureStorage');
await write('user_id', userId);
final savedId = await getUserId(); // Récupération immédiate pour vérifier l'enregistrement
if (savedId != null && savedId == userId) {
_logger.i("[LOG] L'userId a été sauvegardé avec succès et vérifié : $savedId");
AppLogger.i("L'userId a été sauvegardé avec succès et vérifié : $savedId", tag: 'SecureStorage');
} else {
_logger.e("[ERROR] L'userId n'a pas été correctement sauvegardé.");
AppLogger.e("L'userId n'a pas été correctement sauvegardé.", tag: 'SecureStorage');
}
} else {
_logger.e("[ERROR] L'userId est vide, échec de sauvegarde.");
AppLogger.e("L'userId est vide, échec de sauvegarde.", tag: 'SecureStorage');
}
}
/// Récupère l'identifiant utilisateur depuis le stockage sécurisé.
/// Retourne l'ID ou null en cas d'échec.
Future<String?> getUserId() async {
_logger.i("[LOG] Récupération de l'userId.");
return await read('user_id');
AppLogger.d("Récupération de l'userId.", tag: 'SecureStorage');
return read('user_id');
}
/// Sauvegarde le nom d'utilisateur dans le stockage sécurisé.
/// Retourne un booléen pour indiquer le succès ou l'échec.
Future<bool> saveUserName(String userName) async {
_logger.i("[LOG] Tentative de sauvegarde du userName : $userName");
return await _safeWrite('user_name', userName);
AppLogger.d('Tentative de sauvegarde du userName : $userName', tag: 'SecureStorage');
return _safeWrite('user_name', userName);
}
/// Récupère le nom d'utilisateur depuis le stockage sécurisé.
/// Retourne le nom ou null en cas d'échec.
Future<String?> getUserName() async {
_logger.i("[LOG] Tentative de récupération du userName depuis le stockage sécurisé.");
return await _safeRead('user_name');
AppLogger.d('Tentative de récupération du userName depuis le stockage sécurisé.', tag: 'SecureStorage');
return _safeRead('user_name');
}
/// Sauvegarde le prénom de l'utilisateur dans le stockage sécurisé.
/// Retourne un booléen pour indiquer le succès ou l'échec.
Future<bool> saveUserLastName(String userLastName) async {
_logger.i("[LOG] Tentative de sauvegarde du userLastName : $userLastName");
return await _safeWrite('user_last_name', userLastName);
AppLogger.d('Tentative de sauvegarde du userLastName : $userLastName', tag: 'SecureStorage');
return _safeWrite('user_last_name', userLastName);
}
/// Récupère le prénom de l'utilisateur depuis le stockage sécurisé.
/// Retourne le prénom ou null en cas d'échec.
Future<String?> getUserLastName() async {
_logger.i("[LOG] Tentative de récupération du userLastName depuis le stockage sécurisé.");
return await _safeRead('user_last_name');
AppLogger.d('Tentative de récupération du userLastName depuis le stockage sécurisé.', tag: 'SecureStorage');
return _safeRead('user_last_name');
}
/// Supprime toutes les informations utilisateur du stockage sécurisé.
/// Logue chaque étape de la suppression.
Future<void> deleteUserInfo() async {
_logger.i("[LOG] Tentative de suppression de toutes les informations utilisateur.");
AppLogger.i('Tentative de suppression de toutes les informations utilisateur.', tag: 'SecureStorage');
await delete('user_id');
await delete('user_name');
await delete('user_last_name');
_logger.i("[LOG] Suppression réussie des informations utilisateur.");
AppLogger.i('Suppression réussie des informations utilisateur.', tag: 'SecureStorage');
}
/// Méthode privée pour encapsuler l'écriture sécurisée avec gestion d'erreur.
@@ -119,7 +117,7 @@ class SecureStorage {
await write(key, value);
return true; // Indique que l'écriture a réussi.
} catch (e) {
_logger.e("[ERROR] Erreur lors de l'écriture sécurisée : $e");
AppLogger.e("Erreur lors de l'écriture sécurisée", error: e, tag: 'SecureStorage');
return false; // Indique un échec.
}
}
@@ -130,7 +128,7 @@ class SecureStorage {
try {
return await read(key);
} catch (e) {
_logger.e("[ERROR] Erreur lors de la lecture sécurisée : $e");
AppLogger.e('Erreur lors de la lecture sécurisée', error: e, tag: 'SecureStorage');
return null; // Retourne null en cas d'erreur.
}
}