Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
222
lib/features/dashboard/data/models/dashboard_stats_model.dart
Normal file
222
lib/features/dashboard/data/models/dashboard_stats_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
130
lib/features/dashboard/data/models/dashboard_stats_model.g.dart
Normal file
130
lib/features/dashboard/data/models/dashboard_stats_model.g.dart
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user