Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
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 '../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 ApiClient apiClient;
DashboardRemoteDataSourceImpl(this.apiClient);
@override
Future<DashboardDataModel> getDashboardData(String organizationId, String userId) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/data',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
},
);
if (response.statusCode == 200) {
return DashboardDataModel.fromJson(response.data);
} else {
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, 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 apiClient.get(
'/api/v1/dashboard/stats',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
},
);
if (response.statusCode == 200) {
return DashboardStatsModel.fromJson(response.data);
} else {
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, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<List<RecentActivityModel>> getRecentActivities(
String organizationId,
String userId, {
int limit = 10,
}) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/activities',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
'limit': limit,
},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['activities'] ?? [];
return data.map((json) => RecentActivityModel.fromJson(json)).toList();
} else {
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, st) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<List<UpcomingEventModel>> getUpcomingEvents(
String organizationId,
String userId, {
int limit = 5,
}) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/events/upcoming',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
'limit': limit,
},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['events'] ?? [];
return data.map((json) => UpcomingEventModel.fromJson(json)).toList();
} else {
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, 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

@@ -0,0 +1,222 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'dashboard_stats_model.g.dart';
/// Modèle pour les statistiques du dashboard
@JsonSerializable()
class DashboardStatsModel extends Equatable {
final int totalMembers;
final int activeMembers;
final int totalEvents;
final int upcomingEvents;
final int totalContributions;
final double totalContributionAmount;
final int pendingRequests;
final int completedProjects;
final double monthlyGrowth;
final double engagementRate;
final DateTime lastUpdated;
final int? totalOrganizations;
final Map<String, int>? organizationTypeDistribution;
const DashboardStatsModel({
required this.totalMembers,
required this.activeMembers,
required this.totalEvents,
required this.upcomingEvents,
required this.totalContributions,
required this.totalContributionAmount,
required this.pendingRequests,
required this.completedProjects,
required this.monthlyGrowth,
required this.engagementRate,
required this.lastUpdated,
this.totalOrganizations,
this.organizationTypeDistribution,
});
factory DashboardStatsModel.fromJson(Map<String, dynamic> json) =>
_$DashboardStatsModelFromJson(json);
Map<String, dynamic> toJson() => _$DashboardStatsModelToJson(this);
// Getters calculés
String get formattedContributionAmount {
return '${totalContributionAmount.toStringAsFixed(2)}';
}
bool get hasGrowth => monthlyGrowth > 0;
bool get isHighEngagement => engagementRate > 0.7;
double get activeMemberPercentage {
return totalMembers > 0 ? (activeMembers / totalMembers) : 0.0;
}
@override
List<Object?> get props => [
totalMembers,
activeMembers,
totalEvents,
upcomingEvents,
totalContributions,
totalContributionAmount,
pendingRequests,
completedProjects,
monthlyGrowth,
engagementRate,
lastUpdated,
totalOrganizations,
organizationTypeDistribution,
];
}
/// Modèle pour les activités récentes
@JsonSerializable()
class RecentActivityModel extends Equatable {
final String id;
final String type;
final String title;
final String description;
final String? userAvatar;
final String userName;
final DateTime timestamp;
final String? actionUrl;
final Map<String, dynamic>? metadata;
const RecentActivityModel({
required this.id,
required this.type,
required this.title,
required this.description,
this.userAvatar,
required this.userName,
required this.timestamp,
this.actionUrl,
this.metadata,
});
factory RecentActivityModel.fromJson(Map<String, dynamic> json) =>
_$RecentActivityModelFromJson(json);
Map<String, dynamic> toJson() => _$RecentActivityModelToJson(this);
// Getter calculé pour l'affichage du temps
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 0) {
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inHours > 0) {
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
} else if (difference.inMinutes > 0) {
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
} else {
return 'à l\'instant';
}
}
@override
List<Object?> get props => [
id,
type,
title,
description,
userAvatar,
userName,
timestamp,
actionUrl,
metadata,
];
}
/// Modèle pour les événements à venir
@JsonSerializable()
class UpcomingEventModel extends Equatable {
final String id;
final String title;
final String description;
final DateTime startDate;
final DateTime? endDate;
final String location;
final int maxParticipants;
final int currentParticipants;
final String status;
final String? imageUrl;
final List<String> tags;
const UpcomingEventModel({
required this.id,
required this.title,
required this.description,
required this.startDate,
this.endDate,
required this.location,
required this.maxParticipants,
required this.currentParticipants,
required this.status,
this.imageUrl,
required this.tags,
});
factory UpcomingEventModel.fromJson(Map<String, dynamic> json) =>
_$UpcomingEventModelFromJson(json);
Map<String, dynamic> toJson() => _$UpcomingEventModelToJson(this);
bool get isAlmostFull => currentParticipants >= (maxParticipants * 0.8);
bool get isFull => currentParticipants >= maxParticipants;
double get fillPercentage => maxParticipants > 0 ? currentParticipants / maxParticipants : 0.0;
@override
List<Object?> get props => [
id,
title,
description,
startDate,
endDate,
location,
maxParticipants,
currentParticipants,
status,
imageUrl,
tags,
];
}
/// Modèle pour les données du dashboard complet
@JsonSerializable()
class DashboardDataModel extends Equatable {
final DashboardStatsModel stats;
final List<RecentActivityModel> recentActivities;
final List<UpcomingEventModel> upcomingEvents;
final Map<String, dynamic> userPreferences;
final String organizationId;
final String userId;
const DashboardDataModel({
required this.stats,
required this.recentActivities,
required this.upcomingEvents,
required this.userPreferences,
required this.organizationId,
required this.userId,
});
factory DashboardDataModel.fromJson(Map<String, dynamic> json) =>
_$DashboardDataModelFromJson(json);
Map<String, dynamic> toJson() => _$DashboardDataModelToJson(this);
@override
List<Object?> get props => [
stats,
recentActivities,
upcomingEvents,
userPreferences,
organizationId,
userId,
];
}

View File

@@ -0,0 +1,130 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dashboard_stats_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DashboardStatsModel _$DashboardStatsModelFromJson(Map<String, dynamic> json) =>
DashboardStatsModel(
totalMembers: (json['totalMembers'] as num).toInt(),
activeMembers: (json['activeMembers'] as num).toInt(),
totalEvents: (json['totalEvents'] as num).toInt(),
upcomingEvents: (json['upcomingEvents'] as num).toInt(),
totalContributions: (json['totalContributions'] as num).toInt(),
totalContributionAmount:
(json['totalContributionAmount'] as num).toDouble(),
pendingRequests: (json['pendingRequests'] as num).toInt(),
completedProjects: (json['completedProjects'] as num).toInt(),
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(
DashboardStatsModel instance) =>
<String, dynamic>{
'totalMembers': instance.totalMembers,
'activeMembers': instance.activeMembers,
'totalEvents': instance.totalEvents,
'upcomingEvents': instance.upcomingEvents,
'totalContributions': instance.totalContributions,
'totalContributionAmount': instance.totalContributionAmount,
'pendingRequests': instance.pendingRequests,
'completedProjects': instance.completedProjects,
'monthlyGrowth': instance.monthlyGrowth,
'engagementRate': instance.engagementRate,
'lastUpdated': instance.lastUpdated.toIso8601String(),
'totalOrganizations': instance.totalOrganizations,
'organizationTypeDistribution': instance.organizationTypeDistribution,
};
RecentActivityModel _$RecentActivityModelFromJson(Map<String, dynamic> json) =>
RecentActivityModel(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
description: json['description'] as String,
userAvatar: json['userAvatar'] as String?,
userName: json['userName'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
actionUrl: json['actionUrl'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$RecentActivityModelToJson(
RecentActivityModel instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'title': instance.title,
'description': instance.description,
'userAvatar': instance.userAvatar,
'userName': instance.userName,
'timestamp': instance.timestamp.toIso8601String(),
'actionUrl': instance.actionUrl,
'metadata': instance.metadata,
};
UpcomingEventModel _$UpcomingEventModelFromJson(Map<String, dynamic> json) =>
UpcomingEventModel(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
startDate: DateTime.parse(json['startDate'] as String),
endDate: json['endDate'] == null
? null
: DateTime.parse(json['endDate'] as String),
location: json['location'] as String,
maxParticipants: (json['maxParticipants'] as num).toInt(),
currentParticipants: (json['currentParticipants'] as num).toInt(),
status: json['status'] as String,
imageUrl: json['imageUrl'] as String?,
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
);
Map<String, dynamic> _$UpcomingEventModelToJson(UpcomingEventModel instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'description': instance.description,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate?.toIso8601String(),
'location': instance.location,
'maxParticipants': instance.maxParticipants,
'currentParticipants': instance.currentParticipants,
'status': instance.status,
'imageUrl': instance.imageUrl,
'tags': instance.tags,
};
DashboardDataModel _$DashboardDataModelFromJson(Map<String, dynamic> json) =>
DashboardDataModel(
stats:
DashboardStatsModel.fromJson(json['stats'] as Map<String, dynamic>),
recentActivities: (json['recentActivities'] as List<dynamic>)
.map((e) => RecentActivityModel.fromJson(e as Map<String, dynamic>))
.toList(),
upcomingEvents: (json['upcomingEvents'] as List<dynamic>)
.map((e) => UpcomingEventModel.fromJson(e as Map<String, dynamic>))
.toList(),
userPreferences: json['userPreferences'] as Map<String, dynamic>,
organizationId: json['organizationId'] as String,
userId: json['userId'] as String,
);
Map<String, dynamic> _$DashboardDataModelToJson(DashboardDataModel instance) =>
<String, dynamic>{
'stats': instance.stats,
'recentActivities': instance.recentActivities,
'upcomingEvents': instance.upcomingEvents,
'userPreferences': instance.userPreferences,
'organizationId': instance.organizationId,
'userId': instance.userId,
};

View File

@@ -0,0 +1,85 @@
/// Modèle pour la réponse GET /api/dashboard/membre/me (backend MembreDashboardSyntheseResponse).
/// Utilisé quand l'utilisateur est un membre sans organisationId (dashboard personnel).
class MembreDashboardSyntheseModel {
final String prenom;
final String nom;
final String? dateInscription; // ISO date string
final double mesCotisationsPaiement;
/// Total des cotisations payées sur l'année (pour dashboard).
final double totalCotisationsPayeesAnnee;
/// Total des cotisations payées tout temps (pour carte « Contribution Totale »).
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;
final double evolutionEpargneNombre;
final String evolutionEpargne;
final int objectifEpargne;
final int mesEvenementsInscrits;
final int evenementsAVenir;
final int? tauxParticipationPerso;
final int mesDemandesAide;
final int aidesEnCours;
final int? tauxAidesApprouvees;
const MembreDashboardSyntheseModel({
required this.prenom,
required this.nom,
this.dateInscription,
this.mesCotisationsPaiement = 0,
this.totalCotisationsPayeesAnnee = 0,
this.totalCotisationsPayeesToutTemps = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.statutCotisations = 'À jour',
this.tauxCotisationsPerso,
this.monSoldeEpargne = 0,
this.evolutionEpargneNombre = 0,
this.evolutionEpargne = '+0%',
this.objectifEpargne = 0,
this.mesEvenementsInscrits = 0,
this.evenementsAVenir = 0,
this.tauxParticipationPerso,
this.mesDemandesAide = 0,
this.aidesEnCours = 0,
this.tauxAidesApprouvees,
});
factory MembreDashboardSyntheseModel.fromJson(Map<String, dynamic> json) {
return MembreDashboardSyntheseModel(
prenom: json['prenom'] as String? ?? '',
nom: json['nom'] as String? ?? '',
dateInscription: json['dateInscription'] as String?,
mesCotisationsPaiement: _toDouble(json['mesCotisationsPaiement']),
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']),
evolutionEpargneNombre: _toDouble(json['evolutionEpargneNombre']),
evolutionEpargne: json['evolutionEpargne'] as String? ?? '+0%',
objectifEpargne: (json['objectifEpargne'] as num?)?.toInt() ?? 0,
mesEvenementsInscrits: (json['mesEvenementsInscrits'] as num?)?.toInt() ?? 0,
evenementsAVenir: (json['evenementsAVenir'] as num?)?.toInt() ?? 0,
tauxParticipationPerso: (json['tauxParticipationPerso'] as num?)?.toInt(),
mesDemandesAide: (json['mesDemandesAide'] as num?)?.toInt() ?? 0,
aidesEnCours: (json['aidesEnCours'] as num?)?.toInt() ?? 0,
tauxAidesApprouvees: (json['tauxAidesApprouvees'] as num?)?.toInt(),
);
}
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

@@ -0,0 +1,318 @@
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';
@LazySingleton(as: DashboardRepository)
class DashboardRepositoryImpl implements DashboardRepository {
final DashboardRemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
DashboardRepositoryImpl({
required this.remoteDataSource,
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,
String userId,
) async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
// Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me)
final useMemberDashboard = organizationId.trim().isEmpty;
if (useMemberDashboard) {
// 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) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
/// 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();
// ------------------------------------------------------------------
// 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: nombreCotisations,
totalContributionAmount: monSoldeTotal,
contributionsAmountOnly: totalCotisationsToutTemps,
pendingRequests: 0,
completedProjects: 0,
monthlyGrowth: s.evolutionEpargneNombre,
engagementRate: engagementRate,
lastUpdated: now,
totalOrganizations: null,
organizationTypeDistribution: null,
);
return DashboardEntity(
stats: stats,
recentActivities: const [],
upcomingEvents: const [],
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
Future<Either<Failure, DashboardStatsEntity>> getDashboardStats(
String organizationId,
String userId,
) async {
if (await networkInfo.isConnected) {
try {
final stats = await remoteDataSource.getDashboardStats(organizationId, userId);
return Right(_mapStatsToEntity(stats));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, List<RecentActivityEntity>>> getRecentActivities(
String organizationId,
String userId, {
int limit = 10,
}) async {
if (await networkInfo.isConnected) {
try {
final activities = await remoteDataSource.getRecentActivities(
organizationId,
userId,
limit: limit,
);
return Right(activities.map(_mapActivityToEntity).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, List<UpcomingEventEntity>>> getUpcomingEvents(
String organizationId,
String userId, {
int limit = 5,
}) async {
if (await networkInfo.isConnected) {
try {
final events = await remoteDataSource.getUpcomingEvents(
organizationId,
userId,
limit: limit,
);
return Right(events.map(_mapEventToEntity).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
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(
stats: _mapStatsToEntity(model.stats),
recentActivities: model.recentActivities.map(_mapActivityToEntity).toList(),
upcomingEvents: model.upcomingEvents.map(_mapEventToEntity).toList(),
userPreferences: model.userPreferences,
organizationId: model.organizationId,
userId: model.userId,
);
}
DashboardStatsEntity _mapStatsToEntity(DashboardStatsModel model) {
return DashboardStatsEntity(
totalMembers: model.totalMembers,
activeMembers: model.activeMembers,
totalEvents: model.totalEvents,
upcomingEvents: model.upcomingEvents,
totalContributions: model.totalContributions,
totalContributionAmount: model.totalContributionAmount,
contributionsAmountOnly: null,
pendingRequests: model.pendingRequests,
completedProjects: model.completedProjects,
monthlyGrowth: model.monthlyGrowth,
engagementRate: model.engagementRate,
lastUpdated: model.lastUpdated,
totalOrganizations: null,
organizationTypeDistribution: null,
);
}
RecentActivityEntity _mapActivityToEntity(RecentActivityModel model) {
return RecentActivityEntity(
id: model.id,
type: model.type,
title: model.title,
description: model.description,
userAvatar: model.userAvatar,
userName: model.userName,
timestamp: model.timestamp,
actionUrl: model.actionUrl,
metadata: model.metadata,
);
}
UpcomingEventEntity _mapEventToEntity(UpcomingEventModel model) {
return UpcomingEventEntity(
id: model.id,
title: model.title,
description: model.description,
startDate: model.startDate,
endDate: model.endDate,
location: model.location,
maxParticipants: model.maxParticipants,
currentParticipants: model.currentParticipants,
status: model.status,
imageUrl: model.imageUrl,
tags: model.tags,
);
}
}

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

@@ -0,0 +1,507 @@
import 'dart:io';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:flutter/services.dart';
import '../models/dashboard_stats_model.dart';
/// Service d'export de rapports PDF pour le Dashboard
class DashboardExportService {
static const String _reportsFolder = 'UnionFlow_Reports';
/// Exporte un rapport complet du dashboard en PDF
Future<String> exportDashboardReport({
required DashboardDataModel dashboardData,
required String organizationName,
required String reportTitle,
bool includeCharts = true,
bool includeActivities = true,
bool includeEvents = true,
}) async {
final pdf = pw.Document();
// Charger les polices personnalisées si disponibles
final font = await _loadFont();
// Page 1: Couverture et statistiques principales
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildHeader(organizationName, reportTitle),
pw.SizedBox(height: 20),
_buildStatsSection(dashboardData.stats),
pw.SizedBox(height: 20),
_buildSummarySection(dashboardData),
],
),
);
// Page 2: Activités récentes (si incluses)
if (includeActivities && dashboardData.recentActivities.isNotEmpty) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Activités Récentes'),
pw.SizedBox(height: 10),
_buildActivitiesSection(dashboardData.recentActivities),
],
),
);
}
// Page 3: Événements à venir (si inclus)
if (includeEvents && dashboardData.upcomingEvents.isNotEmpty) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Événements à Venir'),
pw.SizedBox(height: 10),
_buildEventsSection(dashboardData.upcomingEvents),
],
),
);
}
// Page 4: Graphiques et analyses (si inclus)
if (includeCharts) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Analyses et Tendances'),
pw.SizedBox(height: 10),
_buildAnalyticsSection(dashboardData.stats),
],
),
);
}
// Sauvegarder le PDF
final fileName = _generateFileName(reportTitle);
final filePath = await _savePdf(pdf, fileName);
return filePath;
}
/// Exporte uniquement les statistiques en PDF
Future<String> exportStatsReport({
required DashboardStatsModel stats,
required String organizationName,
String? customTitle,
}) async {
final pdf = pw.Document();
final font = await _loadFont();
final title = customTitle ?? 'Rapport Statistiques - ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year}';
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildHeader(organizationName, title),
pw.SizedBox(height: 30),
_buildStatsSection(stats),
pw.SizedBox(height: 30),
_buildStatsAnalysis(stats),
],
),
);
final fileName = _generateFileName('Stats_${DateTime.now().millisecondsSinceEpoch}');
final filePath = await _savePdf(pdf, fileName);
return filePath;
}
/// Charge une police personnalisée
Future<pw.Font?> _loadFont() async {
try {
final fontData = await rootBundle.load('assets/fonts/Inter-Regular.ttf');
return pw.Font.ttf(fontData);
} catch (e) {
// Police par défaut si la police personnalisée n'est pas disponible
return null;
}
}
/// Crée le thème PDF
pw.ThemeData _createTheme(pw.Font? font) {
return pw.ThemeData.withFont(
base: font ?? pw.Font.helvetica(),
bold: font ?? pw.Font.helveticaBold(),
);
}
/// Construit l'en-tête du rapport
pw.Widget _buildHeader(String organizationName, String reportTitle) {
return pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(20),
decoration: pw.BoxDecoration(
gradient: pw.LinearGradient(
colors: [
PdfColor.fromHex('#4169E1'), // Bleu Roi
PdfColor.fromHex('#008B8B'), // Bleu Pétrole
],
),
borderRadius: pw.BorderRadius.circular(10),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
organizationName,
style: pw.TextStyle(
fontSize: 24,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
),
pw.SizedBox(height: 5),
pw.Text(
reportTitle,
style: const pw.TextStyle(
fontSize: 16,
color: PdfColors.white,
),
),
pw.SizedBox(height: 10),
pw.Text(
'Généré le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}',
style: const pw.TextStyle(
fontSize: 12,
color: PdfColors.white,
),
),
],
),
);
}
/// Construit la section des statistiques
pw.Widget _buildStatsSection(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Statistiques Principales'),
pw.SizedBox(height: 15),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Membres Total', stats.totalMembers.toString(), PdfColor.fromHex('#4169E1')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Membres Actifs', stats.activeMembers.toString(), PdfColor.fromHex('#10B981')),
),
],
),
pw.SizedBox(height: 10),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Événements', stats.totalEvents.toString(), PdfColor.fromHex('#008B8B')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Contributions', stats.formattedContributionAmount, PdfColor.fromHex('#F59E0B')),
),
],
),
pw.SizedBox(height: 10),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Croissance', '${stats.monthlyGrowth.toStringAsFixed(1)}%',
stats.hasGrowth ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#EF4444')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Engagement', '${(stats.engagementRate * 100).toStringAsFixed(1)}%',
stats.isHighEngagement ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#F59E0B')),
),
],
),
],
);
}
/// Construit une carte de statistique
pw.Widget _buildStatCard(String title, String value, PdfColor color) {
return pw.Container(
padding: const pw.EdgeInsets.all(15),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: color, width: 2),
borderRadius: pw.BorderRadius.circular(8),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
title,
style: const pw.TextStyle(
fontSize: 12,
color: PdfColors.grey700,
),
),
pw.SizedBox(height: 5),
pw.Text(
value,
style: pw.TextStyle(
fontSize: 20,
fontWeight: pw.FontWeight.bold,
color: color,
),
),
],
),
);
}
/// Construit un titre de section
pw.Widget _buildSectionTitle(String title) {
return pw.Text(
title,
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('#1F2937'),
),
);
}
/// Construit la section de résumé
pw.Widget _buildSummarySection(DashboardDataModel data) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Résumé Exécutif'),
pw.SizedBox(height: 10),
pw.Text(
'Ce rapport présente un aperçu complet de l\'activité de l\'organisation. '
'Avec ${data.stats.totalMembers} membres dont ${data.stats.activeMembers} actifs '
'(${data.stats.activeMemberPercentage.toStringAsFixed(1)}%), l\'organisation maintient '
'un niveau d\'engagement de ${(data.stats.engagementRate * 100).toStringAsFixed(1)}%.',
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
),
pw.SizedBox(height: 10),
pw.Text(
'La croissance mensuelle de ${data.stats.monthlyGrowth.toStringAsFixed(1)}% '
'${data.stats.hasGrowth ? 'indique une tendance positive' : 'nécessite une attention particulière'}. '
'Les contributions totales s\'élèvent à ${data.stats.formattedContributionAmount} XOF.',
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
),
],
);
}
/// Construit la section des activités
pw.Widget _buildActivitiesSection(List<RecentActivityModel> activities) {
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
children: [
// En-tête
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
children: [
_buildTableHeader('Type'),
_buildTableHeader('Description'),
_buildTableHeader('Utilisateur'),
_buildTableHeader('Date'),
],
),
// Données
...activities.take(10).map((activity) => pw.TableRow(
children: [
_buildTableCell(activity.type),
_buildTableCell(activity.title),
_buildTableCell(activity.userName),
_buildTableCell(activity.timeAgo),
],
)),
],
);
}
/// Construit la section des événements
pw.Widget _buildEventsSection(List<UpcomingEventModel> events) {
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
children: [
// En-tête
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
children: [
_buildTableHeader('Événement'),
_buildTableHeader('Date'),
_buildTableHeader('Lieu'),
_buildTableHeader('Participants'),
],
),
// Données
...events.take(10).map((event) => pw.TableRow(
children: [
_buildTableCell(event.title),
_buildTableCell('${event.startDate.day}/${event.startDate.month}'),
_buildTableCell(event.location),
_buildTableCell('${event.currentParticipants}/${event.maxParticipants}'),
],
)),
],
);
}
/// Construit l'en-tête de tableau
pw.Widget _buildTableHeader(String text) {
return pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
text,
style: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
fontSize: 10,
),
),
);
}
/// Construit une cellule de tableau
pw.Widget _buildTableCell(String text) {
return pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
text,
style: const pw.TextStyle(fontSize: 9),
),
);
}
/// Construit la section d'analyse des statistiques
pw.Widget _buildStatsAnalysis(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Analyse des Performances'),
pw.SizedBox(height: 10),
_buildAnalysisPoint('Taux d\'activité des membres',
'${stats.activeMemberPercentage.toStringAsFixed(1)}%',
stats.activeMemberPercentage > 70 ? 'Excellent' : 'À améliorer'),
_buildAnalysisPoint('Croissance mensuelle',
'${stats.monthlyGrowth.toStringAsFixed(1)}%',
stats.hasGrowth ? 'Positive' : 'Négative'),
_buildAnalysisPoint('Niveau d\'engagement',
'${(stats.engagementRate * 100).toStringAsFixed(1)}%',
stats.isHighEngagement ? 'Élevé' : 'Modéré'),
],
);
}
/// Construit un point d'analyse
pw.Widget _buildAnalysisPoint(String metric, String value, String assessment) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 5),
child: pw.Row(
children: [
pw.Expanded(flex: 2, child: pw.Text(metric, style: const pw.TextStyle(fontSize: 11))),
pw.Expanded(flex: 1, child: pw.Text(value, style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold))),
pw.Expanded(flex: 1, child: pw.Text(assessment, style: const pw.TextStyle(fontSize: 11))),
],
),
);
}
/// Construit la section d'analytics
pw.Widget _buildAnalyticsSection(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Tendances et Projections', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 15),
pw.Text('Basé sur les données actuelles, voici les principales tendances observées:', style: const pw.TextStyle(fontSize: 11)),
pw.SizedBox(height: 10),
pw.Bullet(text: 'Évolution du nombre de membres: ${stats.hasGrowth ? 'Croissance' : 'Déclin'} de ${stats.monthlyGrowth.abs().toStringAsFixed(1)}% ce mois'),
pw.Bullet(text: 'Participation aux événements: ${stats.upcomingEvents} événements programmés'),
pw.Bullet(text: 'Volume des contributions: ${stats.formattedContributionAmount} XOF collectés'),
pw.Bullet(text: 'Demandes en attente: ${stats.pendingRequests} nécessitent un traitement'),
],
);
}
/// Génère un nom de fichier unique
String _generateFileName(String baseName) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final cleanName = baseName.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
return '${cleanName}_$timestamp.pdf';
}
/// Sauvegarde le PDF et retourne le chemin
Future<String> _savePdf(pw.Document pdf, String fileName) async {
final directory = await getApplicationDocumentsDirectory();
final reportsDir = Directory('${directory.path}/$_reportsFolder');
if (!await reportsDir.exists()) {
await reportsDir.create(recursive: true);
}
final file = File('${reportsDir.path}/$fileName');
await file.writeAsBytes(await pdf.save());
return file.path;
}
/// Partage un rapport PDF
Future<void> shareReport(String filePath, {String? subject}) async {
await Share.shareXFiles(
[XFile(filePath)],
subject: subject ?? 'Rapport Dashboard UnionFlow',
text: 'Rapport généré par l\'application UnionFlow',
);
}
/// Obtient la liste des rapports sauvegardés
Future<List<File>> getSavedReports() async {
final directory = await getApplicationDocumentsDirectory();
final reportsDir = Directory('${directory.path}/$_reportsFolder');
if (!await reportsDir.exists()) {
return [];
}
final files = await reportsDir.list().where((entity) =>
entity is File && entity.path.endsWith('.pdf')).cast<File>().toList();
// Trier par date de modification (plus récent en premier)
files.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
return files;
}
/// Supprime un rapport
Future<void> deleteReport(String filePath) async {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
}
/// Supprime tous les rapports anciens (plus de 30 jours)
Future<void> cleanupOldReports() async {
final reports = await getSavedReports();
final cutoffDate = DateTime.now().subtract(const Duration(days: 30));
for (final report in reports) {
final lastModified = await report.lastModified();
if (lastModified.isBefore(cutoffDate)) {
await report.delete();
}
}
}
}

View File

@@ -0,0 +1,392 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/dashboard_stats_model.dart';
import '../../config/dashboard_config.dart';
import '../../../../core/config/environment.dart';
/// Service de notifications temps réel pour le Dashboard
class DashboardNotificationService {
static String get _wsEndpoint => AppConfig.wsDashboardUrl;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
bool _isConnected = false;
bool _shouldReconnect = true;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 5);
static const Duration _heartbeatInterval = Duration(seconds: 30);
// Streams pour les différents types de notifications
final StreamController<DashboardStatsModel> _statsController =
StreamController<DashboardStatsModel>.broadcast();
final StreamController<RecentActivityModel> _activityController =
StreamController<RecentActivityModel>.broadcast();
final StreamController<UpcomingEventModel> _eventController =
StreamController<UpcomingEventModel>.broadcast();
final StreamController<DashboardNotification> _notificationController =
StreamController<DashboardNotification>.broadcast();
final StreamController<ConnectionStatus> _connectionController =
StreamController<ConnectionStatus>.broadcast();
// Getters pour les streams
Stream<DashboardStatsModel> get statsStream => _statsController.stream;
Stream<RecentActivityModel> get activityStream => _activityController.stream;
Stream<UpcomingEventModel> get eventStream => _eventController.stream;
Stream<DashboardNotification> get notificationStream => _notificationController.stream;
Stream<ConnectionStatus> get connectionStream => _connectionController.stream;
/// Initialise le service de notifications
Future<void> initialize(String organizationId, String userId) async {
if (!DashboardConfig.enableNotifications) {
debugPrint('📱 Notifications désactivées dans la configuration');
return;
}
debugPrint('📱 Initialisation du service de notifications...');
await _connect(organizationId, userId);
}
/// Établit la connexion WebSocket
Future<void> _connect(String organizationId, String userId) async {
if (_isConnected) return;
try {
final uri = Uri.parse('$_wsEndpoint?orgId=$organizationId&userId=$userId');
_channel = WebSocketChannel.connect(uri);
debugPrint('📱 Connexion WebSocket en cours...');
_connectionController.add(ConnectionStatus.connecting);
// Écouter les messages
_subscription = _channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
);
_isConnected = true;
_reconnectAttempts = 0;
_connectionController.add(ConnectionStatus.connected);
// Démarrer le heartbeat
_startHeartbeat();
debugPrint('✅ Connexion WebSocket établie');
} catch (e) {
debugPrint('❌ Erreur de connexion WebSocket: $e');
_connectionController.add(ConnectionStatus.error);
_scheduleReconnect(organizationId, userId);
}
}
/// Gère les messages reçus
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String);
final type = data['type'] as String?;
final payload = data['payload'];
debugPrint('📨 Message reçu: $type');
switch (type) {
case 'stats_update':
final stats = DashboardStatsModel.fromJson(payload);
_statsController.add(stats);
break;
case 'new_activity':
final activity = RecentActivityModel.fromJson(payload);
_activityController.add(activity);
break;
case 'event_update':
final event = UpcomingEventModel.fromJson(payload);
_eventController.add(event);
break;
case 'notification':
final notification = DashboardNotification.fromJson(payload);
_notificationController.add(notification);
break;
case 'pong':
// Réponse au heartbeat
debugPrint('💓 Heartbeat reçu');
break;
default:
debugPrint('⚠️ Type de message inconnu: $type');
}
} catch (e) {
debugPrint('❌ Erreur de parsing du message: $e');
}
}
/// Gère les erreurs de connexion
void _handleError(error) {
debugPrint('❌ Erreur WebSocket: $error');
_isConnected = false;
_connectionController.add(ConnectionStatus.error);
}
/// Gère la déconnexion
void _handleDisconnection() {
debugPrint('🔌 Connexion WebSocket fermée');
_isConnected = false;
_connectionController.add(ConnectionStatus.disconnected);
if (_shouldReconnect) {
// Programmer une reconnexion
_scheduleReconnect('', ''); // Les IDs seront récupérés du contexte
}
}
/// Programme une tentative de reconnexion
void _scheduleReconnect(String organizationId, String userId) {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('❌ Nombre maximum de tentatives de reconnexion atteint');
_connectionController.add(ConnectionStatus.failed);
return;
}
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 Reconnexion programmée dans ${delay.inSeconds}s (tentative $_reconnectAttempts)');
_reconnectTimer = Timer(delay, () {
if (_shouldReconnect) {
_connect(organizationId, userId);
}
});
}
/// Démarre le heartbeat
void _startHeartbeat() {
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (timer) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'ping',
'timestamp': DateTime.now().toIso8601String(),
}));
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi du heartbeat: $e');
}
}
});
}
/// Envoie une demande de rafraîchissement
void requestRefresh(String organizationId, String userId) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'refresh_request',
'payload': {
'organizationId': organizationId,
'userId': userId,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📤 Demande de rafraîchissement envoyée');
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi de la demande: $e');
}
}
}
/// S'abonne aux notifications pour un type spécifique
void subscribeToNotifications(List<String> notificationTypes) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'subscribe',
'payload': {
'notificationTypes': notificationTypes,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📋 Abonnement aux notifications: $notificationTypes');
} catch (e) {
debugPrint('❌ Erreur lors de l\'abonnement: $e');
}
}
}
/// Se désabonne des notifications
void unsubscribeFromNotifications(List<String> notificationTypes) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'unsubscribe',
'payload': {
'notificationTypes': notificationTypes,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📋 Désabonnement des notifications: $notificationTypes');
} catch (e) {
debugPrint('❌ Erreur lors du désabonnement: $e');
}
}
}
/// Obtient le statut de la connexion
bool get isConnected => _isConnected;
/// Obtient le nombre de tentatives de reconnexion
int get reconnectAttempts => _reconnectAttempts;
/// Force une reconnexion
Future<void> reconnect(String organizationId, String userId) async {
await disconnect();
_reconnectAttempts = 0;
await _connect(organizationId, userId);
}
/// Déconnecte le service
Future<void> disconnect() async {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
if (_channel != null) {
await _channel!.sink.close();
_channel = null;
}
await _subscription?.cancel();
_subscription = null;
_isConnected = false;
_connectionController.add(ConnectionStatus.disconnected);
debugPrint('🔌 Service de notifications déconnecté');
}
/// Libère les ressources
void dispose() {
disconnect();
_statsController.close();
_activityController.close();
_eventController.close();
_notificationController.close();
_connectionController.close();
}
}
/// Statut de la connexion
enum ConnectionStatus {
disconnected,
connecting,
connected,
error,
failed,
}
/// Notification du dashboard
class DashboardNotification {
final String id;
final String type;
final String title;
final String message;
final NotificationPriority priority;
final DateTime timestamp;
final Map<String, dynamic>? data;
final String? actionUrl;
final bool isRead;
const DashboardNotification({
required this.id,
required this.type,
required this.title,
required this.message,
required this.priority,
required this.timestamp,
this.data,
this.actionUrl,
this.isRead = false,
});
factory DashboardNotification.fromJson(Map<String, dynamic> json) {
return DashboardNotification(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
message: json['message'] as String,
priority: NotificationPriority.values.firstWhere(
(p) => p.name == json['priority'],
orElse: () => NotificationPriority.normal,
),
timestamp: DateTime.parse(json['timestamp'] as String),
data: json['data'] as Map<String, dynamic>?,
actionUrl: json['actionUrl'] as String?,
isRead: json['isRead'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'title': title,
'message': message,
'priority': priority.name,
'timestamp': timestamp.toIso8601String(),
'data': data,
'actionUrl': actionUrl,
'isRead': isRead,
};
}
/// Obtient l'icône pour le type de notification
String get icon {
switch (type) {
case 'new_member':
return '👤';
case 'new_event':
return '📅';
case 'contribution':
return '💰';
case 'urgent':
return '🚨';
case 'system':
return '⚙️';
default:
return '📢';
}
}
/// Obtient la couleur pour la priorité
String get priorityColor {
switch (priority) {
case NotificationPriority.low:
return '#6B7280';
case NotificationPriority.normal:
return '#3B82F6';
case NotificationPriority.high:
return '#F59E0B';
case NotificationPriority.urgent:
return '#EF4444';
}
}
}
/// Priorité des notifications
enum NotificationPriority {
low,
normal,
high,
urgent,
}

View File

@@ -0,0 +1,499 @@
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 '../../../../core/storage/dashboard_cache_manager.dart';
/// Service de mode hors ligne avec synchronisation pour le Dashboard
class DashboardOfflineService {
static const String _offlineQueueKey = 'dashboard_offline_queue';
static const String _lastSyncKey = 'dashboard_last_sync';
static const String _offlineModeKey = 'dashboard_offline_mode';
final DashboardCacheManager _cacheManager;
final ApiClient _apiClient;
final Connectivity _connectivity = Connectivity();
SharedPreferences? _prefs;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Timer? _syncTimer;
final StreamController<OfflineStatus> _statusController =
StreamController<OfflineStatus>.broadcast();
final StreamController<SyncProgress> _syncController =
StreamController<SyncProgress>.broadcast();
final List<OfflineAction> _pendingActions = [];
bool _isOnline = true;
bool _isSyncing = false;
DateTime? _lastSyncTime;
// Streams publics
Stream<OfflineStatus> get statusStream => _statusController.stream;
Stream<SyncProgress> get syncStream => _syncController.stream;
DashboardOfflineService(this._cacheManager, this._apiClient);
/// Initialise le service hors ligne
Future<void> initialize() async {
debugPrint('📱 Initialisation du service hors ligne...');
_prefs = await SharedPreferences.getInstance();
// Charger les actions en attente
await _loadPendingActions();
// Charger la dernière synchronisation
_loadLastSyncTime();
// Vérifier la connectivité initiale
final connectivityResult = await _connectivity.checkConnectivity();
_updateConnectivityStatus(connectivityResult);
// Écouter les changements de connectivité
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
(List<ConnectivityResult> results) => _updateConnectivityStatus(results),
);
// Démarrer la synchronisation automatique
_startAutoSync();
debugPrint('✅ Service hors ligne initialisé');
}
/// Met à jour le statut de connectivité
void _updateConnectivityStatus(dynamic result) {
final wasOnline = _isOnline;
if (result is List<ConnectivityResult>) {
_isOnline = result.any((r) => r != ConnectivityResult.none);
} else if (result is ConnectivityResult) {
_isOnline = result != ConnectivityResult.none;
} else {
_isOnline = false;
}
debugPrint('🌐 Connectivité: ${_isOnline ? 'En ligne' : 'Hors ligne'}');
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
// Si on revient en ligne, synchroniser
if (!wasOnline && _isOnline && _pendingActions.isNotEmpty) {
_syncPendingActions();
}
}
/// Démarre la synchronisation automatique
void _startAutoSync() {
_syncTimer = Timer.periodic(
const Duration(minutes: 5),
(_) {
if (_isOnline && _pendingActions.isNotEmpty) {
_syncPendingActions();
}
},
);
}
/// Ajoute une action à la queue hors ligne
Future<void> queueAction(OfflineAction action) async {
_pendingActions.add(action);
await _savePendingActions();
debugPrint('📝 Action mise en queue: ${action.type} (${_pendingActions.length} en attente)');
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
// Si en ligne, essayer de synchroniser immédiatement
if (_isOnline) {
_syncPendingActions();
}
}
/// Synchronise les actions en attente
Future<void> _syncPendingActions() async {
if (_isSyncing || _pendingActions.isEmpty || !_isOnline) {
return;
}
_isSyncing = true;
debugPrint('🔄 Début de la synchronisation (${_pendingActions.length} actions)');
_syncController.add(SyncProgress(
isActive: true,
totalActions: _pendingActions.length,
completedActions: 0,
currentAction: _pendingActions.first.type.toString(),
));
final actionsToSync = List<OfflineAction>.from(_pendingActions);
int completedCount = 0;
for (final action in actionsToSync) {
try {
await _executeAction(action);
_pendingActions.remove(action);
completedCount++;
_syncController.add(SyncProgress(
isActive: true,
totalActions: actionsToSync.length,
completedActions: completedCount,
currentAction: completedCount < actionsToSync.length
? actionsToSync[completedCount].type.toString()
: null,
));
debugPrint('✅ Action synchronisée: ${action.type}');
} catch (e) {
debugPrint('❌ Erreur lors de la synchronisation de ${action.type}: $e');
// Marquer l'action comme échouée si trop de tentatives
action.retryCount++;
if (action.retryCount >= 3) {
_pendingActions.remove(action);
debugPrint('🗑️ Action abandonnée après 3 tentatives: ${action.type}');
}
}
}
await _savePendingActions();
_lastSyncTime = DateTime.now();
await _saveLastSyncTime();
_syncController.add(SyncProgress(
isActive: false,
totalActions: actionsToSync.length,
completedActions: completedCount,
currentAction: null,
));
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
_isSyncing = false;
debugPrint('✅ Synchronisation terminée ($completedCount/${actionsToSync.length} réussies)');
}
/// Exécute une action spécifique
Future<void> _executeAction(OfflineAction action) async {
switch (action.type) {
case OfflineActionType.refreshDashboard:
await _syncDashboardData(action);
break;
case OfflineActionType.updatePreferences:
await _syncUserPreferences(action);
break;
case OfflineActionType.markActivityRead:
await _syncActivityRead(action);
break;
case OfflineActionType.joinEvent:
await _syncEventJoin(action);
break;
case OfflineActionType.exportReport:
await _syncReportExport(action);
break;
}
}
/// Synchronise les données du dashboard (rafraîchit le cache)
Future<void> _syncDashboardData(OfflineAction action) async {
final orgId = action.data['organizationId'] as String?;
final userId = action.data['userId'] as String?;
if (orgId == null || userId == null) return;
final response = await _apiClient.get('/api/dashboard/stats', queryParameters: {
'organisationId': orgId,
});
if (response.statusCode == 200 && response.data != null) {
await _cacheManager.setKey(
'dashboard_${orgId}_$userId',
response.data as Map<String, dynamic>,
);
}
}
/// Synchronise les préférences utilisateur
Future<void> _syncUserPreferences(OfflineAction action) async {
final userId = action.data['userId'] as String?;
final preferences = action.data['preferences'] as Map<String, dynamic>?;
if (userId == null || preferences == null) return;
await _apiClient.put('/api/membres/$userId/preferences', data: preferences);
}
/// Synchronise le marquage d'activité comme lue
Future<void> _syncActivityRead(OfflineAction action) async {
final activityId = action.data['activityId'] as String?;
if (activityId == null) return;
await _apiClient.put('/api/notifications/$activityId/read');
}
/// Synchronise l'inscription à un événement
Future<void> _syncEventJoin(OfflineAction action) async {
final eventId = action.data['eventId'] as String?;
final membreId = action.data['membreId'] as String?;
if (eventId == null || membreId == null) return;
await _apiClient.post('/api/evenements/$eventId/inscription', data: {
'membreId': membreId,
});
}
/// Synchronise l'export de rapport
Future<void> _syncReportExport(OfflineAction action) async {
final reportType = action.data['reportType'] as String?;
final params = action.data['params'] as Map<String, dynamic>?;
if (reportType == null) return;
await _apiClient.post('/api/export/$reportType', data: params ?? {});
}
/// Sauvegarde les actions en attente
Future<void> _savePendingActions() async {
if (_prefs == null) return;
final actionsJson = _pendingActions
.map((action) => action.toJson())
.toList();
await _prefs!.setString(_offlineQueueKey, jsonEncode(actionsJson));
}
/// Charge les actions en attente
Future<void> _loadPendingActions() async {
if (_prefs == null) return;
final actionsJsonString = _prefs!.getString(_offlineQueueKey);
if (actionsJsonString != null) {
try {
final actionsJson = jsonDecode(actionsJsonString) as List;
_pendingActions.clear();
_pendingActions.addAll(
actionsJson.map((json) => OfflineAction.fromJson(json)),
);
debugPrint('📋 ${_pendingActions.length} actions chargées depuis le cache');
} catch (e) {
debugPrint('❌ Erreur lors du chargement des actions: $e');
await _prefs!.remove(_offlineQueueKey);
}
}
}
/// Sauvegarde l'heure de dernière synchronisation
Future<void> _saveLastSyncTime() async {
if (_prefs == null || _lastSyncTime == null) return;
await _prefs!.setInt(_lastSyncKey, _lastSyncTime!.millisecondsSinceEpoch);
}
/// Charge l'heure de dernière synchronisation
void _loadLastSyncTime() {
if (_prefs == null) return;
final lastSyncMs = _prefs!.getInt(_lastSyncKey);
if (lastSyncMs != null) {
_lastSyncTime = DateTime.fromMillisecondsSinceEpoch(lastSyncMs);
}
}
/// Force une synchronisation manuelle
Future<void> forceSync() async {
if (!_isOnline) {
throw Exception('Impossible de synchroniser hors ligne');
}
await _syncPendingActions();
}
/// Obtient les données en mode hors ligne
Future<DashboardDataModel?> getOfflineData(
String organizationId,
String userId,
) async {
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
Future<bool> hasOfflineData(String organizationId, String userId) async {
final data = await getOfflineData(organizationId, userId);
return data != null;
}
/// Obtient les statistiques du mode hors ligne
OfflineStats getStats() {
return OfflineStats(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
isSyncing: _isSyncing,
cacheStats: _cacheManager.getCacheStats(),
);
}
/// Nettoie les anciennes actions
Future<void> cleanupOldActions() async {
final cutoffTime = DateTime.now().subtract(const Duration(days: 7));
_pendingActions.removeWhere((action) =>
action.timestamp.isBefore(cutoffTime));
await _savePendingActions();
}
/// Libère les ressources
void dispose() {
_connectivitySubscription?.cancel();
_syncTimer?.cancel();
_statusController.close();
_syncController.close();
}
}
/// Action hors ligne
class OfflineAction {
final String id;
final OfflineActionType type;
final Map<String, dynamic> data;
final DateTime timestamp;
int retryCount;
OfflineAction({
required this.id,
required this.type,
required this.data,
required this.timestamp,
this.retryCount = 0,
});
factory OfflineAction.fromJson(Map<String, dynamic> json) {
return OfflineAction(
id: json['id'] as String,
type: OfflineActionType.values.firstWhere(
(t) => t.name == json['type'],
),
data: json['data'] as Map<String, dynamic>,
timestamp: DateTime.parse(json['timestamp'] as String),
retryCount: json['retryCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'data': data,
'timestamp': timestamp.toIso8601String(),
'retryCount': retryCount,
};
}
}
/// Types d'actions hors ligne
enum OfflineActionType {
refreshDashboard,
updatePreferences,
markActivityRead,
joinEvent,
exportReport,
}
/// Statut hors ligne
class OfflineStatus {
final bool isOnline;
final int pendingActionsCount;
final DateTime? lastSyncTime;
const OfflineStatus({
required this.isOnline,
required this.pendingActionsCount,
this.lastSyncTime,
});
String get statusText {
if (isOnline) {
if (pendingActionsCount > 0) {
return 'En ligne - $pendingActionsCount actions en attente';
} else {
return 'En ligne - Synchronisé';
}
} else {
return 'Hors ligne - Mode cache activé';
}
}
}
/// Progression de synchronisation
class SyncProgress {
final bool isActive;
final int totalActions;
final int completedActions;
final String? currentAction;
const SyncProgress({
required this.isActive,
required this.totalActions,
required this.completedActions,
this.currentAction,
});
double get progress {
if (totalActions == 0) return 1.0;
return completedActions / totalActions;
}
String get progressText {
if (!isActive) return 'Synchronisation terminée';
if (currentAction != null) {
return 'Synchronisation: $currentAction ($completedActions/$totalActions)';
}
return 'Synchronisation en cours... ($completedActions/$totalActions)';
}
}
/// Statistiques du mode hors ligne
class OfflineStats {
final bool isOnline;
final int pendingActionsCount;
final DateTime? lastSyncTime;
final bool isSyncing;
final Map<String, dynamic> cacheStats;
const OfflineStats({
required this.isOnline,
required this.pendingActionsCount,
this.lastSyncTime,
required this.isSyncing,
required this.cacheStats,
});
String get lastSyncText {
if (lastSyncTime == null) return 'Jamais synchronisé';
final now = DateTime.now();
final diff = now.difference(lastSyncTime!);
if (diff.inMinutes < 1) return 'Synchronisé à l\'instant';
if (diff.inMinutes < 60) return 'Synchronisé il y a ${diff.inMinutes}min';
if (diff.inHours < 24) return 'Synchronisé il y a ${diff.inHours}h';
return 'Synchronisé il y a ${diff.inDays}j';
}
}

View File

@@ -0,0 +1,528 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../../config/dashboard_config.dart';
/// Moniteur de performances avancé pour le Dashboard
class DashboardPerformanceMonitor {
static const String _channelName = 'dashboard_performance';
static const MethodChannel _channel = MethodChannel(_channelName);
Timer? _monitoringTimer;
Timer? _reportTimer;
final List<PerformanceSnapshot> _snapshots = [];
final StreamController<PerformanceMetrics> _metricsController =
StreamController<PerformanceMetrics>.broadcast();
final StreamController<PerformanceAlert> _alertController =
StreamController<PerformanceAlert>.broadcast();
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');
final int _networkLatencyThreshold = DashboardConfig.getAlertThreshold('networkLatency').toInt();
final double _frameRateThreshold = DashboardConfig.getAlertThreshold('frameRate');
// Streams publics
Stream<PerformanceMetrics> get metricsStream => _metricsController.stream;
Stream<PerformanceAlert> get alertStream => _alertController.stream;
/// Démarre le monitoring des performances
Future<void> startMonitoring() async {
if (_isMonitoring) return;
debugPrint('🔍 Démarrage du monitoring des performances...');
_isMonitoring = true;
_startTime = DateTime.now();
// Timer pour collecter les métriques
_monitoringTimer = Timer.periodic(
DashboardConfig.performanceCheckInterval,
(_) => _collectMetrics(),
);
// Timer pour générer les rapports
_reportTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => _generateReport(),
);
// Collecte initiale
await _collectMetrics();
debugPrint('✅ Monitoring des performances démarré');
}
/// Arrête le monitoring
void stopMonitoring() {
if (!_isMonitoring) return;
_isMonitoring = false;
_monitoringTimer?.cancel();
_reportTimer?.cancel();
debugPrint('🛑 Monitoring des performances arrêté');
}
/// Collecte les métriques de performance
Future<void> _collectMetrics() async {
try {
final metrics = await _gatherMetrics();
final snapshot = PerformanceSnapshot(
timestamp: DateTime.now(),
metrics: metrics,
);
_snapshots.add(snapshot);
// Garder seulement les 1000 derniers snapshots
if (_snapshots.length > 1000) {
_snapshots.removeAt(0);
}
// Émettre les métriques
_metricsController.add(metrics);
// Vérifier les seuils d'alerte
_checkAlerts(metrics);
} catch (e) {
debugPrint('❌ Erreur lors de la collecte des métriques: $e');
}
}
/// Rassemble toutes les métriques
Future<PerformanceMetrics> _gatherMetrics() async {
final memoryUsage = await _getMemoryUsage();
final cpuUsage = await _getCpuUsage();
final networkLatency = await _getNetworkLatency();
final frameRate = await _getFrameRate();
final batteryLevel = await _getBatteryLevel();
final diskUsage = await _getDiskUsage();
final networkUsage = await _getNetworkUsage();
return PerformanceMetrics(
timestamp: DateTime.now(),
memoryUsage: memoryUsage,
cpuUsage: cpuUsage,
networkLatency: networkLatency,
frameRate: frameRate,
batteryLevel: batteryLevel,
diskUsage: diskUsage,
networkUsage: networkUsage,
uptime: DateTime.now().difference(_startTime),
);
}
/// Obtient l'utilisation mémoire
Future<double> _getMemoryUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getMemoryUsage');
return (result as num).toDouble();
} else {
// Simulation pour les autres plateformes
return _simulateMemoryUsage();
}
} catch (e) {
return _simulateMemoryUsage();
}
}
/// Obtient l'utilisation CPU
Future<double> _getCpuUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getCpuUsage');
return (result as num).toDouble();
} else {
return _simulateCpuUsage();
}
} catch (e) {
return _simulateCpuUsage();
}
}
/// 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();
final socket = await Socket.connect(host, port).timeout(const Duration(seconds: 5));
stopwatch.stop();
await socket.close();
return stopwatch.elapsedMilliseconds;
} catch (e) {
return _simulateNetworkLatency();
}
}
/// Obtient le frame rate
Future<double> _getFrameRate() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getFrameRate');
return (result as num).toDouble();
} else {
return _simulateFrameRate();
}
} catch (e) {
return _simulateFrameRate();
}
}
/// Obtient le niveau de batterie
Future<double> _getBatteryLevel() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getBatteryLevel');
return (result as num).toDouble();
} else {
return _simulateBatteryLevel();
}
} catch (e) {
return _simulateBatteryLevel();
}
}
/// Obtient l'utilisation disque
Future<double> _getDiskUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getDiskUsage');
return (result as num).toDouble();
} else {
return _simulateDiskUsage();
}
} catch (e) {
return _simulateDiskUsage();
}
}
/// Obtient l'utilisation réseau
Future<NetworkUsage> _getNetworkUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getNetworkUsage');
return NetworkUsage(
bytesReceived: (result['bytesReceived'] as num).toDouble(),
bytesSent: (result['bytesSent'] as num).toDouble(),
);
} else {
return _simulateNetworkUsage();
}
} catch (e) {
return _simulateNetworkUsage();
}
}
/// Vérifie les seuils d'alerte
void _checkAlerts(PerformanceMetrics metrics) {
// Alerte mémoire
if (metrics.memoryUsage > _memoryThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.memory,
severity: AlertSeverity.warning,
message: 'Utilisation mémoire élevée: ${metrics.memoryUsage.toStringAsFixed(1)}MB',
value: metrics.memoryUsage,
threshold: _memoryThreshold,
timestamp: DateTime.now(),
));
}
// Alerte CPU
if (metrics.cpuUsage > _cpuThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.cpu,
severity: AlertSeverity.warning,
message: 'Utilisation CPU élevée: ${metrics.cpuUsage.toStringAsFixed(1)}%',
value: metrics.cpuUsage,
threshold: _cpuThreshold,
timestamp: DateTime.now(),
));
}
// Alerte latence réseau
if (metrics.networkLatency > _networkLatencyThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.network,
severity: AlertSeverity.error,
message: 'Latence réseau élevée: ${metrics.networkLatency}ms',
value: metrics.networkLatency.toDouble(),
threshold: _networkLatencyThreshold.toDouble(),
timestamp: DateTime.now(),
));
}
// Alerte frame rate
if (metrics.frameRate < _frameRateThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.performance,
severity: AlertSeverity.warning,
message: 'Frame rate faible: ${metrics.frameRate.toStringAsFixed(1)}fps',
value: metrics.frameRate,
threshold: _frameRateThreshold,
timestamp: DateTime.now(),
));
}
}
/// Génère un rapport de performance
void _generateReport() {
if (_snapshots.isEmpty) return;
final recentSnapshots = _snapshots.where((snapshot) =>
DateTime.now().difference(snapshot.timestamp).inMinutes <= 5).toList();
if (recentSnapshots.isEmpty) return;
final report = PerformanceReport.fromSnapshots(recentSnapshots);
debugPrint('📊 RAPPORT DE PERFORMANCE (5 min)');
debugPrint('Mémoire: ${report.averageMemoryUsage.toStringAsFixed(1)}MB (max: ${report.maxMemoryUsage.toStringAsFixed(1)}MB)');
debugPrint('CPU: ${report.averageCpuUsage.toStringAsFixed(1)}% (max: ${report.maxCpuUsage.toStringAsFixed(1)}%)');
debugPrint('Latence: ${report.averageNetworkLatency.toStringAsFixed(0)}ms (max: ${report.maxNetworkLatency.toStringAsFixed(0)}ms)');
debugPrint('FPS: ${report.averageFrameRate.toStringAsFixed(1)}fps (min: ${report.minFrameRate.toStringAsFixed(1)}fps)');
}
/// Obtient les statistiques de performance
PerformanceStats getStats() {
if (_snapshots.isEmpty) {
return PerformanceStats.empty();
}
return PerformanceStats.fromSnapshots(_snapshots, alertsGenerated: _alertsGeneratedCount);
}
/// Méthodes de simulation pour le développement
double _simulateMemoryUsage() {
const base = 200.0;
final variation = 100.0 * (DateTime.now().millisecond / 1000.0);
return base + variation;
}
double _simulateCpuUsage() {
const base = 30.0;
final variation = 40.0 * (DateTime.now().second / 60.0);
return (base + variation).clamp(0.0, 100.0);
}
int _simulateNetworkLatency() {
const base = 150;
final variation = (200 * (DateTime.now().millisecond / 1000.0)).round();
return base + variation;
}
double _simulateFrameRate() {
const base = 58.0;
final variation = 5.0 * (DateTime.now().millisecond / 1000.0);
return (base + variation).clamp(30.0, 60.0);
}
double _simulateBatteryLevel() {
final elapsed = DateTime.now().difference(_startTime).inMinutes;
return (100.0 - elapsed * 0.1).clamp(0.0, 100.0);
}
double _simulateDiskUsage() {
return 45.0 + (10.0 * (DateTime.now().millisecond / 1000.0));
}
NetworkUsage _simulateNetworkUsage() {
const base = 1024.0;
final variation = 512.0 * (DateTime.now().millisecond / 1000.0);
return NetworkUsage(
bytesReceived: base + variation,
bytesSent: (base + variation) * 0.3,
);
}
/// Libère les ressources
void dispose() {
stopMonitoring();
_metricsController.close();
_alertController.close();
_snapshots.clear();
}
}
/// Métriques de performance
class PerformanceMetrics {
final DateTime timestamp;
final double memoryUsage; // MB
final double cpuUsage; // %
final int networkLatency; // ms
final double frameRate; // fps
final double batteryLevel; // %
final double diskUsage; // %
final NetworkUsage networkUsage;
final Duration uptime;
const PerformanceMetrics({
required this.timestamp,
required this.memoryUsage,
required this.cpuUsage,
required this.networkLatency,
required this.frameRate,
required this.batteryLevel,
required this.diskUsage,
required this.networkUsage,
required this.uptime,
});
}
/// Utilisation réseau
class NetworkUsage {
final double bytesReceived;
final double bytesSent;
const NetworkUsage({
required this.bytesReceived,
required this.bytesSent,
});
double get totalBytes => bytesReceived + bytesSent;
}
/// Snapshot de performance
class PerformanceSnapshot {
final DateTime timestamp;
final PerformanceMetrics metrics;
const PerformanceSnapshot({
required this.timestamp,
required this.metrics,
});
}
/// Alerte de performance
class PerformanceAlert {
final AlertType type;
final AlertSeverity severity;
final String message;
final double value;
final double threshold;
final DateTime timestamp;
const PerformanceAlert({
required this.type,
required this.severity,
required this.message,
required this.value,
required this.threshold,
required this.timestamp,
});
}
/// Type d'alerte
enum AlertType { memory, cpu, network, performance, battery, disk }
/// Sévérité d'alerte
enum AlertSeverity { info, warning, error, critical }
/// Rapport de performance
class PerformanceReport {
final DateTime startTime;
final DateTime endTime;
final double averageMemoryUsage;
final double maxMemoryUsage;
final double averageCpuUsage;
final double maxCpuUsage;
final double averageNetworkLatency;
final double maxNetworkLatency;
final double averageFrameRate;
final double minFrameRate;
const PerformanceReport({
required this.startTime,
required this.endTime,
required this.averageMemoryUsage,
required this.maxMemoryUsage,
required this.averageCpuUsage,
required this.maxCpuUsage,
required this.averageNetworkLatency,
required this.maxNetworkLatency,
required this.averageFrameRate,
required this.minFrameRate,
});
factory PerformanceReport.fromSnapshots(List<PerformanceSnapshot> snapshots) {
if (snapshots.isEmpty) {
throw ArgumentError('Cannot create report from empty snapshots');
}
final metrics = snapshots.map((s) => s.metrics).toList();
return PerformanceReport(
startTime: snapshots.first.timestamp,
endTime: snapshots.last.timestamp,
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
maxMemoryUsage: 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,
maxCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
averageNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a + b) / metrics.length,
maxNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a > b ? a : b),
averageFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a + b) / metrics.length,
minFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a < b ? a : b),
);
}
}
/// Statistiques de performance
class PerformanceStats {
final int totalSnapshots;
final Duration totalUptime;
final double averageMemoryUsage;
final double peakMemoryUsage;
final double averageCpuUsage;
final double peakCpuUsage;
final int alertsGenerated;
const PerformanceStats({
required this.totalSnapshots,
required this.totalUptime,
required this.averageMemoryUsage,
required this.peakMemoryUsage,
required this.averageCpuUsage,
required this.peakCpuUsage,
required this.alertsGenerated,
});
factory PerformanceStats.empty() {
return const PerformanceStats(
totalSnapshots: 0,
totalUptime: Duration.zero,
averageMemoryUsage: 0.0,
peakMemoryUsage: 0.0,
averageCpuUsage: 0.0,
peakCpuUsage: 0.0,
alertsGenerated: 0,
);
}
factory PerformanceStats.fromSnapshots(List<PerformanceSnapshot> snapshots, {int alertsGenerated = 0}) {
if (snapshots.isEmpty) return PerformanceStats.empty();
final metrics = snapshots.map((s) => s.metrics).toList();
return PerformanceStats(
totalSnapshots: snapshots.length,
totalUptime: snapshots.last.timestamp.difference(snapshots.first.timestamp),
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
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: alertsGenerated,
);
}
}