feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -1,418 +1,98 @@
/// Gestionnaire de cache multi-niveaux ultra-performant
/// Cache mémoire + disque avec TTL adaptatif selon les rôles
library dashboard_cache_manager;
import 'dart:async';
import 'dart:convert';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart';
import '../../features/authentication/data/models/user_role.dart';
/// Gestionnaire de cache intelligent avec stratégie multi-niveaux
///
/// Niveaux de cache :
/// 1. Cache mémoire (ultra-rapide, volatile)
/// 2. Cache disque (rapide, persistant)
/// 3. Cache réseau (si applicable)
///
/// Fonctionnalités :
/// - TTL adaptatif selon le rôle utilisateur
/// - Compression automatique des données volumineuses
/// - Invalidation intelligente
/// - Métriques de performance
/// - Nettoyage automatique
/// UnionFlow Mobile - Gestionnaire de Cache Unique (DRY)
/// Gère le cache mémoire (L1) et disque (L2) avec SharedPreferences.
class DashboardCacheManager {
static final DashboardCacheManager _instance = DashboardCacheManager._internal();
factory DashboardCacheManager() => _instance;
DashboardCacheManager._internal();
/// Cache mémoire niveau 1 (ultra-rapide)
static final Map<String, _CachedData> _memoryCache = {};
/// Instance SharedPreferences pour le cache disque
static final Map<String, dynamic> _memoryCache = {};
static final Map<String, DateTime> _cacheTimestamps = {};
static SharedPreferences? _prefs;
/// Taille maximale du cache mémoire (en nombre d'entrées)
static const int _maxMemoryCacheSize = 1000;
/// Taille maximale du cache disque (en MB)
static const int _maxDiskCacheSizeMB = 50;
/// TTL par défaut selon les rôles
static const Map<UserRole, Duration> _roleTTL = {
UserRole.superAdmin: Duration(hours: 2), // Cache plus long pour les admins
UserRole.orgAdmin: Duration(hours: 1), // Cache modéré pour les admins org
UserRole.moderator: Duration(minutes: 30), // Cache court pour les modérateurs
UserRole.activeMember: Duration(minutes: 15), // Cache très court pour les membres
UserRole.simpleMember: Duration(minutes: 10), // Cache minimal
UserRole.visitor: Duration(minutes: 5), // Cache très court pour les visiteurs
};
/// Compteurs de performance
static int _memoryHits = 0;
static int _memoryMisses = 0;
static int _diskHits = 0;
static int _diskMisses = 0;
/// Timer pour le nettoyage automatique
static Timer? _cleanupTimer;
static const Duration _defaultExpiry = Duration(minutes: 15);
/// Initialise le gestionnaire de cache
static Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
// Démarrer le nettoyage automatique toutes les 30 minutes
_cleanupTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => _performAutomaticCleanup(),
);
debugPrint('DashboardCacheManager initialisé');
debugPrint('📦 DashboardCacheManager Initialisé');
}
/// Dispose le gestionnaire de cache
static void dispose() {
_cleanupTimer?.cancel();
_memoryCache.clear();
}
/// Récupère une donnée du cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [fromDisk] - Autoriser la récupération depuis le disque
static Future<T?> get<T>(
String key,
UserRole userRole, {
bool fromDisk = true,
}) async {
// Niveau 1 : Cache mémoire
final memoryData = _getFromMemory<T>(key);
if (memoryData != null) {
_memoryHits++;
return memoryData;
}
_memoryMisses++;
// Niveau 2 : Cache disque
if (fromDisk && _prefs != null) {
final diskData = await _getFromDisk<T>(key, userRole);
if (diskData != null) {
_diskHits++;
// Remettre en cache mémoire pour les prochains accès
await _putInMemory(key, diskData, userRole);
return diskData;
static T? get<T>(String key) {
// 1. Check mémoire
if (_memoryCache.containsKey(key)) {
final ts = _cacheTimestamps[key];
if (ts != null && DateTime.now().difference(ts) < _defaultExpiry) {
return _memoryCache[key] as T?;
}
}
// 2. Check disque
if (_prefs != null) {
final jsonStr = _prefs!.getString('cache_$key');
if (jsonStr != null) {
try {
final data = jsonDecode(jsonStr);
_memoryCache[key] = data;
_cacheTimestamps[key] = DateTime.now();
return data as T?;
} catch (e, st) {
AppLogger.error('DashboardCacheManager.get: décodage JSON échoué pour key=$key', error: e, stackTrace: st);
}
}
_diskMisses++;
}
return null;
}
/// Stocke une donnée dans le cache avec stratégie multi-niveaux
///
/// [key] - Clé unique de la donnée
/// [data] - Donnée à stocker
/// [userRole] - Rôle de l'utilisateur pour le TTL adaptatif
/// [toDisk] - Sauvegarder sur disque
/// [compress] - Compresser les données volumineuses
static Future<void> put<T>(
String key,
T data,
UserRole userRole, {
bool toDisk = true,
bool compress = false,
}) async {
// Niveau 1 : Cache mémoire
await _putInMemory(key, data, userRole);
// Niveau 2 : Cache disque
if (toDisk && _prefs != null) {
await _putOnDisk(key, data, userRole, compress: compress);
}
}
/// Invalide une entrée du cache
static Future<void> invalidate(String key) async {
// Supprimer du cache mémoire
_memoryCache.remove(key);
// Supprimer du cache disque
static Future<void> set<T>(String key, T value) async {
_memoryCache[key] = value;
_cacheTimestamps[key] = DateTime.now();
if (_prefs != null) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
}
}
/// Invalide toutes les entrées d'un préfixe
static Future<void> invalidatePrefix(String prefix) async {
// Cache mémoire
final keysToRemove = _memoryCache.keys
.where((key) => key.startsWith(prefix))
.toList();
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Cache disque
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final diskKeysToRemove = allKeys
.where((key) => key.startsWith('cache_$prefix'))
.toList();
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
try {
await _prefs!.setString('cache_$key', jsonEncode(value));
} catch (e, st) {
AppLogger.error('DashboardCacheManager.set: écriture disque échouée pour key=$key', error: e, stackTrace: st);
rethrow;
}
}
}
/// Vide complètement le cache
static Future<void> invalidateForRole(UserRole role) async {
_memoryCache.removeWhere((key, _) => key.contains(role.name));
_cacheTimestamps.removeWhere((key, _) => key.contains(role.name));
if (_prefs != null) {
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_') && k.contains(role.name));
for (final k in keys) {
await _prefs!.remove(k);
}
}
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
}
static Future<void> clear() async {
_memoryCache.clear();
_cacheTimestamps.clear();
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final cacheKeys = allKeys.where((key) => key.startsWith('cache_')).toList();
for (final key in cacheKeys) {
await _prefs!.remove(key);
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_'));
for (final k in keys) {
await _prefs!.remove(k);
}
}
debugPrint('Cache complètement vidé');
debugPrint('🧹 Cache vidé globalement');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getStats() {
final totalMemoryRequests = _memoryHits + _memoryMisses;
final totalDiskRequests = _diskHits + _diskMisses;
final memoryHitRate = totalMemoryRequests > 0
? (_memoryHits / totalMemoryRequests * 100)
: 0.0;
final diskHitRate = totalDiskRequests > 0
? (_diskHits / totalDiskRequests * 100)
: 0.0;
/// Délégation instance pour linjection de dépendances
Future<void> setKey<T>(String key, T value) async => set<T>(key, value);
/// Délégation instance pour linjection de dépendances
T? getKey<T>(String key) => get<T>(key);
/// Statistiques du cache (entrées, etc.)
Map<String, dynamic> getCacheStats() {
final latest = _cacheTimestamps.isEmpty ? null : _cacheTimestamps.values.reduce((a, b) => a.isAfter(b) ? a : b);
return {
'memoryCache': {
'hits': _memoryHits,
'misses': _memoryMisses,
'hitRate': memoryHitRate.toStringAsFixed(2),
'size': _memoryCache.length,
'maxSize': _maxMemoryCacheSize,
},
'diskCache': {
'hits': _diskHits,
'misses': _diskMisses,
'hitRate': diskHitRate.toStringAsFixed(2),
'maxSizeMB': _maxDiskCacheSizeMB,
},
'entries': _memoryCache.length,
'timestamp': latest?.toIso8601String(),
};
}
/// Effectue un nettoyage manuel du cache
static Future<void> cleanup() async {
await _performAutomaticCleanup();
}
// === MÉTHODES PRIVÉES ===
/// Récupère une donnée du cache mémoire
static T? _getFromMemory<T>(String key) {
final cached = _memoryCache[key];
if (cached == null) return null;
// Vérifier l'expiration
if (cached.expiresAt.isBefore(DateTime.now())) {
_memoryCache.remove(key);
return null;
}
return cached.data as T?;
}
/// Stocke une donnée dans le cache mémoire
static Future<void> _putInMemory<T>(String key, T data, UserRole userRole) async {
// Vérifier la taille du cache et nettoyer si nécessaire
if (_memoryCache.length >= _maxMemoryCacheSize) {
await _cleanOldestMemoryEntries();
}
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
_memoryCache[key] = _CachedData(
data: data,
expiresAt: DateTime.now().add(ttl),
createdAt: DateTime.now(),
);
}
/// Récupère une donnée du cache disque
static Future<T?> _getFromDisk<T>(String key, UserRole userRole) async {
if (_prefs == null) return null;
// Récupérer les métadonnées
final metaJson = _prefs!.getString('cache_meta_$key');
if (metaJson == null) return null;
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
// Vérifier l'expiration
if (expiresAt.isBefore(DateTime.now())) {
await _prefs!.remove('cache_$key');
await _prefs!.remove('cache_meta_$key');
return null;
}
// Récupérer les données
final dataJson = _prefs!.getString('cache_$key');
if (dataJson == null) return null;
try {
final data = jsonDecode(dataJson);
return data as T;
} catch (e) {
debugPrint('Erreur de désérialisation du cache: $e');
return null;
}
}
/// Stocke une donnée sur le cache disque
static Future<void> _putOnDisk<T>(
String key,
T data,
UserRole userRole, {
bool compress = false,
}) async {
if (_prefs == null) return;
try {
final ttl = _roleTTL[userRole] ?? const Duration(minutes: 5);
final expiresAt = DateTime.now().add(ttl);
// Sérialiser les données
final dataJson = jsonEncode(data);
// Métadonnées
final meta = {
'expiresAt': expiresAt.toIso8601String(),
'createdAt': DateTime.now().toIso8601String(),
'userRole': userRole.name,
'compressed': compress,
};
// Sauvegarder
await _prefs!.setString('cache_$key', dataJson);
await _prefs!.setString('cache_meta_$key', jsonEncode(meta));
} catch (e) {
debugPrint('Erreur de sérialisation du cache: $e');
}
}
/// Nettoie les entrées les plus anciennes du cache mémoire
static Future<void> _cleanOldestMemoryEntries() async {
if (_memoryCache.isEmpty) return;
// Trier par date de création et supprimer les 10% les plus anciennes
final entries = _memoryCache.entries.toList();
entries.sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
final toRemove = (entries.length * 0.1).ceil();
for (int i = 0; i < toRemove && i < entries.length; i++) {
_memoryCache.remove(entries[i].key);
}
}
/// Effectue un nettoyage automatique
static Future<void> _performAutomaticCleanup() async {
final now = DateTime.now();
// Nettoyer le cache mémoire expiré
_memoryCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache disque expiré
if (_prefs != null) {
final allKeys = _prefs!.getKeys();
final metaKeys = allKeys.where((key) => key.startsWith('cache_meta_')).toList();
for (final metaKey in metaKeys) {
final metaJson = _prefs!.getString(metaKey);
if (metaJson != null) {
try {
final meta = jsonDecode(metaJson) as Map<String, dynamic>;
final expiresAt = DateTime.parse(meta['expiresAt']);
if (expiresAt.isBefore(now)) {
final dataKey = metaKey.replaceFirst('cache_meta_', 'cache_');
await _prefs!.remove(dataKey);
await _prefs!.remove(metaKey);
}
} catch (e) {
// Supprimer les métadonnées corrompues
await _prefs!.remove(metaKey);
}
}
}
}
debugPrint('Nettoyage automatique du cache effectué');
}
/// Invalide le cache pour un rôle spécifique
static Future<void> invalidateForRole(UserRole role) async {
debugPrint('🗑️ Invalidation du cache pour le rôle: ${role.displayName}');
// Invalider le cache mémoire pour ce rôle
final keysToRemove = <String>[];
for (final key in _memoryCache.keys) {
if (key.contains(role.name)) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_memoryCache.remove(key);
}
// Invalider le cache disque pour ce rôle
_prefs ??= await SharedPreferences.getInstance();
if (_prefs != null) {
final keys = _prefs!.getKeys();
final diskKeysToRemove = <String>[];
for (final key in keys) {
if (key.startsWith('cache_') && key.contains(role.name)) {
diskKeysToRemove.add(key);
}
}
for (final key in diskKeysToRemove) {
await _prefs!.remove(key);
// Supprimer aussi les métadonnées associées
final metaKey = key.replaceFirst('cache_', 'cache_meta_');
await _prefs!.remove(metaKey);
}
}
debugPrint('✅ Cache invalidé pour le rôle: ${role.displayName}');
}
}
/// Classe pour les données mises en cache
class _CachedData {
final dynamic data;
final DateTime expiresAt;
final DateTime createdAt;
_CachedData({
required this.data,
required this.expiresAt,
required this.createdAt,
});
}

View File

@@ -0,0 +1,154 @@
/// Storage for pending operations that failed due to network issues
library pending_operations_store;
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart' show AppLogger;
/// Store for persisting failed operations to retry later
@singleton
class PendingOperationsStore {
static const String _keyPendingOperations = 'pending_operations';
final SharedPreferences _preferences;
PendingOperationsStore(this._preferences);
/// Add a pending operation to the store
Future<void> addPendingOperation({
required String operationType,
required String endpoint,
required Map<String, dynamic> data,
Map<String, String>? headers,
}) async {
try {
final operations = await getPendingOperations();
final newOperation = {
'id': DateTime.now().millisecondsSinceEpoch.toString(),
'operationType': operationType,
'endpoint': endpoint,
'data': data,
'headers': headers ?? {},
'timestamp': DateTime.now().toIso8601String(),
'retryCount': 0,
};
operations.add(newOperation);
await _saveOperations(operations);
AppLogger.info('Pending operation added: $operationType on $endpoint');
} catch (e) {
AppLogger.error('Failed to add pending operation', error: e);
rethrow;
}
}
/// Get all pending operations
Future<List<Map<String, dynamic>>> getPendingOperations() async {
try {
final json = _preferences.getString(_keyPendingOperations);
if (json == null || json.isEmpty) {
return [];
}
final List<dynamic> decoded = jsonDecode(json);
return decoded.cast<Map<String, dynamic>>();
} catch (e) {
AppLogger.error('Failed to get pending operations', error: e);
return [];
}
}
/// Remove a pending operation by ID
Future<void> removePendingOperation(String id) async {
try {
final operations = await getPendingOperations();
operations.removeWhere((op) => op['id'] == id);
await _saveOperations(operations);
AppLogger.info('Pending operation removed: $id');
} catch (e) {
AppLogger.error('Failed to remove pending operation', error: e);
rethrow;
}
}
/// Update retry count for an operation
Future<void> incrementRetryCount(String id) async {
try {
final operations = await getPendingOperations();
final index = operations.indexWhere((op) => op['id'] == id);
if (index != -1) {
operations[index]['retryCount'] = (operations[index]['retryCount'] ?? 0) + 1;
operations[index]['lastRetryTimestamp'] = DateTime.now().toIso8601String();
await _saveOperations(operations);
}
} catch (e) {
AppLogger.error('Failed to increment retry count', error: e);
rethrow;
}
}
/// Clear all pending operations
Future<void> clearAll() async {
try {
await _preferences.remove(_keyPendingOperations);
AppLogger.info('All pending operations cleared');
} catch (e) {
AppLogger.error('Failed to clear pending operations', error: e);
rethrow;
}
}
/// Remove operations older than a certain duration
Future<void> removeOldOperations({Duration maxAge = const Duration(days: 7)}) async {
try {
final operations = await getPendingOperations();
final now = DateTime.now();
final filtered = operations.where((op) {
final timestamp = DateTime.parse(op['timestamp'] as String);
return now.difference(timestamp) < maxAge;
}).toList();
if (filtered.length != operations.length) {
await _saveOperations(filtered);
AppLogger.info('Removed ${operations.length - filtered.length} old operations');
}
} catch (e) {
AppLogger.error('Failed to remove old operations', error: e);
}
}
/// Get operations by type
Future<List<Map<String, dynamic>>> getOperationsByType(String operationType) async {
try {
final operations = await getPendingOperations();
return operations.where((op) => op['operationType'] == operationType).toList();
} catch (e) {
AppLogger.error('Failed to get operations by type', error: e);
return [];
}
}
/// Get count of pending operations
Future<int> getCount() async {
final operations = await getPendingOperations();
return operations.length;
}
/// Save operations to storage
Future<void> _saveOperations(List<Map<String, dynamic>> operations) async {
try {
final json = jsonEncode(operations);
await _preferences.setString(_keyPendingOperations, json);
} catch (e) {
AppLogger.error('Failed to save operations', error: e);
rethrow;
}
}
}