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,400 +0,0 @@
import 'dart:convert';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/dashboard_stats_model.dart';
import '../../config/dashboard_config.dart';
/// Gestionnaire de cache avancé pour le Dashboard
class DashboardCacheManager {
static const String _keyPrefix = 'dashboard_cache_';
static const String _keyDashboardData = '${_keyPrefix}data';
static const String _keyDashboardStats = '${_keyPrefix}stats';
static const String _keyRecentActivities = '${_keyPrefix}activities';
static const String _keyUpcomingEvents = '${_keyPrefix}events';
static const String _keyLastUpdate = '${_keyPrefix}last_update';
static const String _keyUserPreferences = '${_keyPrefix}user_prefs';
SharedPreferences? _prefs;
final Map<String, dynamic> _memoryCache = {};
final Map<String, DateTime> _cacheTimestamps = {};
Timer? _cleanupTimer;
/// Initialise le gestionnaire de cache
Future<void> initialize() async {
_prefs = await SharedPreferences.getInstance();
_startCleanupTimer();
await _loadMemoryCache();
}
/// Démarre le timer de nettoyage automatique
void _startCleanupTimer() {
_cleanupTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => _cleanupExpiredCache(),
);
}
/// Charge le cache en mémoire au démarrage
Future<void> _loadMemoryCache() async {
if (_prefs == null) return;
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
for (final key in keys) {
final value = _prefs!.getString(key);
if (value != null) {
try {
final data = jsonDecode(value);
_memoryCache[key] = data;
// Charger le timestamp si disponible
final timestampKey = '${key}_timestamp';
final timestamp = _prefs!.getInt(timestampKey);
if (timestamp != null) {
_cacheTimestamps[key] = DateTime.fromMillisecondsSinceEpoch(timestamp);
}
} catch (e) {
// Supprimer les données corrompues
await _prefs!.remove(key);
}
}
}
}
/// Sauvegarde les données complètes du dashboard
Future<void> cacheDashboardData(
DashboardDataModel data,
String organizationId,
String userId,
) async {
final key = '${_keyDashboardData}_${organizationId}_$userId';
await _cacheData(key, data.toJson());
}
/// Récupère les données complètes du dashboard
Future<DashboardDataModel?> getCachedDashboardData(
String organizationId,
String userId,
) async {
final key = '${_keyDashboardData}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null) {
try {
return DashboardDataModel.fromJson(data);
} catch (e) {
// Supprimer les données corrompues
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les statistiques du dashboard
Future<void> cacheDashboardStats(
DashboardStatsModel stats,
String organizationId,
String userId,
) async {
final key = '${_keyDashboardStats}_${organizationId}_$userId';
await _cacheData(key, stats.toJson());
}
/// Récupère les statistiques du dashboard
Future<DashboardStatsModel?> getCachedDashboardStats(
String organizationId,
String userId,
) async {
final key = '${_keyDashboardStats}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null) {
try {
return DashboardStatsModel.fromJson(data);
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les activités récentes
Future<void> cacheRecentActivities(
List<RecentActivityModel> activities,
String organizationId,
String userId,
) async {
final key = '${_keyRecentActivities}_${organizationId}_$userId';
final data = activities.map((activity) => activity.toJson()).toList();
await _cacheData(key, data);
}
/// Récupère les activités récentes
Future<List<RecentActivityModel>?> getCachedRecentActivities(
String organizationId,
String userId,
) async {
final key = '${_keyRecentActivities}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null && data is List) {
try {
return data
.map((item) => RecentActivityModel.fromJson(item))
.toList();
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les événements à venir
Future<void> cacheUpcomingEvents(
List<UpcomingEventModel> events,
String organizationId,
String userId,
) async {
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
final data = events.map((event) => event.toJson()).toList();
await _cacheData(key, data);
}
/// Récupère les événements à venir
Future<List<UpcomingEventModel>?> getCachedUpcomingEvents(
String organizationId,
String userId,
) async {
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
final data = await _getCachedData(key);
if (data != null && data is List) {
try {
return data
.map((item) => UpcomingEventModel.fromJson(item))
.toList();
} catch (e) {
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Sauvegarde les préférences utilisateur
Future<void> cacheUserPreferences(
Map<String, dynamic> preferences,
String userId,
) async {
final key = '${_keyUserPreferences}_$userId';
await _cacheData(key, preferences);
}
/// Récupère les préférences utilisateur
Future<Map<String, dynamic>?> getCachedUserPreferences(String userId) async {
final key = '${_keyUserPreferences}_$userId';
final data = await _getCachedData(key);
if (data != null && data is Map<String, dynamic>) {
return data;
}
return null;
}
/// Méthode générique pour sauvegarder des données
Future<void> _cacheData(String key, dynamic data) async {
if (_prefs == null) return;
try {
final jsonString = jsonEncode(data);
await _prefs!.setString(key, jsonString);
// Sauvegarder le timestamp
final timestamp = DateTime.now().millisecondsSinceEpoch;
await _prefs!.setInt('${key}_timestamp', timestamp);
// Mettre à jour le cache mémoire
_memoryCache[key] = data;
_cacheTimestamps[key] = DateTime.now();
} catch (e) {
// Erreur de sérialisation, ignorer
}
}
/// Méthode générique pour récupérer des données
Future<dynamic> _getCachedData(String key) async {
// Vérifier d'abord le cache mémoire
if (_memoryCache.containsKey(key)) {
if (_isCacheValid(key)) {
return _memoryCache[key];
} else {
// Cache expiré, le supprimer
await _removeCachedData(key);
return null;
}
}
// Vérifier le cache persistant
if (_prefs == null) return null;
final jsonString = _prefs!.getString(key);
if (jsonString != null) {
try {
final data = jsonDecode(jsonString);
// Vérifier la validité du cache
if (_isCacheValid(key)) {
// Charger en mémoire pour les prochains accès
_memoryCache[key] = data;
return data;
} else {
// Cache expiré, le supprimer
await _removeCachedData(key);
return null;
}
} catch (e) {
// Données corrompues, les supprimer
await _removeCachedData(key);
return null;
}
}
return null;
}
/// Vérifie si le cache est encore valide
bool _isCacheValid(String key) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) {
// Essayer de récupérer le timestamp depuis SharedPreferences
final timestampMs = _prefs?.getInt('${key}_timestamp');
if (timestampMs != null) {
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestampMs);
_cacheTimestamps[key] = cacheTime;
return DateTime.now().difference(cacheTime) < DashboardConfig.cacheExpiration;
}
return false;
}
return DateTime.now().difference(timestamp) < DashboardConfig.cacheExpiration;
}
/// Supprime des données du cache
Future<void> _removeCachedData(String key) async {
_memoryCache.remove(key);
_cacheTimestamps.remove(key);
if (_prefs != null) {
await _prefs!.remove(key);
await _prefs!.remove('${key}_timestamp');
}
}
/// Nettoie le cache expiré
Future<void> _cleanupExpiredCache() async {
final keysToRemove = <String>[];
for (final key in _cacheTimestamps.keys) {
if (!_isCacheValid(key)) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
await _removeCachedData(key);
}
}
/// Vide tout le cache
Future<void> clearCache() async {
_memoryCache.clear();
_cacheTimestamps.clear();
if (_prefs != null) {
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
for (final key in keys) {
await _prefs!.remove(key);
}
}
}
/// Vide le cache pour un utilisateur spécifique
Future<void> clearUserCache(String organizationId, String userId) async {
final userKeys = [
'${_keyDashboardData}_${organizationId}_$userId',
'${_keyDashboardStats}_${organizationId}_$userId',
'${_keyRecentActivities}_${organizationId}_$userId',
'${_keyUpcomingEvents}_${organizationId}_$userId',
'${_keyUserPreferences}_$userId',
];
for (final key in userKeys) {
await _removeCachedData(key);
}
}
/// Obtient les statistiques du cache
Map<String, dynamic> getCacheStats() {
final totalKeys = _memoryCache.length;
final validKeys = _cacheTimestamps.keys.where(_isCacheValid).length;
final expiredKeys = totalKeys - validKeys;
return {
'totalKeys': totalKeys,
'validKeys': validKeys,
'expiredKeys': expiredKeys,
'memoryUsage': _calculateMemoryUsage(),
'oldestEntry': _getOldestEntryAge(),
'newestEntry': _getNewestEntryAge(),
};
}
/// Calcule l'utilisation mémoire approximative
int _calculateMemoryUsage() {
int totalSize = 0;
for (final data in _memoryCache.values) {
try {
totalSize += jsonEncode(data).length;
} catch (e) {
// Ignorer les erreurs de sérialisation
}
}
return totalSize;
}
/// Obtient l'âge de l'entrée la plus ancienne
Duration? _getOldestEntryAge() {
if (_cacheTimestamps.isEmpty) return null;
final oldestTimestamp = _cacheTimestamps.values
.reduce((a, b) => a.isBefore(b) ? a : b);
return DateTime.now().difference(oldestTimestamp);
}
/// Obtient l'âge de l'entrée la plus récente
Duration? _getNewestEntryAge() {
if (_cacheTimestamps.isEmpty) return null;
final newestTimestamp = _cacheTimestamps.values
.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(newestTimestamp);
}
/// Libère les ressources
void dispose() {
_cleanupTimer?.cancel();
_memoryCache.clear();
_cacheTimestamps.clear();
}
}

View File

@@ -1,24 +1,36 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/utils/logger.dart';
import '../models/dashboard_stats_model.dart';
import '../../../../core/network/dio_client.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
abstract class DashboardRemoteDataSource {
Future<DashboardDataModel> getDashboardData(String organizationId, String userId);
/// Dashboard personnel du membre connecté (sans organisationId). GET /api/dashboard/membre/me
Future<MembreDashboardSyntheseModel> getMemberDashboardData();
/// Synthèse des cotisations du membre connecté. GET /api/cotisations/mes-cotisations/synthese
/// Utilisé en fallback quand les montants de getMemberDashboardData() sont à 0.
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Compte adhérent unifié (soldes, crédits, capacité d'emprunt). GET /api/membres/mon-compte
Future<CompteAdherentModel> getCompteAdherent();
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId);
Future<List<RecentActivityModel>> getRecentActivities(String organizationId, String userId, {int limit = 10});
Future<List<UpcomingEventModel>> getUpcomingEvents(String organizationId, String userId, {int limit = 5});
}
@Injectable(as: DashboardRemoteDataSource)
class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
final DioClient dioClient;
final ApiClient apiClient;
DashboardRemoteDataSourceImpl({required this.dioClient});
DashboardRemoteDataSourceImpl(this.apiClient);
@override
Future<DashboardDataModel> getDashboardData(String organizationId, String userId) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/data',
queryParameters: {
'organizationId': organizationId,
@@ -32,16 +44,77 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load dashboard data: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<MembreDashboardSyntheseModel> getMemberDashboardData() async {
try {
final response = await apiClient.get('/api/dashboard/membre/me');
if (response.statusCode == 200) {
return MembreDashboardSyntheseModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load member dashboard: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await apiClient.get('/api/cotisations/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
return response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
}
return null;
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<CompteAdherentModel> getCompteAdherent() async {
try {
final response = await apiClient.get('/api/membres/mon-compte');
if (response.statusCode == 200) {
return CompteAdherentModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load adherent account: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/stats',
queryParameters: {
'organizationId': organizationId,
@@ -55,9 +128,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load dashboard stats: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e, stackTrace: st);
rethrow;
}
}
@@ -68,7 +143,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
int limit = 10,
}) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/activities',
queryParameters: {
'organizationId': organizationId,
@@ -84,9 +159,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load recent activities: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e, stackTrace: st);
rethrow;
}
}
@@ -97,7 +174,7 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
int limit = 5,
}) async {
try {
final response = await dioClient.get(
final response = await apiClient.get(
'/api/v1/dashboard/events/upcoming',
queryParameters: {
'organizationId': organizationId,
@@ -113,9 +190,11 @@ class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
throw ServerException('Failed to load upcoming events: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e) {
throw ServerException('Unexpected error: $e');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e, stackTrace: st);
rethrow;
}
}
}

View File

@@ -0,0 +1,72 @@
/// Modèle pour le "compte adhérent" unifié (GET /api/membres/mon-compte).
class CompteAdherentModel {
final String numeroMembre;
final String nomComplet;
final String? organisationNom;
final String? dateAdhesion;
final String statutCompte;
final double soldeCotisations;
final double soldeEpargne;
final double soldeBloque;
final double soldeTotalDisponible;
final double encoursCreditTotal;
final double capaciteEmprunt;
final int nombreCotisationsPayees;
final int nombreCotisationsTotal;
final int nombreCotisationsEnRetard;
final int? tauxEngagement;
final int nombreComptesEpargne;
final String dateCalcul;
const CompteAdherentModel({
required this.numeroMembre,
required this.nomComplet,
this.organisationNom,
this.dateAdhesion,
this.statutCompte = 'ACTIF',
this.soldeCotisations = 0,
this.soldeEpargne = 0,
this.soldeBloque = 0,
this.soldeTotalDisponible = 0,
this.encoursCreditTotal = 0,
this.capaciteEmprunt = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.nombreCotisationsEnRetard = 0,
this.tauxEngagement,
this.nombreComptesEpargne = 0,
required this.dateCalcul,
});
factory CompteAdherentModel.fromJson(Map<String, dynamic> json) {
return CompteAdherentModel(
numeroMembre: json['numeroMembre'] as String? ?? 'N/A',
nomComplet: json['nomComplet'] as String? ?? '',
organisationNom: json['organisationNom'] as String?,
dateAdhesion: json['dateAdhesion'] as String?,
statutCompte: json['statutCompte'] as String? ?? 'ACTIF',
soldeCotisations: _toDouble(json['soldeCotisations']),
soldeEpargne: _toDouble(json['soldeEpargne']),
soldeBloque: _toDouble(json['soldeBloque']),
soldeTotalDisponible: _toDouble(json['soldeTotalDisponible']),
encoursCreditTotal: _toDouble(json['encoursCreditTotal']),
capaciteEmprunt: _toDouble(json['capaciteEmprunt']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ?? 0,
nombreCotisationsEnRetard: (json['nombreCotisationsEnRetard'] as num?)?.toInt() ?? 0,
tauxEngagement: (json['tauxEngagement'] as num?)?.toInt(),
nombreComptesEpargne: (json['nombreComptesEpargne'] as num?)?.toInt() ?? 0,
dateCalcul: json['dateCalcul'] as String? ?? '',
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
}

View File

@@ -17,6 +17,8 @@ class DashboardStatsModel extends Equatable {
final double monthlyGrowth;
final double engagementRate;
final DateTime lastUpdated;
final int? totalOrganizations;
final Map<String, int>? organizationTypeDistribution;
const DashboardStatsModel({
required this.totalMembers,
@@ -30,6 +32,8 @@ class DashboardStatsModel extends Equatable {
required this.monthlyGrowth,
required this.engagementRate,
required this.lastUpdated,
this.totalOrganizations,
this.organizationTypeDistribution,
});
factory DashboardStatsModel.fromJson(Map<String, dynamic> json) =>
@@ -63,6 +67,8 @@ class DashboardStatsModel extends Equatable {
monthlyGrowth,
engagementRate,
lastUpdated,
totalOrganizations,
organizationTypeDistribution,
];
}

View File

@@ -20,6 +20,11 @@ DashboardStatsModel _$DashboardStatsModelFromJson(Map<String, dynamic> json) =>
monthlyGrowth: (json['monthlyGrowth'] as num).toDouble(),
engagementRate: (json['engagementRate'] as num).toDouble(),
lastUpdated: DateTime.parse(json['lastUpdated'] as String),
totalOrganizations: (json['totalOrganizations'] as num?)?.toInt(),
organizationTypeDistribution:
(json['organizationTypeDistribution'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
);
Map<String, dynamic> _$DashboardStatsModelToJson(
@@ -36,6 +41,8 @@ Map<String, dynamic> _$DashboardStatsModelToJson(
'monthlyGrowth': instance.monthlyGrowth,
'engagementRate': instance.engagementRate,
'lastUpdated': instance.lastUpdated.toIso8601String(),
'totalOrganizations': instance.totalOrganizations,
'organizationTypeDistribution': instance.organizationTypeDistribution,
};
RecentActivityModel _$RecentActivityModelFromJson(Map<String, dynamic> json) =>

View File

@@ -11,6 +11,8 @@ class MembreDashboardSyntheseModel {
final double totalCotisationsPayeesToutTemps;
/// Nombre de cotisations payées (pour carte « Cotisations »).
final int nombreCotisationsPayees;
/// Nombre total de cotisations (toutes années, tous statuts).
final int nombreCotisationsTotal;
final String statutCotisations;
final int? tauxCotisationsPerso;
final double monSoldeEpargne;
@@ -32,6 +34,7 @@ class MembreDashboardSyntheseModel {
this.totalCotisationsPayeesAnnee = 0,
this.totalCotisationsPayeesToutTemps = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.statutCotisations = 'À jour',
this.tauxCotisationsPerso,
this.monSoldeEpargne = 0,
@@ -55,6 +58,8 @@ class MembreDashboardSyntheseModel {
totalCotisationsPayeesAnnee: _toDouble(json['totalCotisationsPayeesAnnee']),
totalCotisationsPayeesToutTemps: _toDouble(json['totalCotisationsPayeesToutTemps']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ??
(json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
statutCotisations: json['statutCotisations'] as String? ?? 'À jour',
tauxCotisationsPerso: (json['tauxCotisationsPerso'] as num?)?.toInt(),
monSoldeEpargne: _toDouble(json['monSoldeEpargne']),
@@ -70,6 +75,7 @@ class MembreDashboardSyntheseModel {
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();

View File

@@ -1,10 +1,12 @@
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import '../../domain/entities/dashboard_entity.dart';
import '../../domain/entities/compte_adherent_entity.dart';
import '../../domain/repositories/dashboard_repository.dart';
import '../datasources/dashboard_remote_datasource.dart';
import '../models/dashboard_stats_model.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
@@ -19,6 +21,21 @@ class DashboardRepositoryImpl implements DashboardRepository {
required this.networkInfo,
});
@override
Future<Either<Failure, CompteAdherentEntity>> getCompteAdherent() async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
final model = await remoteDataSource.getCompteAdherent();
return Right(_mapCompteToEntity(model));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, DashboardEntity>> getDashboardData(
String organizationId,
@@ -31,9 +48,32 @@ class DashboardRepositoryImpl implements DashboardRepository {
// Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me)
final useMemberDashboard = organizationId.trim().isEmpty;
if (useMemberDashboard) {
final synthese = await remoteDataSource.getMemberDashboardData();
return Right(_mapMemberSyntheseToEntity(synthese, userId));
// Chargement parallèle de la synthèse et du compte adhérent unifié
final results = await Future.wait([
remoteDataSource.getMemberDashboardData(),
remoteDataSource.getCompteAdherent(),
]);
final synthese = results[0] as MembreDashboardSyntheseModel;
final compteModel = results[1] as CompteAdherentModel;
// Fallback : si les montants sont à zéro mais qu'il y a des cotisations,
// on complète avec /api/cotisations/mes-cotisations/synthese
Map<String, dynamic>? cotSynthese;
if (synthese.totalCotisationsPayeesToutTemps == 0 ||
synthese.tauxCotisationsPerso == null ||
(synthese.tauxCotisationsPerso ?? 0) == 0) {
cotSynthese = await remoteDataSource.getMesCotisationsSynthese();
}
return Right(_mapMemberSyntheseToEntity(
synthese,
userId,
cotSynthese: cotSynthese,
compteModel: compteModel,
));
}
final dashboardData = await remoteDataSource.getDashboardData(organizationId, userId);
return Right(_mapToEntity(dashboardData));
} on ServerException catch (e) {
@@ -43,24 +83,65 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
/// Construit une DashboardEntity à partir de la synthèse membre (même structure pour réutiliser l'UI).
DashboardEntity _mapMemberSyntheseToEntity(MembreDashboardSyntheseModel s, String userId) {
/// Construit une DashboardEntity à partir de la synthèse membre.
/// [cotSynthese] est optionnel : utilisé en fallback quand les montants du dashboard
/// membre sont à zéro (incohérence backend entre /api/dashboard/membre/me
/// et /api/cotisations/mes-cotisations/synthese).
DashboardEntity _mapMemberSyntheseToEntity(
MembreDashboardSyntheseModel s,
String userId, {
Map<String, dynamic>? cotSynthese,
CompteAdherentModel? compteModel,
}) {
final now = DateTime.now();
// Contribution Totale = cotisations payées tout temps ; MON SOLDE TOTAL = cotisations tout temps + épargne
final totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
// ------------------------------------------------------------------
// Montant des cotisations payées tout temps
// ------------------------------------------------------------------
double totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
if (totalCotisationsToutTemps == 0 && cotSynthese != null) {
// totalPayeAnnee = montant payé sur l'année en cours (meilleure approximation disponible)
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
if (totalPayeAnnee > 0) totalCotisationsToutTemps = totalPayeAnnee;
}
// ------------------------------------------------------------------
// MON SOLDE TOTAL = cotisations payées + épargne
// ------------------------------------------------------------------
final monSoldeTotal = totalCotisationsToutTemps + s.monSoldeEpargne;
// ------------------------------------------------------------------
// Taux d'engagement (en %)
// Priorité : tauxParticipationPerso > tauxCotisationsPerso > calculé depuis cotSynthese
// ------------------------------------------------------------------
int? tauxBrut = s.tauxParticipationPerso ?? s.tauxCotisationsPerso;
double engagementRate = (tauxBrut ?? 0) / 100.0;
if (engagementRate == 0 && cotSynthese != null) {
final montantDu = _toDouble(cotSynthese['montantDu']);
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
if (total > 0) engagementRate = totalPayeAnnee / total;
}
// ------------------------------------------------------------------
// Nombre de cotisations — utilize NEW nombreCotisationsTotal if available
// ------------------------------------------------------------------
final int nombreCotisations = s.nombreCotisationsTotal > 0
? s.nombreCotisationsTotal
: s.nombreCotisationsPayees;
final stats = DashboardStatsEntity(
totalMembers: 0,
activeMembers: 0,
totalEvents: 0,
upcomingEvents: s.evenementsAVenir,
totalContributions: s.nombreCotisationsPayees,
totalContributions: nombreCotisations,
totalContributionAmount: monSoldeTotal,
contributionsAmountOnly: totalCotisationsToutTemps,
pendingRequests: 0,
completedProjects: 0,
monthlyGrowth: s.evolutionEpargneNombre,
engagementRate: ((s.tauxParticipationPerso ?? s.tauxCotisationsPerso) ?? 0) / 100.0,
engagementRate: engagementRate,
lastUpdated: now,
totalOrganizations: null,
organizationTypeDistribution: null,
@@ -69,10 +150,20 @@ class DashboardRepositoryImpl implements DashboardRepository {
stats: stats,
recentActivities: const [],
upcomingEvents: const [],
userPreferences: <String, dynamic>{},
userPreferences: const <String, dynamic>{},
organizationId: '',
userId: userId,
monCompte: compteModel != null ? _mapCompteToEntity(compteModel) : null,
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
@override
@@ -142,6 +233,28 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
CompteAdherentEntity _mapCompteToEntity(CompteAdherentModel model) {
return CompteAdherentEntity(
numeroMembre: model.numeroMembre,
nomComplet: model.nomComplet,
organisationNom: model.organisationNom,
dateAdhesion: model.dateAdhesion != null ? DateTime.tryParse(model.dateAdhesion!) : null,
statutCompte: model.statutCompte,
soldeCotisations: model.soldeCotisations,
soldeEpargne: model.soldeEpargne,
soldeBloque: model.soldeBloque,
soldeTotalDisponible: model.soldeTotalDisponible,
encoursCreditTotal: model.encoursCreditTotal,
capaciteEmprunt: model.capaciteEmprunt,
nombreCotisationsPayees: model.nombreCotisationsPayees,
nombreCotisationsTotal: model.nombreCotisationsTotal,
nombreCotisationsEnRetard: model.nombreCotisationsEnRetard,
engagementRate: (model.tauxEngagement ?? 0) / 100.0,
nombreComptesEpargne: model.nombreComptesEpargne,
dateCalcul: DateTime.tryParse(model.dateCalcul) ?? DateTime.now(),
);
}
// Mappers
DashboardEntity _mapToEntity(DashboardDataModel model) {
return DashboardEntity(

View File

@@ -0,0 +1,89 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../presentation/bloc/finance_state.dart';
/// Repository pour les données financières (cotisations, synthèse).
/// Appelle les endpoints /api/cotisations/mes-cotisations/*.
@lazySingleton
class FinanceRepository {
final ApiClient _apiClient;
FinanceRepository(this._apiClient);
/// Synthèse des cotisations du membre connecté (GET /api/cotisations/mes-cotisations/synthese).
Future<FinanceSummary> getFinancialSummary() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/synthese');
final data = response.data as Map<String, dynamic>;
final totalPayeAnnee = (data['totalPayeAnnee'] is num)
? (data['totalPayeAnnee'] as num).toDouble()
: 0.0;
final montantDu = (data['montantDu'] is num)
? (data['montantDu'] as num).toDouble()
: 0.0;
final epargneBalance = (data['epargneBalance'] is num)
? (data['epargneBalance'] as num).toDouble()
: 0.0;
return FinanceSummary(
totalContributionsPaid: totalPayeAnnee,
totalContributionsPending: montantDu,
epargneBalance: epargneBalance,
);
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary échoué', error: e, stackTrace: st);
rethrow;
} catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary erreur inattendue', error: e, stackTrace: st);
rethrow;
}
}
/// Cotisations en attente du membre connecté (GET /api/cotisations/mes-cotisations/en-attente).
Future<List<FinanceTransaction>> getTransactions() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/en-attente');
final List<dynamic> data = response.data is List ? response.data as List : [];
return data
.map((json) => _transactionFromJson(json as Map<String, dynamic>))
.toList();
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getTransactions échoué', error: e, stackTrace: st);
if (e.response?.statusCode == 404) return [];
rethrow;
}
}
static FinanceTransaction _transactionFromJson(Map<String, dynamic> json) {
final id = json['id']?.toString() ?? '';
final ref = json['numeroReference']?.toString() ?? '';
final nomMembre = json['nomMembre']?.toString() ?? 'Cotisation';
final montantDu = (json['montantDu'] is num)
? (json['montantDu'] as num).toDouble()
: 0.0;
final statutLibelle = json['statutLibelle']?.toString() ?? 'En attente';
final dateEcheance = json['dateEcheance']?.toString();
final dateStr = dateEcheance != null
? _parseDateToDisplay(dateEcheance)
: '';
return FinanceTransaction(
id: id,
title: nomMembre.isNotEmpty ? nomMembre : 'Cotisation $ref',
date: dateStr,
amount: montantDu,
status: statutLibelle,
);
}
static String _parseDateToDisplay(String isoDate) {
try {
final d = DateTime.parse(isoDate);
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
} catch (e) {
AppLogger.warning('FinanceRepository: _parseDateToDisplay date invalide', tag: isoDate);
return isoDate;
}
}
}

View File

@@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/dashboard_stats_model.dart';
import '../cache/dashboard_cache_manager.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
/// Service de mode hors ligne avec synchronisation pour le Dashboard
class DashboardOfflineService {
@@ -14,7 +15,7 @@ class DashboardOfflineService {
static const String _offlineModeKey = 'dashboard_offline_mode';
final DashboardCacheManager _cacheManager;
final Dio _dio;
final ApiClient _apiClient;
final Connectivity _connectivity = Connectivity();
SharedPreferences? _prefs;
@@ -35,7 +36,7 @@ class DashboardOfflineService {
Stream<OfflineStatus> get statusStream => _statusController.stream;
Stream<SyncProgress> get syncStream => _syncController.stream;
DashboardOfflineService(this._cacheManager, this._dio);
DashboardOfflineService(this._cacheManager, this._apiClient);
/// Initialise le service hors ligne
Future<void> initialize() async {
@@ -216,14 +217,13 @@ class DashboardOfflineService {
final userId = action.data['userId'] as String?;
if (orgId == null || userId == null) return;
final response = await _dio.get('/api/dashboard/stats', queryParameters: {
final response = await _apiClient.get('/api/dashboard/stats', queryParameters: {
'organisationId': orgId,
});
if (response.statusCode == 200 && response.data != null) {
await _cacheManager.cacheDashboardData(
DashboardDataModel.fromJson(response.data as Map<String, dynamic>),
orgId,
userId,
await _cacheManager.setKey(
'dashboard_${orgId}_$userId',
response.data as Map<String, dynamic>,
);
}
}
@@ -234,7 +234,7 @@ class DashboardOfflineService {
final preferences = action.data['preferences'] as Map<String, dynamic>?;
if (userId == null || preferences == null) return;
await _dio.put('/api/membres/$userId/preferences', data: preferences);
await _apiClient.put('/api/membres/$userId/preferences', data: preferences);
}
/// Synchronise le marquage d'activité comme lue
@@ -242,7 +242,7 @@ class DashboardOfflineService {
final activityId = action.data['activityId'] as String?;
if (activityId == null) return;
await _dio.put('/api/notifications/$activityId/read');
await _apiClient.put('/api/notifications/$activityId/read');
}
/// Synchronise l'inscription à un événement
@@ -251,7 +251,7 @@ class DashboardOfflineService {
final membreId = action.data['membreId'] as String?;
if (eventId == null || membreId == null) return;
await _dio.post('/api/evenements/$eventId/inscription', data: {
await _apiClient.post('/api/evenements/$eventId/inscription', data: {
'membreId': membreId,
});
}
@@ -262,7 +262,7 @@ class DashboardOfflineService {
final params = action.data['params'] as Map<String, dynamic>?;
if (reportType == null) return;
await _dio.post('/api/export/$reportType', data: params ?? {});
await _apiClient.post('/api/export/$reportType', data: params ?? {});
}
/// Sauvegarde les actions en attente
@@ -315,7 +315,7 @@ class DashboardOfflineService {
}
/// Force une synchronisation manuelle
Future<void> forcSync() async {
Future<void> forceSync() async {
if (!_isOnline) {
throw Exception('Impossible de synchroniser hors ligne');
}
@@ -328,7 +328,8 @@ class DashboardOfflineService {
String organizationId,
String userId,
) async {
return await _cacheManager.getCachedDashboardData(organizationId, userId);
final m = _cacheManager.getKey<Map<String, dynamic>>('dashboard_${organizationId}_$userId');
return m != null ? DashboardDataModel.fromJson(m) : null;
}
/// Vérifie si des données sont disponibles hors ligne

View File

@@ -19,7 +19,8 @@ class DashboardPerformanceMonitor {
bool _isMonitoring = false;
DateTime _startTime = DateTime.now();
int _alertsGeneratedCount = 0;
// Seuils d'alerte configurables
final double _memoryThreshold = DashboardConfig.getAlertThreshold('memoryUsage');
final double _cpuThreshold = DashboardConfig.getAlertThreshold('cpuUsage');
@@ -147,18 +148,16 @@ class DashboardPerformanceMonitor {
}
}
/// Obtient la latence réseau
/// Obtient la latence réseau (hôte/port depuis DashboardConfig.apiBaseUrl).
Future<int> _getNetworkLatency() async {
try {
final uri = Uri.parse(DashboardConfig.apiBaseUrl);
final host = uri.host.isNotEmpty ? uri.host : 'localhost';
final port = uri.hasPort ? uri.port : 8085;
final stopwatch = Stopwatch()..start();
// Ping vers le serveur de l'API
final socket = await Socket.connect('localhost', 8080)
.timeout(const Duration(seconds: 5));
final socket = await Socket.connect(host, port).timeout(const Duration(seconds: 5));
stopwatch.stop();
await socket.close();
return stopwatch.elapsedMilliseconds;
} catch (e) {
return _simulateNetworkLatency();
@@ -228,6 +227,7 @@ class DashboardPerformanceMonitor {
void _checkAlerts(PerformanceMetrics metrics) {
// Alerte mémoire
if (metrics.memoryUsage > _memoryThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.memory,
severity: AlertSeverity.warning,
@@ -240,6 +240,7 @@ class DashboardPerformanceMonitor {
// Alerte CPU
if (metrics.cpuUsage > _cpuThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.cpu,
severity: AlertSeverity.warning,
@@ -252,6 +253,7 @@ class DashboardPerformanceMonitor {
// Alerte latence réseau
if (metrics.networkLatency > _networkLatencyThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.network,
severity: AlertSeverity.error,
@@ -264,6 +266,7 @@ class DashboardPerformanceMonitor {
// Alerte frame rate
if (metrics.frameRate < _frameRateThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.performance,
severity: AlertSeverity.warning,
@@ -298,8 +301,7 @@ class DashboardPerformanceMonitor {
if (_snapshots.isEmpty) {
return PerformanceStats.empty();
}
return PerformanceStats.fromSnapshots(_snapshots);
return PerformanceStats.fromSnapshots(_snapshots, alertsGenerated: _alertsGeneratedCount);
}
/// Méthodes de simulation pour le développement
@@ -508,7 +510,7 @@ class PerformanceStats {
);
}
factory PerformanceStats.fromSnapshots(List<PerformanceSnapshot> snapshots) {
factory PerformanceStats.fromSnapshots(List<PerformanceSnapshot> snapshots, {int alertsGenerated = 0}) {
if (snapshots.isEmpty) return PerformanceStats.empty();
final metrics = snapshots.map((s) => s.metrics).toList();
@@ -520,7 +522,7 @@ class PerformanceStats {
peakMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a > b ? a : b),
averageCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a + b) / metrics.length,
peakCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
alertsGenerated: 0, // À implémenter si nécessaire
alertsGenerated: alertsGenerated,
);
}
}