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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
318
lib/data/services/chat_websocket_service.dart
Normal file
318
lib/data/services/chat_websocket_service.dart
Normal 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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
173
lib/data/services/image_compression_service.dart
Normal file
173
lib/data/services/image_compression_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
199
lib/data/services/media_upload_service.dart
Normal file
199
lib/data/services/media_upload_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
246
lib/data/services/notification_service.dart
Normal file
246
lib/data/services/notification_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
421
lib/data/services/realtime_notification_service.dart
Normal file
421
lib/data/services/realtime_notification_service.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user